hoauth2 2.13.1 → 2.15.1
raw patch · 32 files changed
Files
- CHANGELOG.md +161/−0
- README.md +11/−0
- README.org +0/−8
- hoauth2.cabal +32/−20
- src/Network/OAuth/OAuth2.hs +0/−26
- src/Network/OAuth/OAuth2/AuthorizationRequest.hs +0/−88
- src/Network/OAuth/OAuth2/HttpClient.hs +0/−332
- src/Network/OAuth/OAuth2/Internal.hs +0/−190
- src/Network/OAuth/OAuth2/TokenRequest.hs +0/−346
- src/Network/OAuth2.hs +26/−0
- src/Network/OAuth2/AuthorizationRequest.hs +88/−0
- src/Network/OAuth2/Experiment.hs +34/−38
- src/Network/OAuth2/Experiment/Flows.hs +273/−0
- src/Network/OAuth2/Experiment/Flows/AuthorizationRequest.hs +0/−45
- src/Network/OAuth2/Experiment/Flows/DeviceAuthorizationRequest.hs +6/−47
- src/Network/OAuth2/Experiment/Flows/RefreshTokenRequest.hs +6/−20
- src/Network/OAuth2/Experiment/Flows/TokenRequest.hs +18/−60
- src/Network/OAuth2/Experiment/Flows/UserInfoRequest.hs +0/−37
- src/Network/OAuth2/Experiment/Grants.hs +0/−1
- src/Network/OAuth2/Experiment/Grants/AuthorizationCode.hs +52/−37
- src/Network/OAuth2/Experiment/Grants/ClientCredentials.hs +14/−16
- src/Network/OAuth2/Experiment/Grants/DeviceAuthorization.hs +19/−69
- src/Network/OAuth2/Experiment/Grants/JwtBearer.hs +2/−11
- src/Network/OAuth2/Experiment/Grants/ResourceOwnerPassword.hs +8/−8
- src/Network/OAuth2/Experiment/Types.hs +8/−28
- src/Network/OAuth2/HttpClient.hs +245/−0
- src/Network/OAuth2/Internal.hs +116/−0
- src/Network/OAuth2/TokenRequest.hs +362/−0
- test/Network/OAuth/OAuth2/TokenRequestSpec.hs +0/−56
- test/Network/OAuth2/InternalSpec.hs +69/−0
- test/Network/OAuth2/TokenRequestSpec.hs +56/−0
- test/Network/OAuth2/TokenResponseSpec.hs +48/−0
+ CHANGELOG.md view
@@ -0,0 +1,161 @@+# hoauth2 Changelog++## 2.15.1 (2026-04-17)+- Dependency changes+ - Relax the `containers` upper bound to `< 0.9` so `containers-0.8` is supported.+ - No API or behavior changes.++## 2.15.0 (2025-10-05)+- Breaking changes+ - Type and class renames+ - `OAuth2Token` renamed to `TokenResponse`.+ - `TokenRequestAuthenicationMethod` renamed to `ClientAuthenticationMethod`.+ - `HasTokenRequestClientAuthenticationMethod` renamed to `HasClientAuthenticationMethod`.+ - Removed several `Has*` type classes (e.g., `HasOAuth2Key`, `HasOAuthKey` from `HasTokenRequest`).+ - Removed deprecated API in `Network.OAuth2.HttpClient`+ - Deprecated functions have been removed. Use `authGetJSON`/`authGetBS`/`authPostJSON`/`authPostBS` and the `WithAuthMethod` variants with `APIAuthenticationMethod`.+ - Request body encoding behavior+ - `authPostJSON` now sends JSON with `Content-Type: application/json`. If an endpoint requires form-encoded data, use `authPostBS` or construct a custom request body.+ - Import/module path cleanup+ - Consumers should import `Network.OAuth2` (not `Network.OAuth.OAuth2`).+- Behavioral changes and bug fixes+ - Client authentication handling+ - Append `client_id` and `client_secret` for `ClientSecretPost` in client credentials flow.+ - Refresh token flow honors the configured client authentication method.+ - Authorization Code flow supports client authentication methods.+ - Device Authorization flow+ - Updated handling of device authorization requests and polling behavior.+- Refactors and internal cleanup+ - Refactored header handling, simplified URI-to-Request conversion, removed deprecated or unused internals, and general cleanups.+- Dependency changes+ - Allow `microlens` 0.5 (`microlens` >= 0.4 && < 0.6).+ - Raise lower bound to `uri-bytestring` >= 0.4 (< 0.5).++## 2.14.3 (2025-03-14)++- Fixes and improvements:+ - Append client_id and client_secret for ClientSecretPost in client credentials flow+ - Add raw response to token response and derive Show instances+ - Skip empty scope; assorted refactors and cleanups++## 2.14.2 (2025-01-30)++* Updated uri-bytestring to version 0.4++## 2.14.1 (2024-11-19)++* Updated data-default to version 0.8++## 2.14.0 (2024-11-19)++* Updated crypton to version 1.0++## 2.13.0 (2024-03-07)++* Replaced cryptonite with crypton++## 2.12.0 (2024-01-19)++* Updated base64 to version 1.0++## 2.11.0 (2023-12-30)++* Updated aeson to version 2.2+* Updated binary to version 0.10+* Updated bytestring to version 0.12+* Updated container to version 0.7++## 2.10.0 (2023-11-17)++* Added support for text 2.2++## 2.9.0 (2023-10-26)++* Refactored oauth2 Experiment module implementation+* Removed generics+* Added Device Authorization grant+* Moved IdpName to hoauth2 and enabled DataKinds+* Changed the type parameter in HttpClient methods+* Removed data family `RefreshTokenRequest`++## 2.8.1 (2023-06-17)++* Added support for GHC-9.6+* Updated CI configuration for GHC 9.6.1+* Added hiedb integration++## 2.8.0 (2023-03-15)++* Added support for GHC-9.4.4+* Added support for text-2.0++## 2.7 (2022-11-17)++* Added GrantType jwt-bearer+* Added JWT authentication method for ClientCredential flow+* Replaced `OAuth2Error` with `TokenRequestError`+* Moved `HasIdpName` class to hoauth2-demo+* Improved error handling when response body is empty in `HttpClient.handleResponse`+* Removed the following modules from exposure list:+ - Network.OAuth2.Experiment.Pkce+ - Network.OAuth2.Experiment.Types+ - Network.OAuth2.Experiment.Utils+ - Network.OAuth.OAuth2.Internal++## 2.6 (2022-10-04)++* Changed type parameter order in http client JSON method+* Modified http client to only accept one Authentication Method instead of a Set+* Removed `authPostBS1` (non-standard approach to sending credentials)+* Removed Douban IdP (discontinued OAuth2 support)+* Deprecated all *Internal methods, added *WithAuthMethod alternatives+* Changed license to MIT+* Added support for PKCE flow in `Network.OAuth2.Experiment` module+* Added support for Resource Owner Password and Client Credentials flows+* Added `hoauth2-providers` and `hoauth2-providers-tutorial` modules+* Added `hoauth2-tutorial` module++## 2.5 (2022-08-17)++* Updated aeson to version 2.1++## 2.4 (2022-08-17)++* Relaxed binary and bytestring version constraints++## 2.1 (2022-02-19)++* Added documentation for OAuth2 specification+* Updated aeson to version 2++## 2.0 (2022-02-15)++* Breaking change: Refactored naming convention of `OAuth2` data type+ ```diff+ - { oauthClientId = "xxxxxxxxxxxxxxx"+ - , oauthClientSecret = Just "xxxxxxxxxxxxxxxxxxxxxx"+ - , oauthCallback = Just [uri|http://127.0.0.1:9988/oauthCallback|]+ - , oauthOAuthorizeEndpoint = [uri|https://api.weibo.com/oauth2/authorize|]+ - , oauthAccessTokenEndpoint = [uri|https://api.weibo.com/oauth2/access_token|]+ + { oauth2ClientId = "xxxxxxxxxxxxxxx"+ + , oauth2ClientSecret = Just "xxxxxxxxxxxxxxxxxxxxxx"+ + , oauth2RedirectUri = Just [uri|http://127.0.0.1:9988/oauthCallback|]+ + , oauth2AuthorizeEndpoint = [uri|https://api.weibo.com/oauth2/authorize|]+ + , oauth2TokenEndpoint = [uri|https://api.weibo.com/oauth2/access_token|]+ + }+ ```++## 1.7.0 (2018-03-03)++* Added sample server and removed individual sample tests+* Added `fetchAccessToken2` function+* Added `refreshAccessToken` function and deprecated `fetchRefreshToken`+* Renamed `authGetBS'` to `authGetBS2`+* Renamed `authPostBS'` to `authPostBS2`+* Added `authPostBS3` function++## 1.0.0 (2017-04-07)++* Added umbrella type `OAuth2Token` to accommodate `AccessToken` and `RefreshToken`+* Typed the intermediate authentication code as `ExchangeToken`+* Fixed missing client_id error in tests by appending client_id and client_secret to header
+ README.md view
@@ -0,0 +1,11 @@+Haskell bindings for++- [The OAuth 2.0 Authorization Framework](https://datatracker.ietf.org/doc/html/rfc6749)+ - If the Identity Provider also implements [OIDC+ spec](https://openid.net/specs/openid-connect-core-1_0.html), ID+ Token will also be present in the token response (see+ `TokenResponse`).+- [JWT Profile for OAuth2 Client Authentication and Authorization+ Grants](https://www.rfc-editor.org/rfc/rfc7523.html)+- [The OAuth 2.0 Authorization Framework: Bearer Token+ Usage](https://www.rfc-editor.org/rfc/rfc6750)
− README.org
@@ -1,8 +0,0 @@-* Introduction--Haskell binding for--- [[https://datatracker.ietf.org/doc/html/rfc6749][The OAuth 2.0 Authorization Framework]]- - If the Identity Provider also implements [[https://openid.net/specs/openid-connect-core-1_0.html][OIDC spec]], ID Token will also be present in token response (see ~OAuth2Token~).-- [[https://www.rfc-editor.org/rfc/rfc7523.html][JWT Profile for OAuth2 Client Authentication and Authorization Grants]]-- [[https://www.rfc-editor.org/rfc/rfc6750][The OAuth 2.0 Authorization Framework: Bearer Token Usage]]
hoauth2.cabal view
@@ -2,10 +2,10 @@ name: hoauth2 -- http://wiki.haskell.org/Package_versioning_policy-version: 2.13.1+version: 2.15.1 synopsis: Haskell OAuth2 authentication client description:- This is Haskell binding of OAuth2 Authorization framework and Bearer Token Usage framework.+ This package provides Haskell bindings for the OAuth2 Authorization Framework and Bearer Token Usage. homepage: https://github.com/freizl/hoauth2 license: MIT@@ -16,12 +16,16 @@ category: Network build-type: Simple stability: Beta-tested-with: GHC <=9.6.1-extra-source-files: README.org+tested-with:+ GHC ==8.10.7 || ==9.0.2 || ==9.2.7 || ==9.4.4 || ==9.6.1 || ==9.8.2 +extra-source-files:+ CHANGELOG.md+ README.md+ source-repository head type: git- location: git://github.com/freizl/hoauth2.git+ location: https://github.com/freizl/hoauth2.git library hs-source-dirs: src@@ -29,17 +33,18 @@ autogen-modules: Paths_hoauth2 other-modules: Network.HTTP.Client.Contrib- Network.OAuth.OAuth2.Internal+ Network.OAuth2.Internal Network.OAuth2.Experiment.Grants Network.OAuth2.Experiment.Utils Paths_hoauth2 exposed-modules:- Network.OAuth.OAuth2- Network.OAuth.OAuth2.AuthorizationRequest- Network.OAuth.OAuth2.HttpClient- Network.OAuth.OAuth2.TokenRequest+ Network.OAuth2+ Network.OAuth2.AuthorizationRequest+ Network.OAuth2.HttpClient+ Network.OAuth2.TokenRequest Network.OAuth2.Experiment+ Network.OAuth2.Experiment.Flows Network.OAuth2.Experiment.Flows.AuthorizationRequest Network.OAuth2.Experiment.Flows.DeviceAuthorizationRequest Network.OAuth2.Experiment.Flows.RefreshTokenRequest@@ -68,18 +73,19 @@ , base >=4.11 && <5 , base64 >=1.0 && <1.1 , binary >=0.8 && <0.11+ , binary-instances >=1.0 && <1.1 , bytestring >=0.9 && <0.13- , containers >=0.6 && <0.8- , crypton >=0.32 && <0.35- , data-default ^>=0.7+ , containers >=0.6 && <0.9+ , crypton >=0.32 && <1.1+ , data-default ^>=0.8 , exceptions >=0.8.3 && <0.11 , http-conduit >=2.1 && <2.4 , http-types >=0.11 && <0.13 , memory ^>=0.18- , microlens ^>=0.4.0+ , microlens >=0.4 && <0.6 , text >=2.0 && <2.3 , transformers >=0.4 && <0.7- , uri-bytestring >=0.2.3 && <0.4+ , uri-bytestring >=0.4 && <0.5 , uri-bytestring-aeson ^>=0.1 ghc-options:@@ -92,13 +98,19 @@ hs-source-dirs: test ghc-options: -Wall build-depends:- , aeson >=2.0 && <2.3- , base >=4.11 && <5+ , aeson >=2.0 && <2.3+ , base >=4.11 && <5+ , binary >=0.8 && <0.11 , hoauth2- , hspec >=2 && <3- , uri-bytestring >=0.2.3 && <0.4+ , hspec >=2 && <3+ , uri-bytestring >=0.4 && <0.5+ , http-conduit >=2.1 && <2.4 - other-modules: Network.OAuth.OAuth2.TokenRequestSpec+ other-modules:+ Network.OAuth2.InternalSpec+ Network.OAuth2.TokenRequestSpec+ Network.OAuth2.TokenResponseSpec+ default-language: Haskell2010 default-extensions: ImportQualifiedPost
− src/Network/OAuth/OAuth2.hs
@@ -1,26 +0,0 @@--- | A lightweight oauth2 Haskell binding.--- See Readme for more details-module Network.OAuth.OAuth2 (- module Network.OAuth.OAuth2.Internal,-- -- * Authorization Requset- module Network.OAuth.OAuth2.AuthorizationRequest,-- -- * Token Request- module Network.OAuth.OAuth2.TokenRequest,-- -- * OAuth'ed http client utilities- module Network.OAuth.OAuth2.HttpClient,-) where--{-- Hiding Errors data type from default.- Shall qualified import given the naming collision.--}-import Network.OAuth.OAuth2.AuthorizationRequest hiding (- AuthorizationResponseError (..),- AuthorizationResponseErrorCode (..),- )-import Network.OAuth.OAuth2.HttpClient-import Network.OAuth.OAuth2.Internal-import Network.OAuth.OAuth2.TokenRequest
− src/Network/OAuth/OAuth2/AuthorizationRequest.hs
@@ -1,88 +0,0 @@--- | Bindings Authorization part of The OAuth 2.0 Authorization Framework--- RFC6749 <https://www.rfc-editor.org/rfc/rfc6749>-module Network.OAuth.OAuth2.AuthorizationRequest where--import Data.Aeson-import Data.Function (on)-import Data.List qualified as List-import Data.Text (Text)-import Data.Text.Encoding qualified as T-import Lens.Micro (over)-import Network.OAuth.OAuth2.Internal-import URI.ByteString-import Prelude hiding (error)-------------------------------------------------------- * Authorization Request Errors-------------------------------------------------------- | Authorization Code Grant Error Responses https://tools.ietf.org/html/rfc6749#section-4.1.2.1------ I found hard time to figure a way to test the authorization error flow--- When anything wrong in @/authorize@ request, it will stuck at the Provider page--- hence no way for this library to parse error response.--- In other words, @/authorize@ ends up with 4xx or 5xx.--- Revisit this whenever find a case OAuth2 provider redirects back to Relying party with errors.-data AuthorizationResponseError = AuthorizationResponseError- { authorizationResponseError :: AuthorizationResponseErrorCode- , authorizationResponseErrorDescription :: Maybe Text- , authorizationResponseErrorUri :: Maybe (URIRef Absolute)- }- deriving (Show, Eq)--data AuthorizationResponseErrorCode- = InvalidRequest- | UnauthorizedClient- | AccessDenied- | UnsupportedResponseType- | InvalidScope- | ServerError- | TemporarilyUnavailable- | UnknownErrorCode Text- deriving (Show, Eq)--instance FromJSON AuthorizationResponseErrorCode where- parseJSON = withText "parseJSON AuthorizationResponseErrorCode" $ \t ->- pure $ case t of- "invalid_request" -> InvalidRequest- "unauthorized_client" -> UnauthorizedClient- "access_denied" -> AccessDenied- "unsupported_response_type" -> UnsupportedResponseType- "invalid_scope" -> InvalidScope- "server_error" -> ServerError- "temporarily_unavailable" -> TemporarilyUnavailable- _ -> UnknownErrorCode t--instance FromJSON AuthorizationResponseError where- parseJSON = withObject "parseJSON AuthorizationResponseError" $ \t -> do- authorizationResponseError <- t .: "error"- authorizationResponseErrorDescription <- t .:? "error_description"- authorizationResponseErrorUri <- t .:? "error_uri"- pure AuthorizationResponseError {..}-------------------------------------------------------- * URLs-------------------------------------------------------- | See 'authorizationUrlWithParams'-authorizationUrl :: OAuth2 -> URI-authorizationUrl = authorizationUrlWithParams []---- | Prepare the authorization URL. Redirect to this URL--- asking for user interactive authentication.------ @since 2.6.0-authorizationUrlWithParams :: QueryParams -> OAuth2 -> URI-authorizationUrlWithParams qs oa = over (queryL . queryPairsL) (++ queryParts) (oauth2AuthorizeEndpoint oa)- where- queryParts =- List.nubBy ((==) `on` fst) $- qs- ++ [ ("client_id", T.encodeUtf8 $ oauth2ClientId oa)- , ("response_type", "code")- , ("redirect_uri", serializeURIRef' $ oauth2RedirectUri oa)- ]
− src/Network/OAuth/OAuth2/HttpClient.hs
@@ -1,332 +0,0 @@--- | Bindings for The OAuth 2.0 Authorization Framework: Bearer Token Usage--- RFC6750 <https://www.rfc-editor.org/rfc/rfc6750>-module Network.OAuth.OAuth2.HttpClient (- -- * AUTH requests- authGetJSON,- authGetBS,- authGetBS2,- authGetJSONWithAuthMethod,- authGetJSONInternal,- authGetBSWithAuthMethod,- authGetBSInternal,- authPostJSON,- authPostBS,- authPostBS2,- authPostBS3,- authPostJSONWithAuthMethod,- authPostJSONInternal,- authPostBSWithAuthMethod,- authPostBSInternal,-- -- * Types- APIAuthenticationMethod (..),-) where--import Control.Monad.IO.Class (MonadIO (..))-import Control.Monad.Trans.Except (ExceptT (..), throwE)-import Data.Aeson (FromJSON, eitherDecode)-import Data.ByteString.Char8 qualified as BS-import Data.ByteString.Lazy.Char8 qualified as BSL-import Data.Maybe (fromJust, isJust)-import Data.Text.Encoding qualified as T-import Lens.Micro (over)-import Network.HTTP.Client.Contrib (handleResponse)-import Network.HTTP.Conduit-import Network.HTTP.Types qualified as HT-import Network.OAuth.OAuth2.Internal-import URI.ByteString (URI, URIRef, queryL, queryPairsL)-------------------------------------------------------- * AUTH requests---- Making request with Access Token appended to Header, Request body or query string.---------------------------------------------------------- | Conduct an authorized GET request and return response as JSON.--- Inject Access Token to Authorization Header.-authGetJSON ::- (MonadIO m, FromJSON a) =>- -- | HTTP connection manager.- Manager ->- AccessToken ->- URI ->- -- | Response as JSON- ExceptT BSL.ByteString m a-authGetJSON = authGetJSONWithAuthMethod AuthInRequestHeader---- | Deprecated. Use `authGetJSONWithAuthMethod` instead.-authGetJSONInternal ::- (MonadIO m, FromJSON a) =>- APIAuthenticationMethod ->- -- | HTTP connection manager.- Manager ->- AccessToken ->- URI ->- -- | Response as JSON- ExceptT BSL.ByteString m a-authGetJSONInternal = authGetJSONWithAuthMethod-{-# DEPRECATED authGetJSONInternal "use authGetJSONWithAuthMethod" #-}---- | Conduct an authorized GET request and return response as JSON.--- Allow to specify how to append AccessToken.------ @since 2.6.0-authGetJSONWithAuthMethod ::- (MonadIO m, FromJSON a) =>- APIAuthenticationMethod ->- -- | HTTP connection manager.- Manager ->- AccessToken ->- URI ->- -- | Response as JSON- ExceptT BSL.ByteString m a-authGetJSONWithAuthMethod authTypes manager t uri = do- resp <- authGetBSWithAuthMethod authTypes manager t uri- either (throwE . BSL.pack) return (eitherDecode resp)---- | Conduct an authorized GET request.--- Inject Access Token to Authorization Header.-authGetBS ::- MonadIO m =>- -- | HTTP connection manager.- Manager ->- AccessToken ->- URI ->- -- | Response as ByteString- ExceptT BSL.ByteString m BSL.ByteString-authGetBS = authGetBSWithAuthMethod AuthInRequestHeader---- | Same to 'authGetBS' but set access token to query parameter rather than header-authGetBS2 ::- MonadIO m =>- -- | HTTP connection manager.- Manager ->- AccessToken ->- URI ->- -- | Response as ByteString- ExceptT BSL.ByteString m BSL.ByteString-authGetBS2 = authGetBSWithAuthMethod AuthInRequestQuery-{-# DEPRECATED authGetBS2 "use authGetBSWithAuthMethod" #-}--authGetBSInternal ::- MonadIO m =>- APIAuthenticationMethod ->- -- | HTTP connection manager.- Manager ->- AccessToken ->- URI ->- -- | Response as ByteString- ExceptT BSL.ByteString m BSL.ByteString-authGetBSInternal = authGetBSWithAuthMethod-{-# DEPRECATED authGetBSInternal "use authGetBSWithAuthMethod" #-}---- | Conduct an authorized GET request and return response as ByteString.--- Allow to specify how to append AccessToken.------ @since 2.6.0-authGetBSWithAuthMethod ::- MonadIO m =>- -- | Specify the way that how to append the 'AccessToken' in the request- APIAuthenticationMethod ->- -- | HTTP connection manager.- Manager ->- AccessToken ->- URI ->- -- | Response as ByteString- ExceptT BSL.ByteString m BSL.ByteString-authGetBSWithAuthMethod authTypes manager token url = do- let appendToUrl = AuthInRequestQuery == authTypes- let appendToHeader = AuthInRequestHeader == authTypes- let uri = if appendToUrl then url `appendAccessToken` token else url- let upReq = updateRequestHeaders (if appendToHeader then Just token else Nothing) . setMethod HT.GET- req <- liftIO $ uriToRequest uri- authRequest req upReq manager---- | Conduct POST request and return response as JSON.--- Inject Access Token to Authorization Header.-authPostJSON ::- (MonadIO m, FromJSON a) =>- -- | HTTP connection manager.- Manager ->- AccessToken ->- URI ->- PostBody ->- -- | Response as JSON- ExceptT BSL.ByteString m a-authPostJSON = authPostJSONWithAuthMethod AuthInRequestHeader--authPostJSONInternal ::- (MonadIO m, FromJSON a) =>- APIAuthenticationMethod ->- -- | HTTP connection manager.- Manager ->- AccessToken ->- URI ->- PostBody ->- -- | Response as ByteString- ExceptT BSL.ByteString m a-authPostJSONInternal = authPostJSONWithAuthMethod-{-# DEPRECATED authPostJSONInternal "use 'authPostJSONWithAuthMethod'" #-}---- | Conduct POST request and return response as JSON.--- Allow to specify how to append AccessToken.------ @since 2.6.0-authPostJSONWithAuthMethod ::- (MonadIO m, FromJSON a) =>- APIAuthenticationMethod ->- -- | HTTP connection manager.- Manager ->- AccessToken ->- URI ->- PostBody ->- -- | Response as ByteString- ExceptT BSL.ByteString m a-authPostJSONWithAuthMethod authTypes manager token url body = do- resp <- authPostBSWithAuthMethod authTypes manager token url body- either (throwE . BSL.pack) return (eitherDecode resp)---- | Conduct POST request.--- Inject Access Token to http header (Authorization)-authPostBS ::- MonadIO m =>- -- | HTTP connection manager.- Manager ->- AccessToken ->- URI ->- PostBody ->- -- | Response as ByteString- ExceptT BSL.ByteString m BSL.ByteString-authPostBS = authPostBSWithAuthMethod AuthInRequestHeader---- | Conduct POST request with access token only in the request body but header.-authPostBS2 ::- MonadIO m =>- -- | HTTP connection manager.- Manager ->- AccessToken ->- URI ->- PostBody ->- -- | Response as ByteString- ExceptT BSL.ByteString m BSL.ByteString-authPostBS2 = authPostBSWithAuthMethod AuthInRequestBody-{-# DEPRECATED authPostBS2 "use 'authPostBSWithAuthMethod'" #-}---- | Conduct POST request with access token only in the header and not in body-authPostBS3 ::- MonadIO m =>- -- | HTTP connection manager.- Manager ->- AccessToken ->- URI ->- PostBody ->- -- | Response as ByteString- ExceptT BSL.ByteString m BSL.ByteString-authPostBS3 = authPostBSWithAuthMethod AuthInRequestHeader-{-# DEPRECATED authPostBS3 "use 'authPostBSWithAuthMethod'" #-}--authPostBSInternal ::- MonadIO m =>- APIAuthenticationMethod ->- -- | HTTP connection manager.- Manager ->- AccessToken ->- URI ->- PostBody ->- -- | Response as ByteString- ExceptT BSL.ByteString m BSL.ByteString-authPostBSInternal = authPostBSWithAuthMethod-{-# DEPRECATED authPostBSInternal "use 'authPostBSWithAuthMethod'" #-}---- | Conduct POST request and return response as ByteString.--- Allow to specify how to append AccessToken.------ @since 2.6.0-authPostBSWithAuthMethod ::- MonadIO m =>- APIAuthenticationMethod ->- -- | HTTP connection manager.- Manager ->- AccessToken ->- URI ->- PostBody ->- -- | Response as ByteString- ExceptT BSL.ByteString m BSL.ByteString-authPostBSWithAuthMethod authTypes manager token url body = do- let appendToBody = AuthInRequestBody == authTypes- let appendToHeader = AuthInRequestHeader == authTypes- let reqBody = if appendToBody then body ++ accessTokenToParam token else body- -- TODO: urlEncodedBody send request as 'application/x-www-form-urlencoded'- -- seems shall go with application/json which is more common?- let upBody = if null reqBody then id else urlEncodedBody reqBody- let upHeaders = updateRequestHeaders (if appendToHeader then Just token else Nothing) . setMethod HT.POST- let upReq = upHeaders . upBody-- req <- liftIO $ uriToRequest url- authRequest req upReq manager-------------------------------------------------------- * Types-------------------------------------------------------- | https://www.rfc-editor.org/rfc/rfc6750#section-2-data APIAuthenticationMethod- = -- | Provides in Authorization header- AuthInRequestHeader- | -- | Provides in request body- AuthInRequestBody- | -- | Provides in request query parameter- AuthInRequestQuery- deriving (Eq, Ord)-------------------------------------------------------- * Utilities-------------------------------------------------------- | Send an HTTP request.-authRequest ::- MonadIO m =>- -- | Request to perform- Request ->- -- | Modify request before sending- (Request -> Request) ->- -- | HTTP connection manager.- Manager ->- ExceptT BSL.ByteString m BSL.ByteString-authRequest req upReq manage = ExceptT $ do- resp <- httpLbs (upReq req) manage- pure (handleResponse resp)---- | Set several header values:--- + userAgennt : "hoauth2"--- + accept : "application/json"--- + authorization : "Bearer xxxxx" if 'Network.OAuth.OAuth2.AccessToken' provided.-updateRequestHeaders :: Maybe AccessToken -> Request -> Request-updateRequestHeaders t req =- let bearer = [(HT.hAuthorization, "Bearer " `BS.append` T.encodeUtf8 (atoken (fromJust t))) | isJust t]- headers = bearer ++ defaultRequestHeaders ++ requestHeaders req- in req {requestHeaders = headers}---- | Set the HTTP method to use.-setMethod :: HT.StdMethod -> Request -> Request-setMethod m req = req {method = HT.renderStdMethod m}---- | For @GET@ method API.-appendAccessToken ::- -- | Base URI- URIRef a ->- -- | Authorized Access Token- AccessToken ->- -- | Combined Result- URIRef a-appendAccessToken uri t = over (queryL . queryPairsL) (\query -> query ++ accessTokenToParam t) uri---- | Create `QueryParams` with given access token value.-accessTokenToParam :: AccessToken -> [(BS.ByteString, BS.ByteString)]-accessTokenToParam t = [("access_token", T.encodeUtf8 $ atoken t)]
− src/Network/OAuth/OAuth2/Internal.hs
@@ -1,190 +0,0 @@-{-# LANGUAGE QuasiQuotes #-}--module Network.OAuth.OAuth2.Internal where--import Control.Arrow (second)-import Control.Monad.Catch-import Data.Aeson-import Data.Aeson.Types (Parser, explicitParseFieldMaybe)-import Data.Binary (Binary)-import Data.ByteString qualified as BS-import Data.ByteString.Char8 qualified as BS8-import Data.Default-import Data.Maybe-import Data.Text (Text, unpack)-import Data.Version (showVersion)-import GHC.Generics-import Lens.Micro-import Lens.Micro.Extras-import Network.HTTP.Conduit as C-import Network.HTTP.Types qualified as H-import Network.HTTP.Types qualified as HT-import Paths_hoauth2 (version)-import URI.ByteString-import URI.ByteString.Aeson ()-import URI.ByteString.QQ------------------------------------------------------------------------------------- * OAuth2 Configuration------------------------------------------------------------------------------------- | Query Parameter Representation-data OAuth2 = OAuth2- { oauth2ClientId :: Text- , oauth2ClientSecret :: Text- , oauth2AuthorizeEndpoint :: URIRef Absolute- , oauth2TokenEndpoint :: URIRef Absolute- , oauth2RedirectUri :: URIRef Absolute- }- deriving (Show, Eq)--instance Default OAuth2 where- def =- OAuth2- { oauth2ClientId = ""- , oauth2ClientSecret = ""- , oauth2AuthorizeEndpoint = [uri|https://www.example.com/|]- , oauth2TokenEndpoint = [uri|https://www.example.com/|]- , oauth2RedirectUri = [uri|https://www.example.com/|]- }------------------------------------------------------------------------------------- * Tokens-----------------------------------------------------------------------------------newtype AccessToken = AccessToken {atoken :: Text} deriving (Binary, Eq, Show, FromJSON, ToJSON)--newtype RefreshToken = RefreshToken {rtoken :: Text} deriving (Binary, Eq, Show, FromJSON, ToJSON)--newtype IdToken = IdToken {idtoken :: Text} deriving (Binary, Eq, Show, FromJSON, ToJSON)---- | Authorization Code-newtype ExchangeToken = ExchangeToken {extoken :: Text} deriving (Show, FromJSON, ToJSON)---- FIXME: rename to TokenResponse and move to that module---- | https://www.rfc-editor.org/rfc/rfc6749#section-4.1.4-data OAuth2Token = OAuth2Token- { accessToken :: AccessToken- , refreshToken :: Maybe RefreshToken- -- ^ Exists when @offline_access@ scope is in the Authorization Request and the provider supports Refresh Access Token.- , expiresIn :: Maybe Int- , tokenType :: Maybe Text- -- ^ See https://www.rfc-editor.org/rfc/rfc6749#section-5.1. It's required per spec. But OAuth2 provider implementation are vary. Maybe will remove 'Maybe' in future release.- , idToken :: Maybe IdToken- -- ^ Exists when @openid@ scope is in the Authorization Request and the provider supports OpenID protocol.- }- deriving (Eq, Show, Generic)--instance Binary OAuth2Token---- | Parse JSON data into 'OAuth2Token'-instance FromJSON OAuth2Token where- parseJSON = withObject "OAuth2Token" $ \v ->- OAuth2Token- <$> v .: "access_token"- <*> v .:? "refresh_token"- <*> explicitParseFieldMaybe parseIntFlexible v "expires_in"- <*> v .:? "token_type"- <*> v .:? "id_token"- where- parseIntFlexible :: Value -> Parser Int- parseIntFlexible (String s) = pure . read $ unpack s- parseIntFlexible v = parseJSON v--instance ToJSON OAuth2Token where- toJSON = genericToJSON defaultOptions {fieldLabelModifier = camelTo2 '_'}- toEncoding = genericToEncoding defaultOptions {fieldLabelModifier = camelTo2 '_'}------------------------------------------------------------------------------------- * Client Authentication methods------------------------------------------------------------------------------------- | https://www.rfc-editor.org/rfc/rfc6749#section-2.3--- According to spec:------ The client MUST NOT use more than one authentication method in each request.------ Which means use Authorization header or Post body.------ However, I found I have to include authentication in the header all the time in real world.------ In other words, `ClientSecretBasic` is always assured. `ClientSecretPost` is optional.------ Maybe consider an alternative implementation that boolean kind of data type is good enough.-data ClientAuthenticationMethod- = ClientSecretBasic- | ClientSecretPost- | ClientAssertionJwt- deriving (Eq)------------------------------------------------------------------------------------- * Utilies for Request and URI------------------------------------------------------------------------------------- | Type synonym of post body content-type PostBody = [(BS.ByteString, BS.ByteString)]---- | Type sysnonym of request query params-type QueryParams = [(BS.ByteString, BS.ByteString)]--defaultRequestHeaders :: [(HT.HeaderName, BS.ByteString)]-defaultRequestHeaders =- [ (HT.hUserAgent, "hoauth2-" <> BS8.pack (showVersion version))- , (HT.hAccept, "application/json")- ]--appendQueryParams :: [(BS.ByteString, BS.ByteString)] -> URIRef a -> URIRef a-appendQueryParams params =- over (queryL . queryPairsL) (params ++)--uriToRequest :: MonadThrow m => URI -> m Request-uriToRequest auri = do- ssl <- case view (uriSchemeL . schemeBSL) auri of- "http" -> return False- "https" -> return True- s -> throwM $ InvalidUrlException (show auri) ("Invalid scheme: " ++ show s)- let query = fmap (second Just) (view (queryL . queryPairsL) auri)- hostL = authorityL . _Just . authorityHostL . hostBSL- portL = authorityL . _Just . authorityPortL . _Just . portNumberL- defaultPort = (if ssl then 443 else 80) :: Int-- req =- setQueryString query $- defaultRequest- { secure = ssl- , path = view pathL auri- }- req2 = (over hostLens . maybe id const . preview hostL) auri req- req3 = (over portLens . (const . fromMaybe defaultPort) . preview portL) auri req2- return req3--requestToUri :: Request -> URI-requestToUri req =- URI- ( Scheme- ( if secure req- then "https"- else "http"- )- )- (Just (Authority Nothing (Host $ host req) (Just $ Port $ port req)))- (path req)- (Query $ H.parseSimpleQuery $ queryString req)- Nothing--hostLens :: Lens' Request BS.ByteString-hostLens f req = f (C.host req) <&> \h' -> req {C.host = h'}-{-# INLINE hostLens #-}--portLens :: Lens' Request Int-portLens f req = f (C.port req) <&> \p' -> req {C.port = p'}-{-# INLINE portLens #-}
− src/Network/OAuth/OAuth2/TokenRequest.hs
@@ -1,346 +0,0 @@--- | Bindings Access Token and Refresh Token part of The OAuth 2.0 Authorization Framework--- RFC6749 <https://www.rfc-editor.org/rfc/rfc6749>-module Network.OAuth.OAuth2.TokenRequest where--import Control.Monad.IO.Class (MonadIO (..))-import Control.Monad.Trans.Except (ExceptT (..), throwE)-import Data.Aeson-import Data.Aeson.Key qualified as Key-import Data.Aeson.KeyMap qualified as KeyMap-import Data.ByteString.Lazy.Char8 qualified as BSL-import Data.Text (Text)-import Data.Text qualified as T-import Data.Text.Encoding qualified as T-import Network.HTTP.Conduit-import Network.HTTP.Types qualified as HT-import Network.HTTP.Types.URI (parseQuery)-import Network.OAuth.OAuth2.Internal-import URI.ByteString-import Prelude hiding (error)-------------------------------------------------------- * Token Request Errors------------------------------------------------------data TokenResponseError = TokenResponseError- { tokenResponseError :: TokenResponseErrorCode- , tokenResponseErrorDescription :: Maybe Text- , tokenResponseErrorUri :: Maybe (URIRef Absolute)- }- deriving (Show, Eq)---- | Token Error Responses https://tools.ietf.org/html/rfc6749#section-5.2-data TokenResponseErrorCode- = InvalidRequest- | InvalidClient- | InvalidGrant- | UnauthorizedClient- | UnsupportedGrantType- | InvalidScope- | UnknownErrorCode Text- deriving (Show, Eq)--instance FromJSON TokenResponseErrorCode where- parseJSON = withText "parseJSON TokenResponseErrorCode" $ \t ->- pure $ case t of- "invalid_request" -> InvalidRequest- "invalid_client" -> InvalidClient- "invalid_grant" -> InvalidGrant- "unauthorized_client" -> UnauthorizedClient- "unsupported_grant_type" -> UnsupportedGrantType- "invalid_scope" -> InvalidScope- _ -> UnknownErrorCode t--instance FromJSON TokenResponseError where- parseJSON = withObject "parseJSON TokenResponseError" $ \t -> do- tokenResponseError <- t .: "error"- tokenResponseErrorDescription <- t .:? "error_description"- tokenResponseErrorUri <- t .:? "error_uri"- pure TokenResponseError {..}--parseTokeResponseError :: BSL.ByteString -> TokenResponseError-parseTokeResponseError string =- either (mkDecodeOAuth2Error string) id (eitherDecode string)- where- mkDecodeOAuth2Error :: BSL.ByteString -> String -> TokenResponseError- mkDecodeOAuth2Error response err =- TokenResponseError- (UnknownErrorCode "")- (Just $ T.pack $ "Decode TokenResponseError failed: " <> err <> "\n Original Response:\n" <> show (T.decodeUtf8 $ BSL.toStrict response))- Nothing-------------------------------------------------------- * URL-------------------------------------------------------- | Prepare the URL and the request body query for fetching an access token.-accessTokenUrl ::- OAuth2 ->- -- | access code gained via authorization URL- ExchangeToken ->- -- | access token request URL plus the request body.- (URI, PostBody)-accessTokenUrl oa code =- let uri = oauth2TokenEndpoint oa- body =- [ ("code", T.encodeUtf8 $ extoken code)- , ("redirect_uri", serializeURIRef' $ oauth2RedirectUri oa)- , ("grant_type", "authorization_code")- ]- in (uri, body)---- | Obtain a new access token by sending a Refresh Token to the Authorization server.-refreshAccessTokenUrl ::- OAuth2 ->- -- | Refresh Token gained via authorization URL- RefreshToken ->- -- | Refresh Token request URL plus the request body.- (URI, PostBody)-refreshAccessTokenUrl oa token = (uri, body)- where- uri = oauth2TokenEndpoint oa- body =- [ ("grant_type", "refresh_token")- , ("refresh_token", T.encodeUtf8 $ rtoken token)- ]-------------------------------------------------------- * Token management-------------------------------------------------------- | Exchange @code@ for an Access Token with authenticate in request header.-fetchAccessToken ::- MonadIO m =>- -- | HTTP connection manager- Manager ->- -- | OAuth Data- OAuth2 ->- -- | OAuth2 Code- ExchangeToken ->- -- | Access Token- ExceptT TokenResponseError m OAuth2Token-fetchAccessToken = fetchAccessTokenWithAuthMethod ClientSecretBasic--fetchAccessToken2 ::- MonadIO m =>- -- | HTTP connection manager- Manager ->- -- | OAuth Data- OAuth2 ->- -- | Authorization Code- ExchangeToken ->- -- | Access Token- ExceptT TokenResponseError m OAuth2Token-fetchAccessToken2 = fetchAccessTokenWithAuthMethod ClientSecretPost-{-# DEPRECATED fetchAccessToken2 "use 'fetchAccessTokenWithAuthMethod'" #-}--fetchAccessTokenInternal ::- MonadIO m =>- ClientAuthenticationMethod ->- -- | HTTP connection manager- Manager ->- -- | OAuth Data- OAuth2 ->- -- | Authorization Code- ExchangeToken ->- -- | Access Token- ExceptT TokenResponseError m OAuth2Token-fetchAccessTokenInternal = fetchAccessTokenWithAuthMethod-{-# DEPRECATED fetchAccessTokenInternal "use 'fetchAccessTokenWithAuthMethod'" #-}---- | Exchange @code@ for an Access Token------ OAuth2 spec allows credential (@client_id@, @client_secret@) to be sent--- either in the header (a.k.a `ClientSecretBasic`).--- or as form/url params (a.k.a `ClientSecretPost`).------ The OAuth provider can choose to implement only one, or both.--- Look for API document from the OAuth provider you're dealing with.--- If you`re uncertain, try `fetchAccessToken` which sends credential--- in authorization http header, which is common case.------ @since 2.6.0-fetchAccessTokenWithAuthMethod ::- MonadIO m =>- ClientAuthenticationMethod ->- -- | HTTP connection manager- Manager ->- -- | OAuth Data- OAuth2 ->- -- | Authorization Code- ExchangeToken ->- -- | Access Token- ExceptT TokenResponseError m OAuth2Token-fetchAccessTokenWithAuthMethod authMethod manager oa code = do- let (uri, body) = accessTokenUrl oa code- let extraBody = if authMethod == ClientSecretPost then clientSecretPost oa else []- doJSONPostRequest manager oa uri (body ++ extraBody)---- | Fetch a new AccessToken using the Refresh Token with authentication in request header.-refreshAccessToken ::- MonadIO m =>- -- | HTTP connection manager.- Manager ->- -- | OAuth context- OAuth2 ->- -- | Refresh Token gained after authorization- RefreshToken ->- ExceptT TokenResponseError m OAuth2Token-refreshAccessToken = refreshAccessTokenWithAuthMethod ClientSecretBasic--refreshAccessToken2 ::- MonadIO m =>- -- | HTTP connection manager.- Manager ->- -- | OAuth context- OAuth2 ->- -- | Refresh Token gained after authorization- RefreshToken ->- ExceptT TokenResponseError m OAuth2Token-refreshAccessToken2 = refreshAccessTokenWithAuthMethod ClientSecretPost-{-# DEPRECATED refreshAccessToken2 "use 'refreshAccessTokenWithAuthMethod'" #-}--refreshAccessTokenInternal ::- MonadIO m =>- ClientAuthenticationMethod ->- -- | HTTP connection manager.- Manager ->- -- | OAuth context- OAuth2 ->- -- | Refresh Token gained after authorization- RefreshToken ->- ExceptT TokenResponseError m OAuth2Token-refreshAccessTokenInternal = refreshAccessTokenWithAuthMethod-{-# DEPRECATED refreshAccessTokenInternal "use 'refreshAccessTokenWithAuthMethod'" #-}---- | Fetch a new AccessToken using the Refresh Token.------ OAuth2 spec allows credential ("client_id", "client_secret") to be sent--- either in the header (a.k.a 'ClientSecretBasic').--- or as form/url params (a.k.a 'ClientSecretPost').------ The OAuth provider can choose to implement only one, or both.--- Look for API document from the OAuth provider you're dealing with.--- If you're uncertain, try 'refreshAccessToken' which sends credential--- in authorization http header, which is common case.------ @since 2.6.0-refreshAccessTokenWithAuthMethod ::- MonadIO m =>- ClientAuthenticationMethod ->- -- | HTTP connection manager.- Manager ->- -- | OAuth context- OAuth2 ->- -- | Refresh Token gained after authorization- RefreshToken ->- ExceptT TokenResponseError m OAuth2Token-refreshAccessTokenWithAuthMethod authMethod manager oa token = do- let (uri, body) = refreshAccessTokenUrl oa token- let extraBody = if authMethod == ClientSecretPost then clientSecretPost oa else []- doJSONPostRequest manager oa uri (body ++ extraBody)-------------------------------------------------------- * Utilies-------------------------------------------------------- | Conduct post request and return response as JSON.-doJSONPostRequest ::- (MonadIO m, FromJSON a) =>- -- | HTTP connection manager.- Manager ->- -- | OAuth options- OAuth2 ->- -- | The URL- URI ->- -- | request body- PostBody ->- -- | Response as JSON- ExceptT TokenResponseError m a-doJSONPostRequest manager oa uri body = do- resp <- doSimplePostRequest manager oa uri body- case parseResponseFlexible resp of- Right obj -> return obj- Left e -> throwE e---- | Conduct post request.-doSimplePostRequest ::- MonadIO m =>- -- | HTTP connection manager.- Manager ->- -- | OAuth options- OAuth2 ->- -- | URL- URI ->- -- | Request body.- PostBody ->- -- | Response as ByteString- ExceptT TokenResponseError m BSL.ByteString-doSimplePostRequest manager oa url body =- ExceptT . liftIO $ fmap handleOAuth2TokenResponse go- where- go = do- req <- uriToRequest url- let req' = (addBasicAuth oa . addDefaultRequestHeaders) req- httpLbs (urlEncodedBody body req') manager---- | Gets response body from a @Response@ if 200 otherwise assume 'Network.OAuth.OAuth2.TokenRequest.TokenResponseError'-handleOAuth2TokenResponse :: Response BSL.ByteString -> Either TokenResponseError BSL.ByteString-handleOAuth2TokenResponse rsp =- if HT.statusIsSuccessful (responseStatus rsp)- then Right $ responseBody rsp- else Left $ parseTokeResponseError (responseBody rsp)---- | Try to parses response as JSON, if failed, try to parse as like query string.-parseResponseFlexible ::- FromJSON a =>- BSL.ByteString ->- Either TokenResponseError a-parseResponseFlexible r = case eitherDecode r of- Left _ -> parseResponseString r- Right x -> Right x---- | Parses the response that contains not JSON but a Query String-parseResponseString ::- FromJSON a =>- BSL.ByteString ->- Either TokenResponseError a-parseResponseString b = case parseQuery $ BSL.toStrict b of- [] -> Left errorMessage- a -> case fromJSON $ queryToValue a of- Error _ -> Left errorMessage- Success x -> Right x- where- queryToValue = Object . KeyMap.fromList . map paramToPair- paramToPair (k, mv) = (Key.fromText $ T.decodeUtf8 k, maybe Null (String . T.decodeUtf8) mv)- errorMessage = parseTokeResponseError b---- | Add Basic Authentication header using client_id and client_secret.-addBasicAuth :: OAuth2 -> Request -> Request-addBasicAuth oa =- applyBasicAuth- (T.encodeUtf8 $ oauth2ClientId oa)- (T.encodeUtf8 $ oauth2ClientSecret oa)---- | Set several header values:--- + userAgennt : "hoauth2"--- + accept : "application/json"-addDefaultRequestHeaders :: Request -> Request-addDefaultRequestHeaders req =- let headers = defaultRequestHeaders ++ requestHeaders req- in req {requestHeaders = headers}---- | Add Credential (client_id, client_secret) to the request post body.-clientSecretPost :: OAuth2 -> PostBody-clientSecretPost oa =- [ ("client_id", T.encodeUtf8 $ oauth2ClientId oa)- , ("client_secret", T.encodeUtf8 $ oauth2ClientSecret oa)- ]
+ src/Network/OAuth2.hs view
@@ -0,0 +1,26 @@+-- | A lightweight oauth2 Haskell binding.+-- See Readme for more details+module Network.OAuth2 (+ module Network.OAuth2.Internal,++ -- * Authorization Requset+ module Network.OAuth2.AuthorizationRequest,++ -- * Token Request+ module Network.OAuth2.TokenRequest,++ -- * OAuth'ed http client utilities+ module Network.OAuth2.HttpClient,+) where++{-+ Hiding Errors data type from default.+ Shall qualified import given the naming collision.+-}+import Network.OAuth2.AuthorizationRequest hiding (+ AuthorizationResponseError (..),+ AuthorizationResponseErrorCode (..),+ )+import Network.OAuth2.HttpClient+import Network.OAuth2.Internal+import Network.OAuth2.TokenRequest
+ src/Network/OAuth2/AuthorizationRequest.hs view
@@ -0,0 +1,88 @@+-- | Bindings Authorization part of The OAuth 2.0 Authorization Framework+-- RFC6749 <https://www.rfc-editor.org/rfc/rfc6749>+module Network.OAuth2.AuthorizationRequest where++import Data.Aeson+import Data.Function (on)+import Data.List qualified as List+import Data.Text (Text)+import Data.Text.Encoding qualified as T+import Lens.Micro (over)+import Network.OAuth2.Internal+import URI.ByteString+import Prelude hiding (error)++--------------------------------------------------++-- * Authorization Request Errors++--------------------------------------------------++-- | Authorization Code Grant Error Responses https://tools.ietf.org/html/rfc6749#section-4.1.2.1+--+-- I found it hard to figure out a way to test the authorization error flow.+-- When anything goes wrong in an @/authorize@ request, it gets stuck at the provider page,+-- hence no way for this library to parse error response.+-- In other words, @/authorize@ ends up with 4xx or 5xx.+-- Revisit this whenever you find a case where an OAuth2 provider redirects back to the relying party with errors.+data AuthorizationResponseError = AuthorizationResponseError+ { authorizationResponseError :: AuthorizationResponseErrorCode+ , authorizationResponseErrorDescription :: Maybe Text+ , authorizationResponseErrorUri :: Maybe (URIRef Absolute)+ }+ deriving (Show, Eq)++data AuthorizationResponseErrorCode+ = InvalidRequest+ | UnauthorizedClient+ | AccessDenied+ | UnsupportedResponseType+ | InvalidScope+ | ServerError+ | TemporarilyUnavailable+ | UnknownErrorCode Text+ deriving (Show, Eq)++instance FromJSON AuthorizationResponseErrorCode where+ parseJSON = withText "parseJSON AuthorizationResponseErrorCode" $ \t ->+ pure $ case t of+ "invalid_request" -> InvalidRequest+ "unauthorized_client" -> UnauthorizedClient+ "access_denied" -> AccessDenied+ "unsupported_response_type" -> UnsupportedResponseType+ "invalid_scope" -> InvalidScope+ "server_error" -> ServerError+ "temporarily_unavailable" -> TemporarilyUnavailable+ _ -> UnknownErrorCode t++instance FromJSON AuthorizationResponseError where+ parseJSON = withObject "parseJSON AuthorizationResponseError" $ \t -> do+ authorizationResponseError <- t .: "error"+ authorizationResponseErrorDescription <- t .:? "error_description"+ authorizationResponseErrorUri <- t .:? "error_uri"+ pure AuthorizationResponseError {..}++--------------------------------------------------++-- * URLs++--------------------------------------------------++-- | See 'authorizationUrlWithParams'+authorizationUrl :: OAuth2 -> URI+authorizationUrl = authorizationUrlWithParams []++-- | Prepare the authorization URL. Redirect to this URL+-- asking for user interactive authentication.+--+-- @since 2.6.0+authorizationUrlWithParams :: QueryParams -> OAuth2 -> URI+authorizationUrlWithParams qs oa = over (queryL . queryPairsL) (++ queryParts) (oauth2AuthorizeEndpoint oa)+ where+ queryParts =+ List.nubBy ((==) `on` fst) $+ qs+ ++ [ ("client_id", T.encodeUtf8 $ oauth2ClientId oa)+ , ("response_type", "code")+ , ("redirect_uri", serializeURIRef' $ oauth2RedirectUri oa)+ ]
src/Network/OAuth2/Experiment.hs view
@@ -1,32 +1,32 @@ -- | This module contains a new way of doing OAuth2 authorization and authentication--- in order to obtain Access Token and maybe Refresh Token base on rfc6749.+-- in order to obtain an access token and maybe a refresh token based on RFC 6749. ----- This module will become default in future release.+-- This module will become the default in a future release. ----- The key concept/change is to introduce the Grant flow, which determines the entire work flow per spec.--- Each work flow will have slight different request parameters, which often time you'll see--- different configuration when creating OAuth2 application in the IdP developer application page.+-- The key concept is introducing grant flows, which determine the entire workflow per the spec.+-- Each workflow has slightly different request parameters, which is why you'll often see+-- different configuration options when creating an OAuth2 application in an IdP developer console. ----- Here are supported flows+-- Here are the supported flows: ----- 1. Authorization Code. This flow requires authorize call to obtain an authorize code,+-- 1. Authorization Code. This flow requires an authorize call to obtain an authorization code, -- then exchange the code for tokens. ----- 2. Resource Owner Password. This flow only requires to hit token endpoint with, of course,+-- 2. Resource Owner Password. This flow only requires hitting the token endpoint with, of course, -- username and password, to obtain tokens. ----- 3. Client Credentials. This flow also only requires to hit token endpoint but with different parameters.--- Client credentials flow does not involve an end user hence you won't be able to hit userinfo endpoint--- with access token obtained.+-- 3. Client Credentials. This flow also only requires hitting the token endpoint, but with different parameters.+-- The client credentials flow does not involve an end user, so you won't be able to hit the userinfo endpoint+-- with the access token you obtain. ----- 5. PKCE (rfc7636). This is enhancement on top of authorization code flow.+-- 4. PKCE (RFC 7636). This is an enhancement on top of the authorization code flow. ----- Implicit flow is not supported because it is more for SPA (single page app)--- given it is deprecated by Authorization Code flow with PKCE.+-- The implicit flow is not supported because it is primarily for SPAs (single-page apps),+-- and it has been deprecated in favor of the authorization code flow with PKCE. ----- Here is quick sample for how to use vocabularies from this new module.+-- Here is a quick sample showing how to use the vocabulary from this module. ----- Firstly, initialize your IdP (use google as example) and the application.+-- First, initialize your IdP (using Google as an example) and the application. -- -- @ --@@ -58,89 +58,86 @@ -- acAuthorizeRequestExtraParams = Map.empty, -- acRedirectUri = [uri|http:\/\/localhost\/oauth2\/callback|], -- acName = "sample-google-authorization-code-app",--- acTokenRequestAuthenticationMethod = ClientSecretBasic,+-- acClientAuthenticationMethod = ClientSecretBasic, -- } -- -- fooIdpApplication :: IdpApplication AuthorizationCodeApplication Google -- fooIdpApplication = IdpApplication fooApp googleIdp -- @ ----- Secondly, construct the authorize URL.+-- Second, construct the authorize URL. -- -- @ -- authorizeUrl = mkAuthorizationRequest fooIdpApplication -- @ ----- Thirdly, after a successful redirect with authorize code,--- you could exchange for access token+-- Third, after a successful redirect with an authorization code,+-- you can exchange it for an access token. -- -- @ -- mgr <- liftIO $ newManager tlsManagerSettings -- tokenResp <- conduitTokenRequest fooIdpApplication mgr authorizeCode -- @ ----- If you'd like to fetch user info, uses this method+-- If you'd like to fetch user info, use this method: -- -- @ -- conduitUserInfoRequest fooIdpApplication mgr (accessToken tokenResp) -- @ ----- You could also find example from @hoauth2-providers-tutorials@ module.+-- You can also find an example in the @hoauth2-providers-tutorial@ module. module Network.OAuth2.Experiment ( -- * Application per Grant type module Network.OAuth2.Experiment.Grants, -- * Authorization Code- module Network.OAuth2.Experiment.Flows.AuthorizationRequest,+ mkAuthorizationRequest,+ mkPkceAuthorizeRequest, -- * Device Authorization module Network.OAuth2.Experiment.Flows.DeviceAuthorizationRequest,+ conduitDeviceAuthorizationRequest,+ pollDeviceTokenRequest, -- * Token Request module Network.OAuth2.Experiment.Flows.TokenRequest,+ conduitPkceTokenRequest,+ conduitTokenRequest, -- * Refresh Token Request module Network.OAuth2.Experiment.Flows.RefreshTokenRequest,+ conduitRefreshTokenRequest, -- * UserInfo Request module Network.OAuth2.Experiment.Flows.UserInfoRequest,+ conduitUserInfoRequest,+ conduitUserInfoRequestWithCustomMethod, -- * Types module Network.OAuth2.Experiment.Types, module Network.OAuth2.Experiment.Pkce,- module Network.OAuth.OAuth2,+ module Network.OAuth2, -- * Utils module Network.OAuth2.Experiment.Utils, ) where -import Network.OAuth.OAuth2 (ClientAuthenticationMethod (..))-import Network.OAuth2.Experiment.Flows.AuthorizationRequest (- HasAuthorizeRequest,- mkAuthorizationRequest,- mkPkceAuthorizeRequest,- )+import Network.OAuth2 (ClientAuthenticationMethod (..))+import Network.OAuth2.Experiment.Flows import Network.OAuth2.Experiment.Flows.DeviceAuthorizationRequest ( DeviceAuthorizationResponse (..),- HasDeviceAuthorizationRequest,- conduitDeviceAuthorizationRequest, ) import Network.OAuth2.Experiment.Flows.RefreshTokenRequest ( HasRefreshTokenRequest,- conduitRefreshTokenRequest, ) import Network.OAuth2.Experiment.Flows.TokenRequest ( ExchangeTokenInfo, HasTokenRequest, NoNeedExchangeToken (..), TokenRequest,- conduitPkceTokenRequest,- conduitTokenRequest, ) import Network.OAuth2.Experiment.Flows.UserInfoRequest ( HasUserInfoRequest,- conduitUserInfoRequest,- conduitUserInfoRequestWithCustomMethod, ) import Network.OAuth2.Experiment.Grants import Network.OAuth2.Experiment.Pkce (@@ -150,7 +147,6 @@ AuthorizeState (..), ClientId (..), ClientSecret (..),- HasOAuth2Key, Idp (..), IdpApplication (..), Password (..),
+ src/Network/OAuth2/Experiment/Flows.hs view
@@ -0,0 +1,273 @@+{-# LANGUAGE FlexibleContexts #-}++-- | Module implementing various OAuth2 flow types and their request/response handling.+-- Provides support for:+--+-- * Authorization Code Grant+-- * Device Authorization Grant+-- * PKCE Extension+-- * Token Refresh+-- * User Info Endpoints+module Network.OAuth2.Experiment.Flows where++import Control.Concurrent+import Control.Monad.IO.Class (MonadIO (..))+import Control.Monad.Trans.Except+import Data.Aeson (FromJSON)+import Data.Bifunctor+import Data.ByteString.Lazy.Char8 qualified as BSL+import Data.Map.Strict qualified as Map+import Data.Maybe+import Network.HTTP.Client.Contrib+import Network.HTTP.Conduit+import Network.OAuth2+import Network.OAuth2 qualified as OAuth2+import Network.OAuth2.Experiment.Flows.DeviceAuthorizationRequest+import Network.OAuth2.Experiment.Flows.RefreshTokenRequest+import Network.OAuth2.Experiment.Flows.TokenRequest+import Network.OAuth2.Experiment.Flows.UserInfoRequest+import Network.OAuth2.Experiment.Grants.AuthorizationCode+import Network.OAuth2.Experiment.Grants.DeviceAuthorization+import Network.OAuth2.Experiment.Pkce+import Network.OAuth2.Experiment.Types+import Network.OAuth2.Experiment.Utils+import URI.ByteString hiding (UserInfo)++-------------------------------------------------------------------------------+-- Authorization Requests --+-------------------------------------------------------------------------------++-- | Constructs an Authorization Code request URI according to+-- <https://www.rfc-editor.org/rfc/rfc6749#section-4.1.1 RFC 6749 Section 4.1.1>.+--+-- The generated URI includes:+-- * client_id+-- * response_type (always "code")+-- * redirect_uri+-- * state (if provided)+-- * scope (if provided)+mkAuthorizationRequest :: IdpApplication i AuthorizationCodeApplication -> URI+mkAuthorizationRequest idpApp =+ let req = mkAuthorizationRequestParam (application idpApp)+ allParams =+ map (bimap tlToBS tlToBS) $+ Map.toList $+ toQueryParam req+ in appendQueryParams allParams $+ idpAuthorizeEndpoint (idp idpApp)++-- | Constructs an Authorization Code request URI with PKCE support according to+-- <https://datatracker.ietf.org/doc/html/rfc7636 RFC 7636>.+--+-- Returns both the authorization URI and the generated code verifier.+-- The code verifier must be stored securely for later use in the token request.+mkPkceAuthorizeRequest ::+ MonadIO m =>+ IdpApplication i AuthorizationCodeApplication ->+ m (URI, CodeVerifier)+mkPkceAuthorizeRequest IdpApplication {..} = do+ (req, codeVerifier) <- mkPkceAuthorizeRequestParam application+ let allParams = map (bimap tlToBS tlToBS) $ Map.toList $ toQueryParam req+ let url =+ appendQueryParams allParams $+ idpAuthorizeEndpoint idp+ pure (url, codeVerifier)++-------------------------------------------------------------------------------+-- Device Auth --+-------------------------------------------------------------------------------++-- | Makes Device Authorization Request+-- https://www.rfc-editor.org/rfc/rfc8628#section-3.1+conduitDeviceAuthorizationRequest ::+ MonadIO m =>+ IdpApplication i DeviceAuthorizationApplication ->+ Manager ->+ ExceptT BSL.ByteString m DeviceAuthorizationResponse+conduitDeviceAuthorizationRequest IdpApplication {..} mgr = do+ case idpDeviceAuthorizationEndpoint idp of+ Nothing -> throwE "[conduitDeviceAuthorizationRequest] Device Authorization Flow is not supported: missing device_authorization_endpoint."+ Just deviceAuthEndpoint -> do+ let deviceAuthReq = mkDeviceAuthorizationRequestParam application+ body = unionMapsToQueryParams [toQueryParam deviceAuthReq]+ ExceptT . liftIO $ do+ req <- addDefaultRequestHeaders <$> uriToRequest deviceAuthEndpoint+ let req' =+ if daAuthorizationRequestAuthenticationMethod application == ClientSecretBasic+ then addSecretToHeader (daClientId application) (daClientSecret application) req+ else req+ resp <- httpLbs (urlEncodedBody body req') mgr+ pure $ first ("[conduitDeviceAuthorizationRequest] " <>) $ handleResponseJSON resp++-- | Polls for a token using the device authorization flow.+--+-- This implements the polling mechanism described in+-- <https://www.rfc-editor.org/rfc/rfc8628#section-3.5 RFC 8628 Section 3.5>.+-- Handles automatic retries and interval adjustments based on IdP responses.+pollDeviceTokenRequest ::+ MonadIO m =>+ IdpApplication i DeviceAuthorizationApplication ->+ Manager ->+ DeviceAuthorizationResponse ->+ ExceptT TokenResponseError m TokenResponse+pollDeviceTokenRequest idpApp mgr deviceAuthResp = do+ pollDeviceTokenRequestInternal+ idpApp+ mgr+ (deviceCode deviceAuthResp)+ (fromMaybe 5 $ interval deviceAuthResp)++pollDeviceTokenRequestInternal ::+ MonadIO m =>+ IdpApplication i DeviceAuthorizationApplication ->+ Manager ->+ DeviceCode ->+ Int ->+ -- | Polling Interval+ ExceptT TokenResponseError m TokenResponse+pollDeviceTokenRequestInternal idpApp mgr deviceCode intervalSeconds = do+ resp <- runExceptT (conduitTokenRequest idpApp mgr deviceCode)+ case resp of+ Left trRespError -> do+ case tokenResponseError trRespError of+ -- TODO: Didn't have a good idea to expand the error code+ -- specifically for device token request flow+ -- Device Token Response additional error code: https://www.rfc-editor.org/rfc/rfc8628#section-3.5+ UnknownErrorCode "authorization_pending" -> do+ liftIO $ threadDelay $ intervalSeconds * 1000000+ pollDeviceTokenRequestInternal idpApp mgr deviceCode intervalSeconds+ UnknownErrorCode "slow_down" -> do+ let newIntervalSeconds = intervalSeconds + 5+ liftIO $ threadDelay $ newIntervalSeconds * 1000000+ pollDeviceTokenRequestInternal idpApp mgr deviceCode newIntervalSeconds+ _ -> throwE trRespError+ Right v -> pure v++-------------------------------------------------------------------------------+-- Token Request --+-------------------------------------------------------------------------------++-- | Sends a token request according to+-- <https://www.rfc-editor.org/rfc/rfc6749#section-4.1.3 RFC 6749 Section 4.1.3>.+--+-- This is used for exchanging authorization codes, device codes, or other+-- grant types for access tokens.+conduitTokenRequest ::+ (HasTokenRequest a, ToQueryParam (TokenRequest a), MonadIO m) =>+ IdpApplication i a ->+ Manager ->+ ExchangeTokenInfo a ->+ ExceptT TokenResponseError m TokenResponse+conduitTokenRequest idpApp mgr exchangeToken = do+ let req = mkTokenRequestParam (application idpApp) exchangeToken+ body =+ unionMapsToQueryParams+ [ toQueryParam req+ ]+ in conduitTokenRequestInternal idpApp mgr body++-------------------------------------------------------------------------------+-- PKCE Token Request --+-------------------------------------------------------------------------------++-- | https://datatracker.ietf.org/doc/html/rfc7636#section-4.5+conduitPkceTokenRequest ::+ (HasTokenRequest a, ToQueryParam (TokenRequest a), MonadIO m) =>+ IdpApplication i a ->+ Manager ->+ (ExchangeTokenInfo a, CodeVerifier) ->+ ExceptT TokenResponseError m TokenResponse+conduitPkceTokenRequest idpApp mgr (exchangeToken, codeVerifier) =+ let req = mkTokenRequestParam (application idpApp) exchangeToken+ body =+ unionMapsToQueryParams+ [ toQueryParam req+ , toQueryParam codeVerifier+ ]+ in conduitTokenRequestInternal idpApp mgr body++-------------------------------------------------------------------------------+-- Refresh Token --+-------------------------------------------------------------------------------++-- | Makes a Refresh Token Request according to+-- <https://www.rfc-editor.org/rfc/rfc6749#section-6 RFC 6749 Section 6>.+--+-- Used to obtain a new access token using a refresh token.+conduitRefreshTokenRequest ::+ (MonadIO m, HasRefreshTokenRequest a) =>+ IdpApplication i a ->+ Manager ->+ OAuth2.RefreshToken ->+ ExceptT TokenResponseError m TokenResponse+conduitRefreshTokenRequest ia mgr rt =+ let tokenReq = mkRefreshTokenRequestParam (application ia) rt+ body = unionMapsToQueryParams [toQueryParam tokenReq]+ in conduitTokenRequestInternal ia mgr body++-------------------------------------------------------------------------------+-- User Info --+-------------------------------------------------------------------------------++-- | Makes a standard request to the userinfo endpoint using GET method.+--+-- This is commonly used with OpenID Connect providers to fetch+-- user profile information using an access token.+conduitUserInfoRequest ::+ (MonadIO m, HasUserInfoRequest a, FromJSON b) =>+ IdpApplication i a ->+ Manager ->+ AccessToken ->+ ExceptT BSL.ByteString m b+conduitUserInfoRequest = conduitUserInfoRequestWithCustomMethod authGetJSON++-- | Makes a request to the userinfo endpoint using a custom HTTP method.+--+-- Some IdPs may require different HTTP methods (instead of GET) or custom headers+-- for fetching user information. This function provides that flexibility.+conduitUserInfoRequestWithCustomMethod ::+ (MonadIO m, HasUserInfoRequest a, FromJSON b) =>+ ( Manager ->+ AccessToken ->+ URI ->+ ExceptT BSL.ByteString m b+ ) ->+ IdpApplication i a ->+ Manager ->+ AccessToken ->+ ExceptT BSL.ByteString m b+conduitUserInfoRequestWithCustomMethod fetchMethod IdpApplication {..} mgr at =+ fetchMethod mgr at (idpUserInfoEndpoint idp)++-------------------------------------------------------------------------------+-- Internal helpers --+-------------------------------------------------------------------------------++conduitTokenRequestInternal ::+ ( MonadIO m+ , HasClientAuthenticationMethod a+ , FromJSON b+ ) =>+ IdpApplication i a ->+ -- | HTTP connection manager.+ Manager ->+ -- | Request body.+ PostBody ->+ -- | Response as ByteString+ ExceptT TokenResponseError m b+conduitTokenRequestInternal IdpApplication {..} manager body = do+ let clientAuthMethod = getClientAuthenticationMethod application+ url = idpTokenEndpoint idp+ updateAuthHeader =+ case clientAuthMethod of+ ClientSecretBasic -> addClientAuthToHeader application+ ClientSecretPost -> id+ ClientAssertionJwt -> id+ go = do+ req <- uriToRequest url+ let req' = (updateAuthHeader . addDefaultRequestHeaders) req+ httpLbs (urlEncodedBody body req') manager+ resp <- ExceptT . liftIO $ fmap handleOAuth2TokenResponse go+ case parseResponseFlexible resp of+ Right obj -> return obj+ Left e -> throwE e
src/Network/OAuth2/Experiment/Flows/AuthorizationRequest.hs view
@@ -1,16 +1,10 @@ module Network.OAuth2.Experiment.Flows.AuthorizationRequest where -import Control.Monad.IO.Class (MonadIO (..))-import Data.Bifunctor import Data.Map.Strict (Map) import Data.Map.Strict qualified as Map import Data.Set (Set) import Data.Text.Lazy (Text)-import Network.OAuth.OAuth2 hiding (RefreshToken)-import Network.OAuth2.Experiment.Pkce import Network.OAuth2.Experiment.Types-import Network.OAuth2.Experiment.Utils-import URI.ByteString hiding (UserInfo) ------------------------------------------------------------------------------- -- Authorization Request --@@ -37,42 +31,3 @@ , toQueryParam arRedirectUri , arExtraParams ]--class HasAuthorizeRequest a where- -- | Constructs Authorization Code request parameters- -- | https://www.rfc-editor.org/rfc/rfc6749#section-4.1.1- mkAuthorizationRequestParam :: a -> AuthorizationRequestParam---- | Constructs Authorization Code request URI--- https://www.rfc-editor.org/rfc/rfc6749#section-4.1.1-mkAuthorizationRequest :: HasAuthorizeRequest a => IdpApplication i a -> URI-mkAuthorizationRequest idpApp =- let req = mkAuthorizationRequestParam (application idpApp)- allParams =- map (bimap tlToBS tlToBS) $- Map.toList $- toQueryParam req- in appendQueryParams allParams $- idpAuthorizeEndpoint (idp idpApp)------------------------------------------------------------------------------------ PKCE -------------------------------------------------------------------------------------- | https://datatracker.ietf.org/doc/html/rfc7636-class HasAuthorizeRequest a => HasPkceAuthorizeRequest a where- mkPkceAuthorizeRequestParam :: MonadIO m => a -> m (AuthorizationRequestParam, CodeVerifier)---- | Constructs Authorization Code (PKCE) request URI and the Code Verifier.--- https://datatracker.ietf.org/doc/html/rfc7636-mkPkceAuthorizeRequest ::- (MonadIO m, HasPkceAuthorizeRequest a) =>- IdpApplication i a ->- m (URI, CodeVerifier)-mkPkceAuthorizeRequest IdpApplication {..} = do- (req, codeVerifier) <- mkPkceAuthorizeRequestParam application- let allParams = map (bimap tlToBS tlToBS) $ Map.toList $ toQueryParam req- let url =- appendQueryParams allParams $- idpAuthorizeEndpoint idp- pure (url, codeVerifier)
src/Network/OAuth2/Experiment/Flows/DeviceAuthorizationRequest.hs view
@@ -3,20 +3,12 @@ module Network.OAuth2.Experiment.Flows.DeviceAuthorizationRequest where import Control.Applicative-import Control.Monad.IO.Class (MonadIO (..))-import Control.Monad.Trans.Except (ExceptT (..), throwE) import Data.Aeson.Types-import Data.Bifunctor-import Data.ByteString.Lazy.Char8 qualified as BSL import Data.Map.Strict (Map) import Data.Map.Strict qualified as Map import Data.Set (Set) import Data.Text.Lazy (Text)-import Network.HTTP.Client.Contrib-import Network.HTTP.Conduit-import Network.OAuth.OAuth2 hiding (RefreshToken) import Network.OAuth2.Experiment.Types-import Network.OAuth2.Experiment.Utils import URI.ByteString hiding (UserInfo) -------------------------------------------------------------------------------@@ -52,49 +44,16 @@ pure DeviceAuthorizationResponse {..} data DeviceAuthorizationRequestParam = DeviceAuthorizationRequestParam- { arScope :: Set Scope- , arClientId :: Maybe ClientId- , arExtraParams :: Map Text Text+ { darScope :: Set Scope+ , darClientId :: Maybe ClientId+ , darExtraParams :: Map Text Text } instance ToQueryParam DeviceAuthorizationRequestParam where toQueryParam :: DeviceAuthorizationRequestParam -> Map Text Text toQueryParam DeviceAuthorizationRequestParam {..} = Map.unions- [ toQueryParam arScope- , toQueryParam arClientId- , arExtraParams+ [ toQueryParam darScope+ , toQueryParam darClientId+ , darExtraParams ]--class HasOAuth2Key a => HasDeviceAuthorizationRequest a where- -- | Create Device Authorization Request parameters- -- https://www.rfc-editor.org/rfc/rfc8628#section-3.1- mkDeviceAuthorizationRequestParam :: a -> DeviceAuthorizationRequestParam---- TODO: There is only (possibly always only) on instance of 'HasDeviceAuthorizationRequest'--- Maybe consider to hard-code the data type instead of use type class.---- | Makes Device Authorization Request--- https://www.rfc-editor.org/rfc/rfc8628#section-3.1-conduitDeviceAuthorizationRequest ::- (MonadIO m, HasDeviceAuthorizationRequest a) =>- IdpApplication i a ->- Manager ->- ExceptT BSL.ByteString m DeviceAuthorizationResponse-conduitDeviceAuthorizationRequest IdpApplication {..} mgr = do- case idpDeviceAuthorizationEndpoint idp of- Nothing -> throwE "[conduiteDeviceAuthorizationRequest] Device Authorization Flow is not supported due to miss device_authorization_endpoint."- Just deviceAuthEndpoint -> do- let deviceAuthReq = mkDeviceAuthorizationRequestParam application- oauth2Key = mkOAuth2Key application- body = unionMapsToQueryParams [toQueryParam deviceAuthReq]- ExceptT . liftIO $ do- req <- addDefaultRequestHeaders <$> uriToRequest deviceAuthEndpoint- -- Hacky:- -- Missing clientId implies ClientSecretBasic authentication method.- -- See Grant/DeviceAuthorization.hs- let req' = case arClientId deviceAuthReq of- Nothing -> addBasicAuth oauth2Key req- Just _ -> req- resp <- httpLbs (urlEncodedBody body req') mgr- pure $ first ("[conduiteDeviceAuthorizationRequest] " <>) $ handleResponseJSON resp
src/Network/OAuth2/Experiment/Flows/RefreshTokenRequest.hs view
@@ -2,18 +2,13 @@ module Network.OAuth2.Experiment.Flows.RefreshTokenRequest where -import Control.Monad.IO.Class (MonadIO (..))-import Control.Monad.Trans.Except (ExceptT (..)) import Data.Map.Strict (Map) import Data.Map.Strict qualified as Map import Data.Set (Set) import Data.Text.Lazy (Text)-import Network.HTTP.Conduit-import Network.OAuth.OAuth2 hiding (RefreshToken)-import Network.OAuth.OAuth2 qualified as OAuth2+import Network.OAuth2 qualified as OAuth2 import Network.OAuth2.Experiment.Flows.TokenRequest import Network.OAuth2.Experiment.Types-import Network.OAuth2.Experiment.Utils ------------------------------------------------------------------------------- -- RefreshToken Requset --@@ -23,6 +18,8 @@ { rrRefreshToken :: OAuth2.RefreshToken , rrGrantType :: GrantTypeValue , rrScope :: Set Scope+ , rrClientId :: Maybe ClientId+ , rrClientSecret :: Maybe ClientSecret } instance ToQueryParam RefreshTokenRequest where@@ -32,22 +29,11 @@ [ toQueryParam rrGrantType , toQueryParam rrScope , toQueryParam rrRefreshToken+ , toQueryParam rrClientId+ , toQueryParam rrClientSecret ] -class (HasOAuth2Key a, HasTokenRequestClientAuthenticationMethod a) => HasRefreshTokenRequest a where+class HasClientAuthenticationMethod a => HasRefreshTokenRequest a where -- | Make Refresh Token Request parameters -- | https://www.rfc-editor.org/rfc/rfc6749#section-6 mkRefreshTokenRequestParam :: a -> OAuth2.RefreshToken -> RefreshTokenRequest---- | Make Refresh Token Request--- https://www.rfc-editor.org/rfc/rfc6749#section-6-conduitRefreshTokenRequest ::- (MonadIO m, HasRefreshTokenRequest a) =>- IdpApplication i a ->- Manager ->- OAuth2.RefreshToken ->- ExceptT TokenResponseError m OAuth2Token-conduitRefreshTokenRequest IdpApplication {..} mgr rt =- let tokenReq = mkRefreshTokenRequestParam application rt- body = unionMapsToQueryParams [toQueryParam tokenReq]- in doJSONPostRequest mgr (mkOAuth2Key application) (idpTokenEndpoint idp) body
src/Network/OAuth2/Experiment/Flows/TokenRequest.hs view
@@ -2,26 +2,33 @@ module Network.OAuth2.Experiment.Flows.TokenRequest where -import Control.Monad.IO.Class (MonadIO (..))-import Control.Monad.Trans.Except (ExceptT (..), throwE)+import Data.Text.Encoding qualified as T+import Data.Text.Lazy qualified as TL import Network.HTTP.Conduit-import Network.OAuth.OAuth2 hiding (RefreshToken)-import Network.OAuth2.Experiment.Pkce-import Network.OAuth2.Experiment.Types-import Network.OAuth2.Experiment.Utils+import Network.OAuth2 (+ ClientAuthenticationMethod (..),+ )+import Network.OAuth2.Experiment.Types (+ ClientId (unClientId),+ ClientSecret (unClientSecret),+ ) ----------------------------------------------------------------------------------- Token Request ----------------------------------------------------------------------------------+addSecretToHeader :: ClientId -> ClientSecret -> Request -> Request+addSecretToHeader cid csecret =+ applyBasicAuth+ (T.encodeUtf8 $ TL.toStrict $ unClientId cid)+ (T.encodeUtf8 $ TL.toStrict $ unClientSecret csecret) -class HasTokenRequestClientAuthenticationMethod a where+class HasClientAuthenticationMethod a where getClientAuthenticationMethod :: a -> ClientAuthenticationMethod+ addClientAuthToHeader :: a -> Request -> Request+ addClientAuthToHeader _ = id -- | Only Authorization Code Grant involves a Exchange Token (Authorization Code). -- ResourceOwnerPassword and Client Credentials make token request directly. data NoNeedExchangeToken = NoNeedExchangeToken -class (HasOAuth2Key a, HasTokenRequestClientAuthenticationMethod a) => HasTokenRequest a where+class HasClientAuthenticationMethod a => HasTokenRequest a where -- Each GrantTypeFlow has slightly different request parameter to /token endpoint. data TokenRequest a type ExchangeTokenInfo a@@ -32,52 +39,3 @@ -- v.s. 'type instance WithExchangeToken a b = ExchangeToken -> b' implies needing an exchange token -- type WithExchangeToken a b mkTokenRequestParam :: a -> ExchangeTokenInfo a -> TokenRequest a---- | Make Token Request--- https://www.rfc-editor.org/rfc/rfc6749#section-4.1.3-conduitTokenRequest ::- (HasTokenRequest a, ToQueryParam (TokenRequest a), MonadIO m) =>- IdpApplication i a ->- Manager ->- ExchangeTokenInfo a ->- ExceptT TokenResponseError m OAuth2Token-conduitTokenRequest IdpApplication {..} mgr exchangeToken = do- let tokenReq = mkTokenRequestParam application exchangeToken- body = unionMapsToQueryParams [toQueryParam tokenReq]- if getClientAuthenticationMethod application == ClientAssertionJwt- then do- resp <- ExceptT . liftIO $ do- req <- uriToRequest (idpTokenEndpoint idp)- let req' = urlEncodedBody body (addDefaultRequestHeaders req)- handleOAuth2TokenResponse <$> httpLbs req' mgr- case parseResponseFlexible resp of- Right obj -> return obj- Left e -> throwE e- else doJSONPostRequest mgr (mkOAuth2Key application) (idpTokenEndpoint idp) body------------------------------------------------------------------------------------ PKCE -------------------------------------------------------------------------------------- | Make Token Request (PKCE)--- https://datatracker.ietf.org/doc/html/rfc7636#section-4.5-conduitPkceTokenRequest ::- (HasTokenRequest a, ToQueryParam (TokenRequest a), MonadIO m) =>- IdpApplication i a ->- Manager ->- (ExchangeTokenInfo a, CodeVerifier) ->- ExceptT TokenResponseError m OAuth2Token-conduitPkceTokenRequest IdpApplication {..} mgr (exchangeToken, codeVerifier) =- let req = mkTokenRequestParam application exchangeToken- key = mkOAuth2Key application- clientSecretPostParam =- if getClientAuthenticationMethod application == ClientSecretPost- then clientSecretPost key- else []- body =- unionMapsToQueryParams- [ toQueryParam req- , toQueryParam codeVerifier- ]- ++ clientSecretPostParam- in doJSONPostRequest mgr key (idpTokenEndpoint idp) body
src/Network/OAuth2/Experiment/Flows/UserInfoRequest.hs view
@@ -1,44 +1,7 @@-{-# LANGUAGE FlexibleContexts #-}- module Network.OAuth2.Experiment.Flows.UserInfoRequest where -import Control.Monad.IO.Class (MonadIO (..))-import Control.Monad.Trans.Except (ExceptT (..))-import Data.Aeson (FromJSON)-import Data.ByteString.Lazy.Char8 qualified as BSL-import Network.HTTP.Conduit-import Network.OAuth.OAuth2-import Network.OAuth2.Experiment.Types-import URI.ByteString (URI)- ------------------------------------------------------------------------------- -- User Info Request -- ------------------------------------------------------------------------------- class HasUserInfoRequest a---- | Standard approach of fetching /userinfo-conduitUserInfoRequest ::- (MonadIO m, HasUserInfoRequest a, FromJSON b) =>- IdpApplication i a ->- Manager ->- AccessToken ->- ExceptT BSL.ByteString m b-conduitUserInfoRequest = conduitUserInfoRequestWithCustomMethod authGetJSON---- | Usually 'conduitUserInfoRequest' is good enough.--- But some IdP has different approach to fetch user information rather than GET.--- This method gives the flexiblity.-conduitUserInfoRequestWithCustomMethod ::- (MonadIO m, HasUserInfoRequest a, FromJSON b) =>- ( Manager ->- AccessToken ->- URI ->- ExceptT BSL.ByteString m b- ) ->- IdpApplication i a ->- Manager ->- AccessToken ->- ExceptT BSL.ByteString m b-conduitUserInfoRequestWithCustomMethod fetchMethod IdpApplication {..} mgr at =- fetchMethod mgr at (idpUserInfoEndpoint idp)
src/Network/OAuth2/Experiment/Grants.hs view
@@ -10,7 +10,6 @@ import Network.OAuth2.Experiment.Grants.ClientCredentials (ClientCredentialsApplication (..)) import Network.OAuth2.Experiment.Grants.DeviceAuthorization ( DeviceAuthorizationApplication (..),- pollDeviceTokenRequest, ) import Network.OAuth2.Experiment.Grants.JwtBearer (JwtBearerApplication (..)) import Network.OAuth2.Experiment.Grants.ResourceOwnerPassword (ResourceOwnerPasswordApplication (..))
src/Network/OAuth2/Experiment/Grants/AuthorizationCode.hs view
@@ -7,14 +7,15 @@ import Data.Map.Strict qualified as Map import Data.Set (Set) import Data.Text.Lazy (Text)-import Network.OAuth.OAuth2 (ClientAuthenticationMethod (..), ExchangeToken (..), OAuth2)-import Network.OAuth.OAuth2 qualified as OAuth2+import Network.OAuth2 (ClientAuthenticationMethod (..), ExchangeToken (..))+import Network.OAuth2 qualified as OAuth2 import Network.OAuth2.Experiment.Flows.AuthorizationRequest import Network.OAuth2.Experiment.Flows.RefreshTokenRequest import Network.OAuth2.Experiment.Flows.TokenRequest import Network.OAuth2.Experiment.Flows.UserInfoRequest import Network.OAuth2.Experiment.Pkce import Network.OAuth2.Experiment.Types+import Network.OAuth2.Experiment.Utils import URI.ByteString hiding (UserInfo) -- | An Application that supports "Authorization code" flow@@ -28,41 +29,36 @@ , acRedirectUri :: URI , acAuthorizeState :: AuthorizeState , acAuthorizeRequestExtraParams :: Map Text Text- , acTokenRequestAuthenticationMethod :: ClientAuthenticationMethod+ , acClientAuthenticationMethod :: ClientAuthenticationMethod } -instance HasOAuth2Key AuthorizationCodeApplication where- mkOAuth2Key :: AuthorizationCodeApplication -> OAuth2- mkOAuth2Key AuthorizationCodeApplication {..} = toOAuth2Key acClientId acClientSecret--instance HasTokenRequestClientAuthenticationMethod AuthorizationCodeApplication where+instance HasClientAuthenticationMethod AuthorizationCodeApplication where getClientAuthenticationMethod :: AuthorizationCodeApplication -> ClientAuthenticationMethod- getClientAuthenticationMethod AuthorizationCodeApplication {..} = acTokenRequestAuthenticationMethod+ getClientAuthenticationMethod AuthorizationCodeApplication {..} = acClientAuthenticationMethod+ addClientAuthToHeader AuthorizationCodeApplication {..} = addSecretToHeader acClientId acClientSecret -instance HasAuthorizeRequest AuthorizationCodeApplication where- mkAuthorizationRequestParam :: AuthorizationCodeApplication -> AuthorizationRequestParam- mkAuthorizationRequestParam AuthorizationCodeApplication {..} =- AuthorizationRequestParam- { arScope = acScope- , arState = acAuthorizeState- , arClientId = acClientId- , arRedirectUri = Just (RedirectUri acRedirectUri)- , arResponseType = Code- , arExtraParams = acAuthorizeRequestExtraParams- }+mkAuthorizationRequestParam :: AuthorizationCodeApplication -> AuthorizationRequestParam+mkAuthorizationRequestParam AuthorizationCodeApplication {..} =+ AuthorizationRequestParam+ { arScope = acScope+ , arState = acAuthorizeState+ , arClientId = acClientId+ , arRedirectUri = Just (RedirectUri acRedirectUri)+ , arResponseType = Code+ , arExtraParams = acAuthorizeRequestExtraParams+ } -instance HasPkceAuthorizeRequest AuthorizationCodeApplication where- mkPkceAuthorizeRequestParam :: MonadIO m => AuthorizationCodeApplication -> m (AuthorizationRequestParam, CodeVerifier)- mkPkceAuthorizeRequestParam app = do- PkceRequestParam {..} <- mkPkceParam- let authReqParam = mkAuthorizationRequestParam app- combinatedExtraParams =- Map.unions- [ arExtraParams authReqParam- , toQueryParam codeChallenge- , toQueryParam codeChallengeMethod- ]- pure (authReqParam {arExtraParams = combinatedExtraParams}, codeVerifier)+mkPkceAuthorizeRequestParam :: MonadIO m => AuthorizationCodeApplication -> m (AuthorizationRequestParam, CodeVerifier)+mkPkceAuthorizeRequestParam app = do+ PkceRequestParam {..} <- mkPkceParam+ let authReqParam = mkAuthorizationRequestParam app+ combinatedExtraParams =+ Map.unions+ [ arExtraParams authReqParam+ , toQueryParam codeChallenge+ , toQueryParam codeChallengeMethod+ ]+ pure (authReqParam {arExtraParams = combinatedExtraParams}, codeVerifier) -- | https://www.rfc-editor.org/rfc/rfc6749#section-4.1.3 instance HasTokenRequest AuthorizationCodeApplication where@@ -71,6 +67,9 @@ { trCode :: ExchangeToken , trGrantType :: GrantTypeValue , trRedirectUri :: RedirectUri+ , trClientId :: ClientId+ , trClientSecret :: ClientSecret+ , trClientAuthenticationMethod :: ClientAuthenticationMethod } mkTokenRequestParam :: AuthorizationCodeApplication -> ExchangeToken -> TokenRequest AuthorizationCodeApplication@@ -79,16 +78,30 @@ { trCode = authCode , trGrantType = GTAuthorizationCode , trRedirectUri = RedirectUri acRedirectUri+ , trClientId = acClientId+ , trClientSecret = acClientSecret+ , trClientAuthenticationMethod = acClientAuthenticationMethod } instance ToQueryParam (TokenRequest AuthorizationCodeApplication) where toQueryParam :: TokenRequest AuthorizationCodeApplication -> Map Text Text toQueryParam AuthorizationCodeTokenRequest {..} =- Map.unions- [ toQueryParam trCode- , toQueryParam trGrantType- , toQueryParam trRedirectUri- ]+ let extraBodyBasedOnClientAuthMethod =+ case trClientAuthenticationMethod of+ ClientAssertionJwt ->+ [ Map.fromList+ [ ("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")+ , ("client_assertion", bs8ToLazyText $ tlToBS $ unClientSecret trClientSecret)+ ]+ ]+ ClientSecretPost -> [toQueryParam trClientId, toQueryParam trClientSecret]+ ClientSecretBasic -> []+ in Map.unions $+ [ toQueryParam trCode+ , toQueryParam trGrantType+ , toQueryParam trRedirectUri+ ]+ ++ extraBodyBasedOnClientAuthMethod instance HasUserInfoRequest AuthorizationCodeApplication @@ -99,4 +112,6 @@ { rrScope = acScope , rrGrantType = GTRefreshToken , rrRefreshToken = rt+ , rrClientId = if acClientAuthenticationMethod == ClientSecretPost then Just acClientId else Nothing+ , rrClientSecret = if acClientAuthenticationMethod == ClientSecretPost then Just acClientSecret else Nothing }
src/Network/OAuth2/Experiment/Grants/ClientCredentials.hs view
@@ -6,7 +6,7 @@ import Data.Map.Strict qualified as Map import Data.Set (Set) import Data.Text.Lazy (Text)-import Network.OAuth.OAuth2 (ClientAuthenticationMethod (..), OAuth2)+import Network.OAuth2 (ClientAuthenticationMethod (..)) import Network.OAuth2.Experiment.Flows.TokenRequest import Network.OAuth2.Experiment.Types import Network.OAuth2.Experiment.Utils@@ -20,17 +20,15 @@ , ccName :: Text , ccScope :: Set Scope , ccTokenRequestExtraParams :: Map Text Text- , ccTokenRequestAuthenticationMethod :: ClientAuthenticationMethod+ , ccClientAuthenticationMethod :: ClientAuthenticationMethod } -instance HasOAuth2Key ClientCredentialsApplication where- mkOAuth2Key :: ClientCredentialsApplication -> OAuth2- mkOAuth2Key ClientCredentialsApplication {..} = toOAuth2Key ccClientId ccClientSecret--instance HasTokenRequestClientAuthenticationMethod ClientCredentialsApplication where+instance HasClientAuthenticationMethod ClientCredentialsApplication where getClientAuthenticationMethod :: ClientCredentialsApplication -> ClientAuthenticationMethod- getClientAuthenticationMethod ClientCredentialsApplication {..} = ccTokenRequestAuthenticationMethod+ getClientAuthenticationMethod ClientCredentialsApplication {..} = ccClientAuthenticationMethod + addClientAuthToHeader ClientCredentialsApplication {..} = addSecretToHeader ccClientId ccClientSecret+ -- | https://www.rfc-editor.org/rfc/rfc6749#section-4.4.2 instance HasTokenRequest ClientCredentialsApplication where type ExchangeTokenInfo ClientCredentialsApplication = NoNeedExchangeToken@@ -49,7 +47,7 @@ { trScope = ccScope , trGrantType = GTClientCredentials , trClientSecret = ccClientSecret- , trClientAuthenticationMethod = ccTokenRequestAuthenticationMethod+ , trClientAuthenticationMethod = ccClientAuthenticationMethod , trExtraParams = ccTokenRequestExtraParams , trClientId = ccClientId }@@ -57,19 +55,19 @@ instance ToQueryParam (TokenRequest ClientCredentialsApplication) where toQueryParam :: TokenRequest ClientCredentialsApplication -> Map Text Text toQueryParam ClientCredentialsTokenRequest {..} =- let jwtAssertionBody =- if trClientAuthenticationMethod == ClientAssertionJwt- then- [ toQueryParam trClientId- , Map.fromList+ let extraBodyBasedOnClientAuthMethod =+ case trClientAuthenticationMethod of+ ClientAssertionJwt ->+ [ Map.fromList [ ("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") , ("client_assertion", bs8ToLazyText $ tlToBS $ unClientSecret trClientSecret) ] ]- else []+ ClientSecretPost -> [toQueryParam trClientId, toQueryParam trClientSecret]+ ClientSecretBasic -> [] in Map.unions $ [ toQueryParam trGrantType , toQueryParam trScope , trExtraParams ]- ++ jwtAssertionBody+ ++ extraBodyBasedOnClientAuthMethod
src/Network/OAuth2/Experiment/Grants/DeviceAuthorization.hs view
@@ -1,20 +1,13 @@ {-# LANGUAGE FlexibleInstances #-} -module Network.OAuth2.Experiment.Grants.DeviceAuthorization (- DeviceAuthorizationApplication (..),- pollDeviceTokenRequest,-) where+module Network.OAuth2.Experiment.Grants.DeviceAuthorization where -import Control.Concurrent-import Control.Monad.IO.Class (MonadIO (..))-import Control.Monad.Trans.Except import Data.Map.Strict (Map) import Data.Map.Strict qualified as Map import Data.Maybe import Data.Set (Set) import Data.Text.Lazy (Text)-import Network.HTTP.Conduit-import Network.OAuth.OAuth2+import Network.OAuth2 import Network.OAuth2.Experiment.Flows.DeviceAuthorizationRequest import Network.OAuth2.Experiment.Flows.TokenRequest import Network.OAuth2.Experiment.Flows.UserInfoRequest@@ -33,69 +26,26 @@ -- ^ Additional parameters to the device authorization request. -- Most of identity providers follow the spec strictly but -- AzureAD requires "tenant" parameter.- , daAuthorizationRequestAuthenticationMethod :: Maybe ClientAuthenticationMethod+ , daAuthorizationRequestAuthenticationMethod :: ClientAuthenticationMethod -- ^ The spec requires similar authentication method as /token request. -- Most of identity providers doesn't required it but some does like Okta. } -pollDeviceTokenRequest ::- MonadIO m =>- IdpApplication i DeviceAuthorizationApplication ->- Manager ->- DeviceAuthorizationResponse ->- ExceptT TokenResponseError m OAuth2Token-pollDeviceTokenRequest idpApp mgr deviceAuthResp = do- pollDeviceTokenRequestInternal- idpApp- mgr- (deviceCode deviceAuthResp)- (fromMaybe 5 $ interval deviceAuthResp)--pollDeviceTokenRequestInternal ::- MonadIO m =>- IdpApplication i DeviceAuthorizationApplication ->- Manager ->- DeviceCode ->- Int ->- -- | Polling Interval- ExceptT TokenResponseError m OAuth2Token-pollDeviceTokenRequestInternal idpApp mgr deviceCode intervalSeconds = do- resp <- runExceptT (conduitTokenRequest idpApp mgr deviceCode)- case resp of- Left trRespError -> do- case tokenResponseError trRespError of- -- TODO: Didn't have a good idea to expand the error code- -- specifically for device token request flow- -- Device Token Response additional error code: https://www.rfc-editor.org/rfc/rfc8628#section-3.5- UnknownErrorCode "authorization_pending" -> do- liftIO $ threadDelay $ intervalSeconds * 1000000- pollDeviceTokenRequestInternal idpApp mgr deviceCode intervalSeconds- UnknownErrorCode "slow_down" -> do- let newIntervalSeconds = intervalSeconds + 5- liftIO $ threadDelay $ newIntervalSeconds * 1000000- pollDeviceTokenRequestInternal idpApp mgr deviceCode newIntervalSeconds- _ -> throwE trRespError- Right v -> pure v--instance HasOAuth2Key DeviceAuthorizationApplication where- mkOAuth2Key :: DeviceAuthorizationApplication -> OAuth2- mkOAuth2Key DeviceAuthorizationApplication {..} = toOAuth2Key daClientId daClientSecret--instance HasTokenRequestClientAuthenticationMethod DeviceAuthorizationApplication where+instance HasClientAuthenticationMethod DeviceAuthorizationApplication where getClientAuthenticationMethod :: DeviceAuthorizationApplication -> ClientAuthenticationMethod- getClientAuthenticationMethod _ = ClientSecretBasic+ getClientAuthenticationMethod = daAuthorizationRequestAuthenticationMethod+ addClientAuthToHeader DeviceAuthorizationApplication {..} = addSecretToHeader daClientId daClientSecret -instance HasDeviceAuthorizationRequest DeviceAuthorizationApplication where- mkDeviceAuthorizationRequestParam :: DeviceAuthorizationApplication -> DeviceAuthorizationRequestParam- mkDeviceAuthorizationRequestParam DeviceAuthorizationApplication {..} =- DeviceAuthorizationRequestParam- { arScope = daScope- , arClientId =- if daAuthorizationRequestAuthenticationMethod == Just ClientSecretBasic- then Nothing- else Just daClientId- , arExtraParams = daAuthorizationRequestExtraParam- }+mkDeviceAuthorizationRequestParam :: DeviceAuthorizationApplication -> DeviceAuthorizationRequestParam+mkDeviceAuthorizationRequestParam DeviceAuthorizationApplication {..} =+ DeviceAuthorizationRequestParam+ { darScope = daScope+ , darClientId =+ if daAuthorizationRequestAuthenticationMethod == ClientSecretPost+ then Just daClientId+ else Nothing+ , darExtraParams = daAuthorizationRequestExtraParam+ } -- | https://www.rfc-editor.org/rfc/rfc8628#section-3.4 instance HasTokenRequest DeviceAuthorizationApplication where@@ -123,9 +73,9 @@ { trCode = deviceCode , trGrantType = GTDeviceCode , trClientId =- if daAuthorizationRequestAuthenticationMethod == Just ClientSecretBasic- then Nothing- else Just daClientId+ if daAuthorizationRequestAuthenticationMethod == ClientSecretPost+ then Just daClientId+ else Nothing } instance ToQueryParam (TokenRequest DeviceAuthorizationApplication) where
src/Network/OAuth2/Experiment/Grants/JwtBearer.hs view
@@ -3,11 +3,10 @@ module Network.OAuth2.Experiment.Grants.JwtBearer where import Data.ByteString qualified as BS-import Data.Default (Default (def)) import Data.Map.Strict (Map) import Data.Map.Strict qualified as Map import Data.Text.Lazy (Text)-import Network.OAuth.OAuth2 (ClientAuthenticationMethod (..), OAuth2)+import Network.OAuth2 (ClientAuthenticationMethod (..)) import Network.OAuth2.Experiment.Flows.TokenRequest import Network.OAuth2.Experiment.Flows.UserInfoRequest import Network.OAuth2.Experiment.Types@@ -21,15 +20,7 @@ , jbJwtAssertion :: BS.ByteString } --- JwtBearner doesn't use @client_id@ and @client_secret@ for authentication.------ FIXME: The ideal solution shall be do not implement `HasOAuth2Key`--- but it will stop to re-use the method 'conduitTokenRequest' for JwtBearer flow.-instance HasOAuth2Key JwtBearerApplication where- mkOAuth2Key :: JwtBearerApplication -> OAuth2- mkOAuth2Key _ = def--instance HasTokenRequestClientAuthenticationMethod JwtBearerApplication where+instance HasClientAuthenticationMethod JwtBearerApplication where getClientAuthenticationMethod :: JwtBearerApplication -> ClientAuthenticationMethod getClientAuthenticationMethod _ = ClientAssertionJwt
src/Network/OAuth2/Experiment/Grants/ResourceOwnerPassword.hs view
@@ -6,8 +6,8 @@ import Data.Map.Strict qualified as Map import Data.Set (Set) import Data.Text.Lazy (Text)-import Network.OAuth.OAuth2 (ClientAuthenticationMethod (..), OAuth2 (..))-import Network.OAuth.OAuth2 qualified as OAuth2+import Network.OAuth2 (ClientAuthenticationMethod (..))+import Network.OAuth2 qualified as OAuth2 import Network.OAuth2.Experiment.Flows.RefreshTokenRequest import Network.OAuth2.Experiment.Flows.TokenRequest import Network.OAuth2.Experiment.Flows.UserInfoRequest@@ -24,15 +24,13 @@ , ropUserName :: Username , ropPassword :: Password , ropTokenRequestExtraParams :: Map Text Text+ , ropClientAuthenticationMethod :: ClientAuthenticationMethod } -instance HasOAuth2Key ResourceOwnerPasswordApplication where- mkOAuth2Key :: ResourceOwnerPasswordApplication -> OAuth2- mkOAuth2Key ResourceOwnerPasswordApplication {..} = toOAuth2Key ropClientId ropClientSecret--instance HasTokenRequestClientAuthenticationMethod ResourceOwnerPasswordApplication where+instance HasClientAuthenticationMethod ResourceOwnerPasswordApplication where getClientAuthenticationMethod :: ResourceOwnerPasswordApplication -> ClientAuthenticationMethod- getClientAuthenticationMethod _ = ClientSecretBasic+ getClientAuthenticationMethod = ropClientAuthenticationMethod+ addClientAuthToHeader ResourceOwnerPasswordApplication {..} = addSecretToHeader ropClientId ropClientSecret -- | https://www.rfc-editor.org/rfc/rfc6749#section-4.3.2 instance HasTokenRequest ResourceOwnerPasswordApplication where@@ -75,4 +73,6 @@ { rrScope = ropScope , rrGrantType = GTRefreshToken , rrRefreshToken = rt+ , rrClientId = if ropClientAuthenticationMethod == ClientSecretPost then Just ropClientId else Nothing+ , rrClientSecret = if ropClientAuthenticationMethod == ClientSecretPost then Just ropClientSecret else Nothing }
src/Network/OAuth2/Experiment/Types.hs view
@@ -4,7 +4,6 @@ module Network.OAuth2.Experiment.Types where -import Data.Default (Default (def)) import Data.Map.Strict (Map) import Data.Map.Strict qualified as Map import Data.Set (Set)@@ -12,8 +11,8 @@ import Data.String import Data.Text.Lazy (Text) import Data.Text.Lazy qualified as TL-import Network.OAuth.OAuth2 hiding (RefreshToken)-import Network.OAuth.OAuth2 qualified as OAuth2+import Network.OAuth2 hiding (RefreshToken)+import Network.OAuth2 qualified as OAuth2 import Network.OAuth2.Experiment.Pkce import Network.OAuth2.Experiment.Utils import URI.ByteString (URI, serializeURIRef')@@ -24,9 +23,6 @@ ------------------------------------------------------------------------------- --- TODO: Distinct type per endpoint--- Because I made mistake at passing to Authorize and Token Request- -- | @Idp i@ consists various endpoints endpoints. -- -- The @i@ is actually phantom type for information only (Idp name) at this moment.@@ -74,7 +70,7 @@ -- -- Would be nice to define Enum for standard Scope, plus allow user to define their own define (per Idp) and plugin somehow. newtype Scope = Scope {unScope :: Text}- deriving (Eq, Ord)+ deriving (Eq, Ord, Show) instance IsString Scope where fromString :: String -> Scope@@ -111,19 +107,10 @@ newtype ClientId = ClientId {unClientId :: Text} deriving (Show, Eq, IsString) --- | Can be either "Client Secret" or JWT base on client authentication method+-- | Can be either "Client Secret" or JWT based on the client authentication method newtype ClientSecret = ClientSecret {unClientSecret :: Text} deriving (Eq, IsString) --- | In order to reuse some methods from legacy "Network.OAuth.OAuth2".--- Will be removed when Experiment module becomes default.-toOAuth2Key :: ClientId -> ClientSecret -> OAuth2-toOAuth2Key cid csecret =- def- { oauth2ClientId = TL.toStrict $ unClientId cid- , oauth2ClientSecret = TL.toStrict $ unClientSecret csecret- }- newtype RedirectUri = RedirectUri {unRedirectUri :: URI} deriving (Eq) @@ -201,7 +188,10 @@ toQueryParam = toScopeParam . Set.map unScope where toScopeParam :: IsString a => Set Text -> Map a Text- toScopeParam scope = Map.singleton "scope" (TL.intercalate " " $ Set.toList scope)+ toScopeParam scope =+ if Set.null scope+ then Map.empty+ else Map.singleton "scope" (TL.intercalate " " $ Set.toList scope) instance ToQueryParam CodeVerifier where toQueryParam :: CodeVerifier -> Map Text Text@@ -226,13 +216,3 @@ instance ToQueryParam ResponseType where toQueryParam :: ResponseType -> Map Text Text toQueryParam Code = Map.singleton "response_type" "code"------------------------------------------------------------------------------------ HasOAuth2Key ----- ----- Find a way to reuse some methods from old implementation ----- Probably will be removed when Experiment module becomes default ------------------------------------------------------------------------------------class HasOAuth2Key a where- mkOAuth2Key :: a -> OAuth2
+ src/Network/OAuth2/HttpClient.hs view
@@ -0,0 +1,245 @@+-- | Bindings for The OAuth 2.0 Authorization Framework: Bearer Token Usage+-- RFC6750 <https://www.rfc-editor.org/rfc/rfc6750>+module Network.OAuth2.HttpClient (+ -- * AUTH requests+ authGetJSON,+ authGetBS,+ authGetJSONWithAuthMethod,+ authGetBSWithAuthMethod,+ authPostJSON,+ authPostBS,+ authPostJSONWithAuthMethod,+ authPostBSWithAuthMethod,++ -- * Types+ APIAuthenticationMethod (..),+) where++import Control.Monad.IO.Class (MonadIO (..))+import Control.Monad.Trans.Except (ExceptT (..), throwE)+import Data.Aeson (FromJSON)+import Data.Aeson qualified as Aeson+import Data.Aeson.Key qualified as Aeson+import Data.Aeson.KeyMap qualified as Aeson+import Data.ByteString.Char8 qualified as BS+import Data.ByteString.Lazy.Char8 qualified as BSL+import Data.Text.Encoding qualified as T+import Lens.Micro (over)+import Network.HTTP.Client.Conduit (applyBearerAuth)+import Network.HTTP.Client.Contrib (handleResponse)+import Network.HTTP.Conduit+import Network.HTTP.Types qualified as HT+import Network.OAuth2.Internal+import URI.ByteString (URI, URIRef, queryL, queryPairsL)++--------------------------------------------------++-- * AUTH requests++-- Making request with Access Token appended to Header, Request body or query string.+--+--------------------------------------------------++-- | Conduct an authorized GET request and return response as JSON.+-- Inject Access Token to Authorization Header.+authGetJSON ::+ (MonadIO m, FromJSON a) =>+ -- | HTTP connection manager.+ Manager ->+ AccessToken ->+ URI ->+ -- | Response as JSON+ ExceptT BSL.ByteString m a+authGetJSON = authGetJSONWithAuthMethod AuthInRequestHeader++-- | Conduct an authorized GET request and return response as JSON.+-- Allow to specify how to append AccessToken.+--+-- @since 2.6.0+authGetJSONWithAuthMethod ::+ (MonadIO m, FromJSON a) =>+ APIAuthenticationMethod ->+ -- | HTTP connection manager.+ Manager ->+ AccessToken ->+ URI ->+ -- | Response as JSON+ ExceptT BSL.ByteString m a+authGetJSONWithAuthMethod authTypes manager t uri = do+ resp <- authGetBSWithAuthMethod authTypes manager t uri+ either (throwE . BSL.pack) return (Aeson.eitherDecode resp)++-- | Conduct an authorized GET request.+-- Inject Access Token to Authorization Header.+authGetBS ::+ MonadIO m =>+ -- | HTTP connection manager.+ Manager ->+ AccessToken ->+ URI ->+ -- | Response as ByteString+ ExceptT BSL.ByteString m BSL.ByteString+authGetBS = authGetBSWithAuthMethod AuthInRequestHeader++-- | Conduct an authorized GET request and return response as ByteString.+-- Allow to specify how to append AccessToken.+--+-- @since 2.6.0+authGetBSWithAuthMethod ::+ MonadIO m =>+ -- | Specify the way that how to append the 'AccessToken' in the request+ APIAuthenticationMethod ->+ -- | HTTP connection manager.+ Manager ->+ AccessToken ->+ URI ->+ -- | Response as ByteString+ ExceptT BSL.ByteString m BSL.ByteString+authGetBSWithAuthMethod authTypes manager token url = do+ let appendToUrl = AuthInRequestQuery == authTypes+ let appendToHeader = AuthInRequestHeader == authTypes+ let uri = if appendToUrl then url `appendAccessToken` token else url+ let upReq = updateRequestHeaders (if appendToHeader then Just token else Nothing) . setMethod HT.GET+ req <- liftIO $ uriToRequest uri+ authRequest req upReq manager++-- | Conduct POST request and return response as JSON.+-- Inject Access Token to Authorization Header.+authPostJSON ::+ (MonadIO m, FromJSON a) =>+ -- | HTTP connection manager.+ Manager ->+ AccessToken ->+ URI ->+ PostBody ->+ -- | Response as JSON+ ExceptT BSL.ByteString m a+authPostJSON = authPostJSONWithAuthMethod AuthInRequestHeader++-- | Conduct POST request and return response as JSON.+-- Allow to specify how to append AccessToken.+--+-- @since 2.6.0+authPostJSONWithAuthMethod ::+ (MonadIO m, FromJSON a) =>+ APIAuthenticationMethod ->+ -- | HTTP connection manager.+ Manager ->+ AccessToken ->+ URI ->+ PostBody ->+ -- | Response as ByteString+ ExceptT BSL.ByteString m a+authPostJSONWithAuthMethod authTypes manager token url body = do+ resp <- authPostBSWithAuthMethod authTypes manager token url body+ either (throwE . BSL.pack) return (Aeson.eitherDecode resp)++-- | Conduct POST request.+-- Inject Access Token to http header (Authorization)+authPostBS ::+ MonadIO m =>+ -- | HTTP connection manager.+ Manager ->+ AccessToken ->+ URI ->+ PostBody ->+ -- | Response as ByteString+ ExceptT BSL.ByteString m BSL.ByteString+authPostBS = authPostBSWithAuthMethod AuthInRequestHeader++-- | Conduct POST request and return response as ByteString.+-- Allow to specify how to append AccessToken.+--+-- @since 2.6.0+authPostBSWithAuthMethod ::+ MonadIO m =>+ APIAuthenticationMethod ->+ -- | HTTP connection manager.+ Manager ->+ AccessToken ->+ URI ->+ PostBody ->+ -- | Response as ByteString+ ExceptT BSL.ByteString m BSL.ByteString+authPostBSWithAuthMethod authTypes manager token url body = do+ let appendToBody = AuthInRequestBody == authTypes+ let appendToHeader = AuthInRequestHeader == authTypes+ let reqBody = if appendToBody then body ++ accessTokenToParam token else body+ let upBody = if null reqBody then id else jsonBody reqBody+ let upHeaders = updateRequestHeaders (if appendToHeader then Just token else Nothing) . setMethod HT.POST+ let upReq = upHeaders . upBody++ req <- liftIO $ uriToRequest url+ authRequest req upReq manager++jsonBody :: PostBody -> Request -> Request+jsonBody body req =+ req+ { requestBody =+ RequestBodyLBS $+ Aeson.encode $+ Aeson.fromList $+ fmap (\(a, b) -> (Aeson.fromText (T.decodeUtf8 a), T.decodeUtf8 b)) body+ , requestHeaders =+ (HT.hContentType, "application/json")+ : filter (\(x, _) -> x /= HT.hContentType) (requestHeaders req)+ }++--------------------------------------------------++-- * Types++--------------------------------------------------++-- | https://www.rfc-editor.org/rfc/rfc6750#section-2+data APIAuthenticationMethod+ = -- | Provides in Authorization header+ AuthInRequestHeader+ | -- | Provides in request body+ AuthInRequestBody+ | -- | Provides in request query parameter+ AuthInRequestQuery+ deriving (Eq, Ord)++--------------------------------------------------++-- * Utilities++--------------------------------------------------++-- | Send an HTTP request.+authRequest ::+ MonadIO m =>+ -- | Request to perform+ Request ->+ -- | Modify request before sending+ (Request -> Request) ->+ -- | HTTP connection manager.+ Manager ->+ ExceptT BSL.ByteString m BSL.ByteString+authRequest req upReq manage = ExceptT $ do+ resp <- httpLbs (upReq req) manage+ pure (handleResponse resp)++updateRequestHeaders :: Maybe AccessToken -> Request -> Request+updateRequestHeaders mt =+ maybe id (applyBearerAuth . T.encodeUtf8 . atoken) mt+ . addDefaultRequestHeaders++-- | Set the HTTP method to use.+setMethod :: HT.StdMethod -> Request -> Request+setMethod m req = req {method = HT.renderStdMethod m}++-- | For @GET@ method API.+appendAccessToken ::+ -- | Base URI+ URIRef a ->+ -- | Authorized Access Token+ AccessToken ->+ -- | Combined Result+ URIRef a+appendAccessToken uri t = over (queryL . queryPairsL) (\query -> query ++ accessTokenToParam t) uri++-- | Create `QueryParams` with given access token value.+accessTokenToParam :: AccessToken -> [(BS.ByteString, BS.ByteString)]+accessTokenToParam t = [("access_token", T.encodeUtf8 $ atoken t)]
+ src/Network/OAuth2/Internal.hs view
@@ -0,0 +1,116 @@+{-# LANGUAGE QuasiQuotes #-}++module Network.OAuth2.Internal where++import Control.Monad.Catch+import Data.Aeson+import Data.Binary.Instances.Aeson ()+import Data.ByteString qualified as BS+import Data.ByteString.Char8 qualified as BS8+import Data.Default+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.Encoding qualified as T+import Data.Version (showVersion)+import Lens.Micro (over)+import Network.HTTP.Conduit as C+import Network.HTTP.Types qualified as HT+import Paths_hoauth2 (version)+import URI.ByteString+import URI.ByteString.Aeson ()+import URI.ByteString.QQ++-------------------------------------------------------------------------------++-- * OAuth2 Configuration++-------------------------------------------------------------------------------++-- | Query Parameter Representation+data OAuth2 = OAuth2+ { oauth2ClientId :: Text+ , oauth2ClientSecret :: Text+ , oauth2AuthorizeEndpoint :: URIRef Absolute+ , oauth2TokenEndpoint :: URIRef Absolute+ , oauth2RedirectUri :: URIRef Absolute+ }+ deriving (Show, Eq)++instance Default OAuth2 where+ def =+ OAuth2+ { oauth2ClientId = ""+ , oauth2ClientSecret = ""+ , oauth2AuthorizeEndpoint = [uri|https://www.example.com/|]+ , oauth2TokenEndpoint = [uri|https://www.example.com/|]+ , oauth2RedirectUri = [uri|https://www.example.com/|]+ }++-------------------------------------------------------------------------------++-- * Tokens++-------------------------------------------------------------------------------++newtype AccessToken = AccessToken {atoken :: Text} deriving (Eq, Show, FromJSON, ToJSON)++newtype RefreshToken = RefreshToken {rtoken :: Text} deriving (Eq, Show, FromJSON, ToJSON)++newtype IdToken = IdToken {idtoken :: Text} deriving (Eq, Show, FromJSON, ToJSON)++-- | Authorization Code+newtype ExchangeToken = ExchangeToken {extoken :: Text} deriving (Show, FromJSON, ToJSON)++-------------------------------------------------------------------------------++-- * Client Authentication methods++-------------------------------------------------------------------------------++-- | How would the Client (RP) authenticate itself?+--+-- The client MUST NOT use more than one authentication method in each request.+-- Means use Authorization header or Post body.+--+-- See more details+--+-- https://www.rfc-editor.org/rfc/rfc6749#section-2.3+-- https://oauth.net/private-key-jwt/+-- https://www.rfc-editor.org/rfc/rfc7523.html+data ClientAuthenticationMethod+ = ClientSecretBasic+ | ClientSecretPost+ | ClientAssertionJwt+ deriving (Eq, Show)++-------------------------------------------------------------------------------++-- * Utilies for Request and URI++-------------------------------------------------------------------------------++-- | Type synonym of post body content+type PostBody = [(BS.ByteString, BS.ByteString)]++-- | Type sysnonym of request query params+type QueryParams = [(BS.ByteString, BS.ByteString)]++defaultRequestHeaders :: [(HT.HeaderName, BS.ByteString)]+defaultRequestHeaders =+ [ (HT.hUserAgent, "hoauth2-" <> (T.encodeUtf8 . T.pack $ showVersion version))+ , (HT.hAccept, "application/json")+ ]++-- | Set several header values:+-- + userAgennt : "hoauth2"+-- + accept : "application/json"+addDefaultRequestHeaders :: Request -> Request+addDefaultRequestHeaders req =+ let headers = defaultRequestHeaders ++ requestHeaders req+ in req {requestHeaders = headers}++appendQueryParams :: [(BS.ByteString, BS.ByteString)] -> URIRef a -> URIRef a+appendQueryParams params = over (queryL . queryPairsL) (params ++)++uriToRequest :: MonadThrow m => URI -> m Request+uriToRequest = parseRequest . BS8.unpack . serializeURIRef'
+ src/Network/OAuth2/TokenRequest.hs view
@@ -0,0 +1,362 @@+-- | Bindings Access Token and Refresh Token part of The OAuth 2.0 Authorization Framework+-- RFC6749 <https://www.rfc-editor.org/rfc/rfc6749>+module Network.OAuth2.TokenRequest where++import Control.Monad.IO.Class (MonadIO (..))+import Control.Monad.Trans.Except (ExceptT (..), throwE)+import Data.Aeson+import Data.Aeson qualified as Aeson+import Data.Aeson.Key qualified as Key+import Data.Aeson.KeyMap qualified as KeyMap+import Data.Aeson.Types (Parser, explicitParseFieldMaybe)+import Data.Binary (Binary (..))+import Data.Binary.Instances.Aeson ()+import Data.ByteString.Lazy.Char8 qualified as BSL+import Data.Text (Text, unpack)+import Data.Text qualified as T+import Data.Text.Encoding qualified as T+import Network.HTTP.Conduit+import Network.HTTP.Types qualified as HT+import Network.HTTP.Types.URI (parseQuery)+import Network.OAuth2.Internal+import URI.ByteString+import Prelude hiding (error)++--------------------------------------------------++-- * Token Request Errors++--------------------------------------------------++data TokenResponseError = TokenResponseError+ { tokenResponseError :: TokenResponseErrorCode+ , tokenResponseErrorDescription :: Maybe Text+ , tokenResponseErrorUri :: Maybe (URIRef Absolute)+ }+ deriving (Show, Eq)++-- | Token Error Responses https://tools.ietf.org/html/rfc6749#section-5.2+data TokenResponseErrorCode+ = InvalidRequest+ | InvalidClient+ | InvalidGrant+ | UnauthorizedClient+ | UnsupportedGrantType+ | InvalidScope+ | UnknownErrorCode Text+ deriving (Show, Eq)++instance FromJSON TokenResponseErrorCode where+ parseJSON = withText "parseJSON TokenResponseErrorCode" $ \t ->+ pure $ case t of+ "invalid_request" -> InvalidRequest+ "invalid_client" -> InvalidClient+ "invalid_grant" -> InvalidGrant+ "unauthorized_client" -> UnauthorizedClient+ "unsupported_grant_type" -> UnsupportedGrantType+ "invalid_scope" -> InvalidScope+ _ -> UnknownErrorCode t++instance FromJSON TokenResponseError where+ parseJSON = withObject "parseJSON TokenResponseError" $ \t -> do+ tokenResponseError <- t .: "error"+ tokenResponseErrorDescription <- t .:? "error_description"+ tokenResponseErrorUri <- t .:? "error_uri"+ pure TokenResponseError {..}++parseTokeResponseError :: BSL.ByteString -> TokenResponseError+parseTokeResponseError string =+ either (mkDecodeOAuth2Error string) id (eitherDecode string)+ where+ mkDecodeOAuth2Error :: BSL.ByteString -> String -> TokenResponseError+ mkDecodeOAuth2Error response err =+ TokenResponseError+ (UnknownErrorCode "")+ (Just $ T.pack $ "Decode TokenResponseError failed: " <> err <> "\n Original Response:\n" <> show (T.decodeUtf8 $ BSL.toStrict response))+ Nothing++-------------------------------------------------------------------------------++-- * Tokens++-------------------------------------------------------------------------------++-- | https://www.rfc-editor.org/rfc/rfc6749#section-4.1.4+data TokenResponse = TokenResponse+ { accessToken :: AccessToken+ , refreshToken :: Maybe RefreshToken+ -- ^ Exists when @offline_access@ scope is in the Authorization Request and the provider supports Refresh Access Token.+ , expiresIn :: Maybe Int+ , tokenType :: Maybe Text+ -- ^ See https://www.rfc-editor.org/rfc/rfc6749#section-5.1. It's required by spec, but OAuth2 provider implementations vary. We may remove 'Maybe' in a future release.+ , idToken :: Maybe IdToken+ -- ^ Exists when @openid@ scope is in the Authorization Request and the provider supports OpenID protocol.+ , scope :: Maybe Text+ , rawResponse :: Object+ }+ deriving (Eq)++instance Show TokenResponse where+ show TokenResponse {..} =+ "TokenResponse {"+ <> "access_token = ***"+ <> ", id_token = "+ <> showM idToken+ <> ", refresh_token = "+ <> showM refreshToken+ <> ", expires_in = "+ <> show expiresIn+ <> ", token_type = "+ <> show tokenType+ <> ", scope = "+ <> show scope+ <> ", raw_response = ***"+ <> "}"+ where+ showM (Just _) = "***"+ showM Nothing = "Nothing"++instance Binary TokenResponse where+ put TokenResponse {..} = put rawResponse+ get = do+ rawt <- get+ case fromJSON (Aeson.Object rawt) of+ Success a -> pure a+ Error err -> fail err++-- | Parse JSON data into 'TokenResponse'+instance FromJSON TokenResponse where+ parseJSON :: Value -> Parser TokenResponse+ parseJSON = withObject "TokenResponse" $ \v ->+ TokenResponse+ <$> v .: "access_token"+ <*> v .:? "refresh_token"+ <*> explicitParseFieldMaybe parseIntFlexible v "expires_in"+ <*> v .:? "token_type"+ <*> v .:? "id_token"+ <*> v .:? "scope"+ <*> pure v+ where+ parseIntFlexible :: Value -> Parser Int+ parseIntFlexible (String s) = pure . read $ unpack s+ parseIntFlexible v = parseJSON v++instance ToJSON TokenResponse where+ toJSON :: TokenResponse -> Value+ toJSON = toJSON . Object . rawResponse+ toEncoding :: TokenResponse -> Encoding+ toEncoding = toEncoding . Object . rawResponse++--------------------------------------------------++-- * URL++--------------------------------------------------++-- | Prepare the URL and the request body query for fetching an access token.+accessTokenUrl ::+ OAuth2 ->+ -- | access code gained via authorization URL+ ExchangeToken ->+ -- | access token request URL plus the request body.+ (URI, PostBody)+accessTokenUrl oa code =+ let uri = oauth2TokenEndpoint oa+ body =+ [ ("code", T.encodeUtf8 $ extoken code)+ , ("redirect_uri", serializeURIRef' $ oauth2RedirectUri oa)+ , ("grant_type", "authorization_code")+ ]+ in (uri, body)++-- | Obtain a new access token by sending a Refresh Token to the Authorization server.+refreshAccessTokenUrl ::+ OAuth2 ->+ -- | Refresh Token gained via authorization URL+ RefreshToken ->+ -- | Refresh Token request URL plus the request body.+ (URI, PostBody)+refreshAccessTokenUrl oa token = (uri, body)+ where+ uri = oauth2TokenEndpoint oa+ body =+ [ ("grant_type", "refresh_token")+ , ("refresh_token", T.encodeUtf8 $ rtoken token)+ ]++--------------------------------------------------++-- * Token management++--------------------------------------------------++-- | Exchange @code@ for an Access Token with authenticate in request header.+fetchAccessToken ::+ MonadIO m =>+ -- | HTTP connection manager+ Manager ->+ -- | OAuth Data+ OAuth2 ->+ -- | OAuth2 Code+ ExchangeToken ->+ -- | Access Token+ ExceptT TokenResponseError m TokenResponse+fetchAccessToken = fetchAccessTokenWithAuthMethod ClientSecretBasic++-- | Exchange @code@ for an Access Token+--+-- OAuth2 spec allows credential (@client_id@, @client_secret@) to be sent+-- either in the header (a.k.a `ClientSecretBasic`).+-- or as form/url params (a.k.a `ClientSecretPost`).+--+-- The OAuth provider can choose to implement only one, or both.+-- Look for API document from the OAuth provider you're dealing with.+-- If you`re uncertain, try `fetchAccessToken` which sends credential+-- in authorization http header, which is common case.+--+-- @since 2.6.0+fetchAccessTokenWithAuthMethod ::+ MonadIO m =>+ ClientAuthenticationMethod ->+ -- | HTTP connection manager+ Manager ->+ -- | OAuth Data+ OAuth2 ->+ -- | Authorization Code+ ExchangeToken ->+ -- | Access Token+ ExceptT TokenResponseError m TokenResponse+fetchAccessTokenWithAuthMethod authMethod manager oa code = do+ let (uri, body) = accessTokenUrl oa code+ let extraBody = if authMethod == ClientSecretPost then clientSecretPost oa else []+ doJSONPostRequest manager oa uri (body ++ extraBody)++-- | Fetch a new AccessToken using the Refresh Token with authentication in request header.+refreshAccessToken ::+ MonadIO m =>+ -- | HTTP connection manager.+ Manager ->+ -- | OAuth context+ OAuth2 ->+ -- | Refresh Token gained after authorization+ RefreshToken ->+ ExceptT TokenResponseError m TokenResponse+refreshAccessToken = refreshAccessTokenWithAuthMethod ClientSecretBasic++-- | Fetch a new AccessToken using the Refresh Token.+--+-- OAuth2 spec allows credential ("client_id", "client_secret") to be sent+-- either in the header (a.k.a 'ClientSecretBasic').+-- or as form/url params (a.k.a 'ClientSecretPost').+--+-- The OAuth provider can choose to implement only one, or both.+-- Look for API document from the OAuth provider you're dealing with.+-- If you're uncertain, try 'refreshAccessToken' which sends credential+-- in authorization http header, which is common case.+--+-- @since 2.6.0+refreshAccessTokenWithAuthMethod ::+ MonadIO m =>+ ClientAuthenticationMethod ->+ -- | HTTP connection manager.+ Manager ->+ -- | OAuth context+ OAuth2 ->+ -- | Refresh Token gained after authorization+ RefreshToken ->+ ExceptT TokenResponseError m TokenResponse+refreshAccessTokenWithAuthMethod authMethod manager oa token = do+ let (uri, body) = refreshAccessTokenUrl oa token+ let extraBody = if authMethod == ClientSecretPost then clientSecretPost oa else []+ doJSONPostRequest manager oa uri (body ++ extraBody)++--------------------------------------------------++-- * Utilies++--------------------------------------------------++-- | Conduct post request and return response as JSON.+doJSONPostRequest ::+ (MonadIO m, FromJSON a) =>+ -- | HTTP connection manager.+ Manager ->+ -- | OAuth options+ OAuth2 ->+ -- | The URL+ URI ->+ -- | request body+ PostBody ->+ -- | Response as JSON+ ExceptT TokenResponseError m a+doJSONPostRequest manager oa uri body = do+ resp <- doSimplePostRequest manager oa uri body+ case parseResponseFlexible resp of+ Right obj -> return obj+ Left e -> throwE e++-- | Conduct post request.+doSimplePostRequest ::+ MonadIO m =>+ -- | HTTP connection manager.+ Manager ->+ -- | OAuth options+ OAuth2 ->+ -- | URL+ URI ->+ -- | Request body.+ PostBody ->+ -- | Response as ByteString+ ExceptT TokenResponseError m BSL.ByteString+doSimplePostRequest manager oa url body =+ ExceptT . liftIO $ fmap handleOAuth2TokenResponse go+ where+ go = do+ req <- uriToRequest url+ let req' = (addBasicAuth oa . addDefaultRequestHeaders) req+ httpLbs (urlEncodedBody body req') manager++-- | Gets response body from a @Response@ if 200 otherwise assume 'Network.OAuth2.TokenRequest.TokenResponseError'+handleOAuth2TokenResponse :: Response BSL.ByteString -> Either TokenResponseError BSL.ByteString+handleOAuth2TokenResponse rsp =+ if HT.statusIsSuccessful (responseStatus rsp)+ then Right $ responseBody rsp+ else Left $ parseTokeResponseError (responseBody rsp)++-- | Try to parses response as JSON, if failed, try to parse as like query string.+parseResponseFlexible ::+ FromJSON a =>+ BSL.ByteString ->+ Either TokenResponseError a+parseResponseFlexible r = case eitherDecode r of+ Left _ -> parseResponseString r+ Right x -> Right x++-- | Parses the response that contains not JSON but a Query String+parseResponseString ::+ FromJSON a =>+ BSL.ByteString ->+ Either TokenResponseError a+parseResponseString b = case parseQuery $ BSL.toStrict b of+ [] -> Left errorMessage+ a -> case fromJSON $ queryToValue a of+ Error _ -> Left errorMessage+ Success x -> Right x+ where+ queryToValue = Object . KeyMap.fromList . map paramToPair+ paramToPair (k, mv) = (Key.fromText $ T.decodeUtf8 k, maybe Null (String . T.decodeUtf8) mv)+ errorMessage = parseTokeResponseError b++-- | Add Basic Authentication header using client_id and client_secret.+addBasicAuth :: OAuth2 -> Request -> Request+addBasicAuth oa =+ applyBasicAuth+ (T.encodeUtf8 $ oauth2ClientId oa)+ (T.encodeUtf8 $ oauth2ClientSecret oa)++-- | Add Credential (client_id, client_secret) to the request post body.+clientSecretPost :: OAuth2 -> PostBody+clientSecretPost oa =+ [ ("client_id", T.encodeUtf8 $ oauth2ClientId oa)+ , ("client_secret", T.encodeUtf8 $ oauth2ClientSecret oa)+ ]
− test/Network/OAuth/OAuth2/TokenRequestSpec.hs
@@ -1,56 +0,0 @@-{-# LANGUAGE QuasiQuotes #-}--module Network.OAuth.OAuth2.TokenRequestSpec where--import Data.Aeson qualified as Aeson-import Network.OAuth.OAuth2.TokenRequest-import Test.Hspec-import URI.ByteString.QQ-import Prelude hiding (error)--spec :: Spec-spec = do- describe "parseJSON TokenResponseErrorCode" $ do- it "invalid_request" $ do- Aeson.eitherDecode "\"invalid_request\"" `shouldBe` Right InvalidRequest- it "invalid_client" $ do- Aeson.eitherDecode "\"invalid_client\"" `shouldBe` Right InvalidClient- it "invalid_grant" $ do- Aeson.eitherDecode "\"invalid_grant\"" `shouldBe` Right InvalidGrant- it "unauthorized_client" $ do- Aeson.eitherDecode "\"unauthorized_client\"" `shouldBe` Right UnauthorizedClient- it "unsupported_grant_type" $ do- Aeson.eitherDecode "\"unsupported_grant_type\"" `shouldBe` Right UnsupportedGrantType- it "invalid_scope" $ do- Aeson.eitherDecode "\"invalid_scope\"" `shouldBe` Right InvalidScope- it "foo_code" $ do- Aeson.eitherDecode "\"foo_code\"" `shouldBe` Right (UnknownErrorCode "foo_code")-- describe "parseJSON TokenResponseError" $ do- it "parse error" $ do- Aeson.eitherDecode "{\"error\": \"invalid_request\"}"- `shouldBe` Right- ( TokenResponseError- { tokenResponseError = InvalidRequest- , tokenResponseErrorDescription = Nothing- , tokenResponseErrorUri = Nothing- }- )- it "parse error_description" $ do- Aeson.eitherDecode "{\"error\": \"invalid_request\", \"error_description\": \"token request error foo1\"}"- `shouldBe` Right- ( TokenResponseError- { tokenResponseError = InvalidRequest- , tokenResponseErrorDescription = Just "token request error foo1"- , tokenResponseErrorUri = Nothing- }- )- it "parse error_uri" $ do- Aeson.eitherDecode "{\"error\": \"invalid_request\", \"error_uri\": \"https://example.com\"}"- `shouldBe` Right- ( TokenResponseError- { tokenResponseError = InvalidRequest- , tokenResponseErrorDescription = Nothing- , tokenResponseErrorUri = Just [uri|https://example.com|]- }- )
+ test/Network/OAuth2/InternalSpec.hs view
@@ -0,0 +1,69 @@+{-# LANGUAGE QuasiQuotes #-}++module Network.OAuth2.InternalSpec where++import Control.Monad.IO.Class (MonadIO (..))+import Network.HTTP.Conduit+import Network.OAuth2+import Test.Hspec+import URI.ByteString.QQ++spec :: Spec+spec = do+ describe "uriToRequest" $ do+ it "parse http://localhost:3001/abc/foo?scope=openid&redirect_uri=http://localhost:3001/callback" $ do+ req <- liftIO $ uriToRequest [uri|http://localhost:3001/abc/foo?scope=openid&&redirect_uri=http://localhost:3001/callback|]+ secure req `shouldBe` False+ path req `shouldBe` "/abc/foo"+ port req `shouldBe` 3001+ host req `shouldBe` "localhost"+ queryString req `shouldBe` "?scope=openid&redirect_uri=http%3A%2F%2Flocalhost%3A3001%2Fcallback"+ it "parse http://localhost:3001/abc/foo" $ do+ req <- liftIO $ uriToRequest [uri|http://localhost:3001/abc/foo|]+ secure req `shouldBe` False+ path req `shouldBe` "/abc/foo"+ port req `shouldBe` 3001+ host req `shouldBe` "localhost"+ queryString req `shouldBe` ""+ it "parse http://localhost:3001/" $ do+ req <- liftIO $ uriToRequest [uri|http://localhost:3001/|]+ secure req `shouldBe` False+ path req `shouldBe` "/"+ port req `shouldBe` 3001+ host req `shouldBe` "localhost"+ queryString req `shouldBe` ""+ it "parse https://test.auth0.com/authorize" $ do+ req <- liftIO $ uriToRequest [uri|https://test.auth0.com/authorize|]+ secure req `shouldBe` True+ path req `shouldBe` "/authorize"+ port req `shouldBe` 443+ host req `shouldBe` "test.auth0.com"+ queryString req `shouldBe` ""+ it "parse URL with multiple query parameters" $ do+ req <- liftIO $ uriToRequest [uri|https://api.example.com/oauth2/auth?client_id=123&scope=read,write&state=xyz|]+ secure req `shouldBe` True+ path req `shouldBe` "/oauth2/auth"+ port req `shouldBe` 443+ host req `shouldBe` "api.example.com"+ queryString req `shouldBe` "?client_id=123&scope=read%2Cwrite&state=xyz"+ it "parse URL with special characters in path" $ do+ req <- liftIO $ uriToRequest [uri|https://api.example.com/oauth2/user+info/profile%20data|]+ secure req `shouldBe` True+ path req `shouldBe` "/oauth2/user+info/profile%20data"+ port req `shouldBe` 443+ host req `shouldBe` "api.example.com"+ queryString req `shouldBe` ""+ it "parse URL with fragments (which should be ignored)" $ do+ req <- liftIO $ uriToRequest [uri|https://api.example.com/callback#access_token=xyz|]+ secure req `shouldBe` True+ path req `shouldBe` "/callback"+ port req `shouldBe` 443+ host req `shouldBe` "api.example.com"+ queryString req `shouldBe` ""+ it "parse URL with non-standard HTTPS port" $ do+ req <- liftIO $ uriToRequest [uri|https://api.example.com:8443/oauth/token|]+ secure req `shouldBe` True+ path req `shouldBe` "/oauth/token"+ port req `shouldBe` 8443+ host req `shouldBe` "api.example.com"+ queryString req `shouldBe` ""
+ test/Network/OAuth2/TokenRequestSpec.hs view
@@ -0,0 +1,56 @@+{-# LANGUAGE QuasiQuotes #-}++module Network.OAuth2.TokenRequestSpec where++import Data.Aeson qualified as Aeson+import Network.OAuth2.TokenRequest+import Test.Hspec+import URI.ByteString.QQ+import Prelude hiding (error)++spec :: Spec+spec = do+ describe "parseJSON TokenResponseErrorCode" $ do+ it "invalid_request" $ do+ Aeson.eitherDecode "\"invalid_request\"" `shouldBe` Right InvalidRequest+ it "invalid_client" $ do+ Aeson.eitherDecode "\"invalid_client\"" `shouldBe` Right InvalidClient+ it "invalid_grant" $ do+ Aeson.eitherDecode "\"invalid_grant\"" `shouldBe` Right InvalidGrant+ it "unauthorized_client" $ do+ Aeson.eitherDecode "\"unauthorized_client\"" `shouldBe` Right UnauthorizedClient+ it "unsupported_grant_type" $ do+ Aeson.eitherDecode "\"unsupported_grant_type\"" `shouldBe` Right UnsupportedGrantType+ it "invalid_scope" $ do+ Aeson.eitherDecode "\"invalid_scope\"" `shouldBe` Right InvalidScope+ it "foo_code" $ do+ Aeson.eitherDecode "\"foo_code\"" `shouldBe` Right (UnknownErrorCode "foo_code")++ describe "parseJSON TokenResponseError" $ do+ it "parse error" $ do+ Aeson.eitherDecode "{\"error\": \"invalid_request\"}"+ `shouldBe` Right+ ( TokenResponseError+ { tokenResponseError = InvalidRequest+ , tokenResponseErrorDescription = Nothing+ , tokenResponseErrorUri = Nothing+ }+ )+ it "parse error_description" $ do+ Aeson.eitherDecode "{\"error\": \"invalid_request\", \"error_description\": \"token request error foo1\"}"+ `shouldBe` Right+ ( TokenResponseError+ { tokenResponseError = InvalidRequest+ , tokenResponseErrorDescription = Just "token request error foo1"+ , tokenResponseErrorUri = Nothing+ }+ )+ it "parse error_uri" $ do+ Aeson.eitherDecode "{\"error\": \"invalid_request\", \"error_uri\": \"https://example.com\"}"+ `shouldBe` Right+ ( TokenResponseError+ { tokenResponseError = InvalidRequest+ , tokenResponseErrorDescription = Nothing+ , tokenResponseErrorUri = Just [uri|https://example.com|]+ }+ )
+ test/Network/OAuth2/TokenResponseSpec.hs view
@@ -0,0 +1,48 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeApplications #-}++module Network.OAuth2.TokenResponseSpec where++import Data.Aeson qualified as Aeson+import Data.Binary qualified as Binary+import Data.Maybe (fromJust)+import Network.OAuth2 (AccessToken (..), RefreshToken (..), TokenResponse (..))+import Test.Hspec++spec :: Spec+spec = do+ describe "decode as JSON" $ do+ it "parse access token" $ do+ let resp = "{\"access_token\":\"ya29\",\"token_type\":\"Bearer\",\"expires_in\":3600,\"refresh_token\":\"0gk\"}"+ Aeson.eitherDecode resp+ `shouldBe` Right+ ( TokenResponse+ { accessToken = AccessToken "ya29"+ , refreshToken = Just (RefreshToken "0gk")+ , expiresIn = Just 3600+ , tokenType = Just "Bearer"+ , idToken = Nothing+ , scope = Nothing+ , rawResponse = fromJust (Aeson.decode resp)+ }+ )+ it "parse access token with scope" $ do+ let resp = "{\"access_token\":\"ya29\",\"token_type\":\"Bearer\",\"expires_in\":3600,\"refresh_token\":\"0gk\",\"scope\": \"openid profile\"}"+ Aeson.eitherDecode resp+ `shouldBe` Right+ ( TokenResponse+ { accessToken = AccessToken "ya29"+ , refreshToken = Just (RefreshToken "0gk")+ , expiresIn = Just 3600+ , tokenType = Just "Bearer"+ , idToken = Nothing+ , scope = Just "openid profile"+ , rawResponse = fromJust (Aeson.decode resp)+ }+ )+ describe "encode/decode binary" $ do+ it "support binary encoding" $ do+ let resp = "{\"access_token\":\"ya29\",\"token_type\":\"Bearer\",\"expires_in\":3600,\"refresh_token\":\"0gk\"}"+ oauth2Token = fromJust (Aeson.decode @TokenResponse resp)+ Binary.decode @TokenResponse (Binary.encode oauth2Token)+ `shouldBe` oauth2Token