hoauth2-2.15.0: src/Network/OAuth2/Experiment/Flows.hs
{-# 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