module OpenID.Connect.Client.Flow.AuthorizationCode
(
authenticationRedirect
, authenticationSuccess
, RedirectTo(..)
, defaultAuthenticationRequest
, UserReturnFromRedirect(..)
, FlowError(..)
, HTTPS
, ErrorResponse(..)
, module OpenID.Connect.Authentication
, module OpenID.Connect.Client.Provider
, module OpenID.Connect.Scope
) where
import Control.Category ((>>>))
import Control.Exception (Exception)
import Control.Monad.Except
import qualified Crypto.Hash as Hash
import qualified Crypto.JOSE.Error as JOSE
import Crypto.JOSE.JWK (JWKSet)
import Crypto.JWT (SignedJWT, ClaimsSet, JWTError)
import Crypto.Random (MonadRandom(..))
import Data.Bifunctor (bimap, first, second)
import Data.ByteArray.Encoding
import Data.ByteString (ByteString)
import qualified Data.ByteString as ByteString
import qualified Data.ByteString.Char8 as Char8
import qualified Data.ByteString.Lazy as LByteString
import Data.Function ((&))
import Data.Functor ((<&>))
import qualified Data.List.NonEmpty as NonEmpty
import Data.Maybe (isJust)
import Data.Text (Text)
import qualified Data.Text.Encoding as Text
import Data.Time.Clock (UTCTime)
import qualified Network.HTTP.Client as HTTP
import Network.HTTP.Types (QueryItem, renderQuery)
import qualified Network.URI as Network
import OpenID.Connect.Authentication
import OpenID.Connect.Client.Authentication
import OpenID.Connect.Client.HTTP
import OpenID.Connect.Client.Provider
import OpenID.Connect.JSON
import OpenID.Connect.Scope
import OpenID.Connect.TokenResponse (TokenResponse)
import Web.Cookie (SetCookie)
import qualified Web.Cookie as Cookie
import OpenID.Connect.Client.TokenResponse
( decodeIdentityToken
, verifyIdentityTokenClaims
)
data Secrets = Secrets
{ requestForgeryProtectionToken :: ByteString
, replayProtectionNonce :: ByteString
, valueForHttpOnlyCookie :: ByteString
}
generateRandomSecrets :: forall m. MonadRandom m => m Secrets
generateRandomSecrets = do
bytes <- getRandomBytes 64 :: m ByteString
let hash1 = Hash.hash (ByteString.take 32 bytes) :: Hash.Digest Hash.SHA256
hash2 = Hash.hash (ByteString.drop 32 bytes) :: Hash.Digest Hash.SHA256
pure Secrets
{ requestForgeryProtectionToken = convertToBase Base64URLUnpadded hash2
, replayProtectionNonce = convertToBase Base64URLUnpadded hash1
, valueForHttpOnlyCookie = convertToBase Base64URLUnpadded bytes
}
expectedStateParam :: ByteString -> Either FlowError ByteString
expectedStateParam cookie
= extractTokenFromSessionCookie cookie (ByteString.drop 32)
& first (const InvalidStateParameterError)
expectedNonce :: ByteString -> Either FlowError Text
expectedNonce cookie
= extractTokenFromSessionCookie cookie (ByteString.take 32)
& bimap (const InvalidNonceFromProviderError) Text.decodeUtf8
extractTokenFromSessionCookie
:: ByteString
-> (ByteString -> ByteString)
-> Either String ByteString
extractTokenFromSessionCookie cookie f =
convertFromBase Base64URLUnpadded cookie <&> rehash . f
where
rehash :: ByteString -> ByteString
rehash bs = let hash = Hash.hash bs :: Hash.Digest Hash.SHA256
in convertToBase Base64URLUnpadded hash
defaultAuthenticationRequest
:: Scope
-> Credentials
-> AuthenticationRequest
defaultAuthenticationRequest scope creds =
AuthenticationRequest
{ authRequestRedirectURI = clientRedirectUri creds
, authRequestScope = scope
, authRequestResponseType = "code"
, authRequestClientId = assignedClientId creds
, authRequestDisplay = Nothing
, authRequestPrompt = Nothing
, authRequestMaxAge = Nothing
, authRequestUiLocales = Nothing
, authRequestIdTokenHint = Nothing
, authRequestLoginHint = Nothing
, authRequestAcrValues = Nothing
, authRequestOtherParams = []
}
data UserReturnFromRedirect = UserReturnFromRedirect
{ afterRedirectSessionCookie :: ByteString
, afterRedirectCodeParam :: ByteString
, afterRedirectStateParam :: ByteString
}
data FlowError
= ProviderDiscoveryError DiscoveryError
| InvalidStateParameterError
| InvalidNonceFromProviderError
| ProviderMissingTokenEndpointError
| InvalidProviderTokenEndpointError Text
| NoAuthenticationMethodsAvailableError
| InvalidProviderTokenResponseError ErrorResponse
| TokenDecodingError JOSE.Error
| IdentityTokenValidationFailed JWTError
deriving (Show, Exception)
data RedirectTo = RedirectTo Network.URI (ByteString -> SetCookie)
authenticationRedirect
:: MonadRandom m
=> Discovery
-> AuthenticationRequest
-> m (Either FlowError RedirectTo)
authenticationRedirect disco req = do
let uri = authRequestRedirectURI req
secrets <- generateRandomSecrets
makeRedirectURI secrets disco req
& second (`RedirectTo` makeSessionCookie secrets uri)
& pure
authenticationSuccess
:: MonadRandom m
=> HTTPS m
-> UTCTime
-> Provider
-> Credentials
-> UserReturnFromRedirect
-> m (Either FlowError (TokenResponse ClaimsSet))
authenticationSuccess https time (Provider disco keys) creds user = runExceptT $ do
_ <- ExceptT (pure (verifyPostRedirectRequest user))
token <- ExceptT (exchangeCodeForIdentityToken https time disco creds user)
ExceptT (pure (extractClaimsSetFromTokenResponse disco creds token keys time user))
makeRedirectURI
:: Secrets
-> Discovery
-> AuthenticationRequest
-> Either FlowError Network.URI
makeRedirectURI secrets disco AuthenticationRequest{..} =
let uri = getURI (authorizationEndpoint disco)
in Right $ uri
{ Network.uriQuery = Char8.unpack
(renderQuery True (params <> authRequestOtherParams))
}
where
params :: [QueryItem]
params = filter (isJust . snd)
[ ("response_type", Just authRequestResponseType)
, ("client_id", Just (Text.encodeUtf8 authRequestClientId))
, ("redirect_uri", Just redir)
, ("nonce", Just (replayProtectionNonce secrets))
, ("state", Just (requestForgeryProtectionToken secrets))
, ("display", authRequestDisplay)
, ("prompt", authRequestPrompt)
, ("max_age", Char8.pack . show <$> authRequestMaxAge)
, ("ui_locales", Text.encodeUtf8 . fromWords <$> authRequestUiLocales)
, ("id_token_hint", authRequestIdTokenHint)
, ("login_hint", Text.encodeUtf8 <$> authRequestLoginHint)
, ("acr_values", Text.encodeUtf8 . fromWords <$> authRequestAcrValues)
, scopeQueryItem authRequestScope
]
redir :: ByteString
redir = Char8.pack (Network.uriToString id authRequestRedirectURI [])
makeSessionCookie :: Secrets -> ClientRedirectURI -> ByteString -> SetCookie
makeSessionCookie Secrets{valueForHttpOnlyCookie} uri name =
Cookie.defaultSetCookie
{ Cookie.setCookieName = name
, Cookie.setCookieValue = valueForHttpOnlyCookie
, Cookie.setCookiePath = Just (Char8.pack (Network.uriPath uri))
, Cookie.setCookieHttpOnly = True
, Cookie.setCookieSecure = True
, Cookie.setCookieSameSite = Just Cookie.sameSiteLax
}
verifyPostRedirectRequest :: UserReturnFromRedirect -> Either FlowError ()
verifyPostRedirectRequest UserReturnFromRedirect{..} = do
expectState <- expectedStateParam afterRedirectSessionCookie
if afterRedirectStateParam == expectState
then Right ()
else Left InvalidStateParameterError
exchangeCodeForIdentityToken
:: forall m. MonadRandom m
=> HTTPS m
-> UTCTime
-> Discovery
-> Credentials
-> UserReturnFromRedirect
-> m (Either FlowError (TokenResponse SignedJWT))
exchangeCodeForIdentityToken https now disco creds user = do
res <- performRequest
pure (processResponse =<< res)
where
performRequest :: m (Either FlowError (HTTP.Response LByteString.ByteString))
performRequest = runExceptT $ do
uri <- maybe
(throwError ProviderMissingTokenEndpointError) pure
(tokenEndpoint disco)
req <- maybe
(throwError (InvalidProviderTokenEndpointError (uriToText (getURI uri)))) pure
(requestFromURI (Right (getURI uri)))
applyRequestAuthentication creds authMethods
uri now body req >>= \case
Nothing -> throwError NoAuthenticationMethodsAvailableError
Just r -> lift (https r)
processResponse
:: HTTP.Response LByteString.ByteString
-> Either FlowError (TokenResponse SignedJWT)
processResponse res =
parseResponse res
& bimap InvalidProviderTokenResponseError fst
>>= (decodeIdentityToken >>> first TokenDecodingError)
authMethods :: [ClientAuthentication]
authMethods = maybe [ClientSecretPost] NonEmpty.toList
(tokenEndpointAuthMethodsSupported disco)
body :: [ (ByteString, ByteString) ]
body = [ ("grant_type", "authorization_code")
, ("code", afterRedirectCodeParam user)
, ("redirect_uri", Char8.pack (Network.uriToString id (clientRedirectUri creds) []))
, ("client_id", Text.encodeUtf8 (assignedClientId creds))
]
extractClaimsSetFromTokenResponse
:: Discovery
-> Credentials
-> TokenResponse SignedJWT
-> JWKSet
-> UTCTime
-> UserReturnFromRedirect
-> Either FlowError (TokenResponse ClaimsSet)
extractClaimsSetFromTokenResponse disco creds token keys time user = do
nonce <- expectedNonce (afterRedirectSessionCookie user)
verifyIdentityTokenClaims disco (assignedClientId creds) time keys nonce token
& first IdentityTokenValidationFailed