ms-auth-0.4.0.0: src/Network/OAuth2/Session.hs
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE DataKinds, TypeFamilies #-}
{-# language DeriveGeneric, GeneralizedNewtypeDeriving, DerivingStrategies, DeriveDataTypeable #-}
{-# language OverloadedStrings #-}
{-# options_ghc -Wno-unused-imports #-}
-- | MS Identity user session based on OAuth tokens
--
-- The library supports the following authentication scenarios :
--
-- * [Client Credentials](https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow) (server/server or automation accounts), see also https://oauth.net/2/grant-types/client-credentials/
--
-- * [Authorization Code](https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow) (with human users being prompted to delegate some access rights to the app), see also https://oauth.net/2/grant-types/authorization-code/
--
-- and provides functions to keep tokens up to date in the background.
module Network.OAuth2.Session (
-- * A Client Credentials Grant (i.e. server-to-server)
Token
-- , newNoToken
, tokenUpdateLoop
, expireToken
, readToken
-- , fetchUpdateToken
-- ** Default Azure Credential
, defaultAzureCredential
-- * B Auth Code Grant (i.e. with user auth in the loop)
-- ** OAuth endpoints
, loginEndpoint
, replyEndpoint
-- ** In-memory user session
, Tokens
, newTokens
, UserSub
, lookupUser
, expireUser
, tokensToList
-- * Scotty misc
-- ** Azure App Service
, withAADUser
, Scotty
, Action
) where
import Control.Applicative (Alternative(..))
import Control.Exception (Exception(..), SomeException(..))
import Control.Monad.IO.Class (MonadIO(..))
import Data.Functor (void)
import Data.List.NonEmpty (NonEmpty)
import Data.Maybe (fromMaybe)
import Data.String (IsString(..))
import Data.Typeable (Typeable)
import GHC.Exception (SomeException)
import System.Environment (lookupEnv)
-- aeson
import qualified Data.Aeson as A (FromJSON(..), eitherDecode)
-- bytestring
import qualified Data.ByteString.Char8 as BS (pack)
import qualified Data.ByteString.Lazy.Char8 as BSL
-- containers
import qualified Data.Map as M (Map, insert, lookup, alter, toList)
-- -- heaps
-- import qualified Data.Heap as H (Heap, empty, null, size, insert, viewMin, deleteMin, Entry(..), )
-- hoauth2
import Network.OAuth.OAuth2 (OAuth2Token(..), AccessToken(..), ExchangeToken(..), RefreshToken(..), OAuth2Error(..), IdToken(..))
import Network.OAuth2.Experiment (IdpUserInfo, conduitUserInfoRequest, mkAuthorizeRequest, conduitTokenRequest, conduitRefreshTokenRequest, HasRefreshTokenRequest(..), WithExchangeToken, IdpApplication(..), GrantTypeFlow(..))
import Network.OAuth.OAuth2.TokenRequest (Errors)
-- http-client
import Network.HTTP.Client (Manager, parseRequest, requestHeaders, httpLbs, responseBody, responseStatus)
-- http-types
import Network.HTTP.Types (status302, status400, status401, statusCode)
import Network.HTTP.Types.Header (RequestHeaders, Header)
-- scotty
import Web.Scotty (scotty, RoutePattern)
import Web.Scotty.Trans (scottyT, ActionT, ScottyT, get, raise, redirect, params, header, setHeader, status, text)
-- text
import qualified Data.Text as T (Text, pack, unwords)
import qualified Data.Text.Lazy as TL (Text, pack, unpack, toStrict, takeWhile, fromStrict)
-- time
import Data.Time (UTCTime(..), getCurrentTime, fromGregorian, diffUTCTime, addUTCTime, Day, NominalDiffTime)
import Data.Time.Format (FormatTime, formatTime, iso8601DateFormat, defaultTimeLocale)
-- transformers
import Control.Monad.Trans.Except (ExceptT(..), withExceptT, runExceptT, throwE)
-- unliftio
import UnliftIO (MonadUnliftIO(..))
import UnliftIO.Concurrent (ThreadId, forkFinally, threadDelay)
import UnliftIO.Exception (throwIO)
import UnliftIO.STM (STM, TVar, atomically, newTVarIO, readTVar, writeTVar, modifyTVar)
-- uri-bytestring
import URI.ByteString (URI)
-- validation-selective
import Validation.Micro (Validation, failure, validationToEither)
import Network.OAuth2.Provider.AzureAD (OAuthCfg, azureADApp, AzureAD)
import Network.OAuth2.JWT (jwtClaims, UserSub(..), userSub, ApiAudience, apiAudience, decValidSub, decValidExp, decValidNbf, JWTException(..))
type Action = ActionT TL.Text
type Scotty = ScottyT TL.Text
-- * Azure App Service adds headers into each request, which the backend can access to identify the user
--
-- https://learn.microsoft.com/en-us/azure/app-service/configure-authentication-user-identities#access-user-claims-in-app-code
-- | The JWT identity token from the @X-MS-TOKEN-AAD-ID-TOKEN@ header injected by App Service can be decoded for its claims e.g. @sub@ (which is unique for each user for a given app)
--
-- https://bogdan.bynapse.com/azure/the-app-service-token-store-was-added-to-app-service-authentication-authorization-and-it-is-a-repository-of-oauth-tokens-associated-with-your-app-users-when-a-user-logs-into-your-app-via-an-iden/
--
-- https://stackoverflow.com/questions/46757665/authentication-for-azure-functions/
aadHeaderIdToken :: (MonadIO m) =>
(UserSub -> Action m ()) -- ^ look up the UserSub's token, do stuff with it
-> Action m ()
aadHeaderIdToken act = do
let
hdrName = "X-MS-TOKEN-AAD-ID-TOKEN"
mh <- header hdrName
case mh of
Nothing -> do
text $ TL.pack $ unwords ["header", TL.unpack hdrName, "not found in request"]
status status400
Just h -> do
let
idt = IdToken $ TL.toStrict h
ide <- decValidIdToken idt
case ide of
Right usub -> act usub
Left e -> do
text $ TL.pack $ unwords ["AAD header ID token validation exception:", show e]
status status401
-- | Decode the App Service ID token header @X-MS-TOKEN-AAD-ID-TOKEN@, look its user up in the local token store, supply token @t@ to continuation. If the user @sub@ cannot be found in the token store the browser is redirected to the login URI.
--
-- Special case of 'aadHeaderIdToken'
withAADUser :: MonadIO m =>
Tokens UserSub t
-> TL.Text -- ^ login URI
-> (t -> Action m ()) -- ^ call MSGraph APIs with token @t@, etc.
-> Action m ()
withAADUser ts loginURI act = aadHeaderIdToken $ \usub -> do
mt <- lookupUser ts usub
case mt of
Just t -> act t
_ -> do
liftIO $ putStrLn $ unwords ["User", show usub, "not authenticated. Redirecting to login:", TL.unpack loginURI]
redirect loginURI
-- * App-only authorization scenarios, called "CLient credentials grant" https://oauth.net/2/grant-types/client-credentials/ (i.e via automation accounts. Human users not involved)
-- | App has (at most) one token at a time
type Token t = TVar (Maybe t)
-- | Create an empty 'Token' store
newNoToken :: MonadIO m => m (Token t)
newNoToken = newTVarIO Nothing
-- | Delete the current token
expireToken :: MonadIO m => Token t -> m ()
expireToken ts = atomically $ modifyTVar ts (const Nothing)
-- | Read the current value of the token
readToken :: MonadIO m => Token t -> m (Maybe t)
readToken ts = atomically $ readTVar ts
updateToken :: (MonadIO m) =>
Token OAuth2Token -> OAuth2Token -> m NominalDiffTime
updateToken ts oat = do
let
ein = fromIntegral $ fromMaybe 3600 (expiresIn oat) -- expires in [sec]
atomically $ do
writeTVar ts (Just oat)
pure ein
-- | Forks a thread and keeps the OAuth token up to date inside a TVar
tokenUpdateLoop :: MonadIO m =>
IdpApplication 'ClientCredentials AzureAD -- ^ client credentials grant only
-> Manager
-> m (Token OAuth2Token)
tokenUpdateLoop idp mgr = do
t <- newNoToken
fetchUpdateToken idp t mgr
pure t
fetchUpdateTokenWith :: MonadIO m =>
(t1 -> t2 -> ExceptT [String] IO OAuth2Token)
-> t1 -> Token OAuth2Token -> t2 -> m ()
fetchUpdateTokenWith f idpApp ts mgr = liftIO $ void $ forkFinally loop cleanup
where
cleanup = \case
Left e -> throwIO e
Right _ -> pure ()
loop = do
tokenResp <- runExceptT $ f idpApp mgr -- allows different mechanisms of fetching OAuth2 token
case tokenResp of
Left es -> throwIO (OASEDefaultAzureCredentialsE es)
Right oat -> do
ein <- updateToken ts oat
let
dtSecs = (round ein - 30) -- 30 seconds before expiry
threadDelay (dtSecs * 1000000) -- pause thread
loop
-- | DefaultAzureCredential mechanism as in the Python SDK https://pypi.org/project/azure-identity/
--
-- Order of authentication attempts:
--
-- 1) token request with client secret
--
-- 2) token request via managed identity (App Service and Azure Functions) https://learn.microsoft.com/en-us/azure/app-service/overview-managed-identity?tabs=portal%2Chttp#rest-endpoint-reference
defaultAzureCredential :: MonadIO m =>
String -- ^ Client ID
-> String -- ^ Azure Resource URI (for @managed identity@ auth flow)
-> IdpApplication 'ClientCredentials AzureAD
-> Token OAuth2Token
-> Manager
-> m ()
defaultAzureCredential clid resuri = fetchUpdateTokenWith (
\ip mgr ->
tokenRequestNoExchange ip mgr <|>
managedIdentity mgr clid resuri
)
tokenRequestNoExchange :: (MonadIO m) =>
IdpApplication 'ClientCredentials AzureAD
-> Manager
-> ExceptT [String] m OAuth2Token
tokenRequestNoExchange ip mgr = withExceptT (pure . show) (conduitTokenRequest ip mgr)
-- | Token refresh loop for Client Credentials Grant scenarios (Bot Framework auth etc)
--
-- Fetch an OAuth token and keep it updated. Should be called as a first thing in the app
--
-- NB : forks a thread in the background
--
-- https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow
fetchUpdateToken :: MonadIO m =>
IdpApplication 'ClientCredentials AzureAD
-> Token OAuth2Token -- ^ the app manages a single token at a time
-> Manager -- ^ HTTP connection manager
-> m ()
fetchUpdateToken idpApp ts mgr = liftIO $ void $ forkFinally loop cleanup
where
cleanup = \case
Left e -> throwIO e
Right _ -> pure ()
loop = do
tokenResp <- runExceptT $ conduitTokenRequest idpApp mgr -- OAuth2 token
case tokenResp of
Left es -> throwIO (OASEOAuth2Errors es)
Right oat -> do
ein <- updateToken ts oat
let
dtSecs = (round ein - 30) -- 30 seconds before expiry
threadDelay (dtSecs * 1000000) -- pause thread
loop
-- * Managed identity
-- | With its managed identity, an app can obtain tokens for Azure resources that are protected by Azure Active Directory, such as Azure SQL Database, Azure Key Vault, and Azure Storage. These tokens represent the application accessing the resource, and not any specific user of the application.
--
-- App Service and Azure Functions provide an internally accessible REST endpoint for token retrieval.
--
-- https://learn.microsoft.com/en-us/azure/app-service/overview-managed-identity?tabs=portal%2Chttp#rest-endpoint-reference
managedIdentity :: Manager
-> String -- ^ client ID
-> String -- ^ Azure resource URI
-> ExceptT [String] IO OAuth2Token
managedIdentity mgr clid resUri = ExceptT $ do
mih <- lookupEnv "IDENTITY_ENDPOINT"
mie <- lookupEnv "IDENTITY_HEADER"
case (,) <$> mih <*> mie of
Just (idEndpoint, ih) -> do
let
apiVer = "2019-08-01"
xIdentityHeader = ih
r <- parseRequest $ mconcat [idEndpoint, "?", kvs [("resource", resUri), ("api-version", apiVer), ("client_id", clid)]]
let
r' = r {
requestHeaders = [
("X-IDENTITY-HEADER", BS.pack xIdentityHeader)
]
}
res <- httpLbs r' mgr
let
rstat = responseStatus res
sci = statusCode rstat
if 200 <= sci && sci < 300
then
case A.eitherDecode (responseBody res) of
Right oat -> pure $ Right oat
Left e -> pure $ lefts $ unwords ["managedIdentity: Cannot decode OAuth token:", e]
else
pure $ lefts $ unwords ["managedIdentity: status code exception:", show rstat]
_ -> pure $
lefts $ unwords ["managedIdentity: Cannot find either IDENTITY_ENDPOINT or IDENTITY_HEADER env vars."]
lefts :: a -> Either [a] b
lefts s = Left [s]
kvs :: [(String, String)] -> String
kvs = foldr ins mempty
where
ins (k, v) acc = acc <> ("&" <> k <> "=" <> v)
-- * Auth code grant flow (i.e. human user involved)
-- | Login endpoint
--
-- see 'azureADApp'
loginEndpoint :: (MonadIO m) =>
IdpApplication 'AuthorizationCode AzureAD
-> RoutePattern -- ^ e.g. @"/login"@
-> Scotty m ()
loginEndpoint idpApp path = get path (loginH idpApp)
-- | login endpoint handler
loginH :: Monad m =>
IdpApplication 'AuthorizationCode AzureAD
-> Action m ()
loginH idpApp = do
setHeader "Location" (mkAuthorizeRequest idpApp) -- redirect to OAuth consent screen
status status302
-- | The identity provider redirects the client to the 'reply' endpoint as part of the OAuth flow : https://learn.microsoft.com/en-us/graph/auth-v2-user?view=graph-rest-1.0&tabs=http#authorization-response
--
-- NB : forks a thread per logged in user to keep their tokens up to date
replyEndpoint :: MonadIO m =>
IdpApplication 'AuthorizationCode AzureAD
-> Tokens UserSub OAuth2Token -- ^ token TVar
-> Manager
-> RoutePattern -- ^ e.g. @"/oauth\/reply"@
-> Scotty m ()
replyEndpoint idpApp ts mgr path =
get path (replyH idpApp ts mgr)
replyH :: MonadIO m =>
IdpApplication 'AuthorizationCode AzureAD
-> Tokens UserSub OAuth2Token
-> Manager
-> Action m ()
replyH idpApp ts mgr = do
ps <- params
excepttToActionM $ do
case lookup "code" ps of
Just codeP -> do
let
etoken = ExchangeToken $ TL.toStrict codeP
_ <- fetchUpdateTokenACG ts idpApp mgr etoken
pure ()
Nothing -> throwE OASEExchangeTokenNotFound
--
-- oauth2ErrorToText :: Show a => a -> T.Text
-- oauth2ErrorToText e = T.pack $ "Unable to fetch access token. Details : " ++ show e
-- bslToText :: BSL.ByteString -> T.Text
-- bslToText = T.pack . BSL.unpack
-- | Token refresh loop for Auth Code Grant scenarios
--
-- 1) the ExchangeToken arrives with the redirect once the user has approved the scopes in the browser
-- https://learn.microsoft.com/en-us/graph/auth-v2-user?view=graph-rest-1.0&tabs=http#authorization-response
fetchUpdateTokenACG :: MonadIO m =>
Tokens UserSub OAuth2Token -- ^ the app manages one token per user
-> IdpApplication 'AuthorizationCode AzureAD
-> Manager -- ^ HTTP connection manager
-> ExchangeToken -- ^ also called 'code'. Expires in 10 minutes
-> ExceptT OAuthSessionError m OAuth2Token
fetchUpdateTokenACG ts idpApp mgr etoken = ExceptT $ do
tokenResp <- runExceptT $ conduitTokenRequest idpApp mgr etoken -- OAuth2 token
case tokenResp of
Right oat -> case idToken oat of
Nothing -> pure $ Left OASENoOpenID
Just idt -> do
idtClaimsE <- decValidIdToken idt -- decode and validate ID token
case idtClaimsE of
Right uid -> do
_ <- refreshLoopACG ts idpApp mgr uid oat -- fork a thread and start refresh loop for this user
pure $ Right oat
Left es -> pure $ Left (OASEJWTException es) -- id token validation failed
Left es -> pure $ Left (OASEOAuth2Errors es)
-- | 2) fork a thread and start token refresh loop for user @uid@
--
-- ACG stands for "authorization code grant" flow, i.e. the user consent is in the auth loop.
refreshLoopACG :: (MonadIO m, Ord uid, HasRefreshTokenRequest a) =>
Tokens uid OAuth2Token
-> IdpApplication a i
-> Manager
-> uid -- ^ user ID
-> OAuth2Token
-> m ThreadId
refreshLoopACG ts idpApp mgr uid oaToken = liftIO $ forkFinally (act oaToken) cleanup
where
cleanup = \case
Left _ -> do
expireUser ts uid -- auth error(s), remove user from memory
Right _ -> pure ()
act oat = do
ein <- upsertToken ts uid oat -- replace new token for user uid in memory
let
dtSecs = (round ein - 30) -- 30 seconds before expiry
threadDelay (dtSecs * 1000000) -- pause thread
case refreshToken oat of
Nothing -> do
expireUser ts uid -- cannot refresh, remove user from memory
throwIO OASERefreshTokenNotFound -- no refresh token
Just rt -> do
eo' <- runExceptT $ conduitRefreshTokenRequest idpApp mgr rt -- get a new OAuth2 token
case eo' of
Right oat' -> do
act oat' -- loop
Left e -> throwIO (OASEOAuth2Errors e) -- refresh token request failed
data OAuthSessionError = OASERefreshTokenNotFound
| OASEExchangeTokenNotFound
| OASEOAuth2Errors (OAuth2Error Errors)
| OASEDefaultAzureCredentialsE [String]
| OASEJWTException (NonEmpty JWTException)
| OASENoOpenID
deriving (Eq, Typeable)
instance Exception OAuthSessionError
instance Show OAuthSessionError where
show = \case
OASERefreshTokenNotFound -> unwords ["Refresh token not found in OAT"]
OASEExchangeTokenNotFound -> unwords ["Exchange token not found. This shouldn't happen"]
OASEOAuth2Errors oerrs ->
unwords ["OAuth2 error(s):", show oerrs]
OASEDefaultAzureCredentialsE es ->
unwords ["defaultAzureCredential error(s):", mconcat es]
OASEJWTException jwtes -> unwords ["JWT error(s):", show jwtes]
OASENoOpenID -> unwords ["No ID token found. Ensure 'openid' scope appears in token request"]
-- | Insert or update a token in the 'Tokens' object
upsertToken :: (MonadIO m, Ord uid) =>
Tokens uid OAuth2Token
-> uid -- ^ user id
-> OAuth2Token -- ^ new token
-> m NominalDiffTime -- ^ token expires in
upsertToken ts uid oat = do
let
ein = fromIntegral $ fromMaybe 3600 (expiresIn oat) -- expires in [sec]
atomically $ do
thp <- readTVar ts
let
m' = M.insert uid oat (thUsersMap thp)
writeTVar ts (TokensData m')
pure ein
-- | Remove a user, i.e. they will have to authenticate once more
expireUser :: (MonadIO m, Ord uid) =>
Tokens uid t
-> uid -- ^ user identifier e.g. @sub@
-> m ()
expireUser ts uid =
atomically $ modifyTVar ts $ \td -> td{ thUsersMap = M.alter (const Nothing) uid (thUsersMap td)}
-- | Look up a user identifier and return their current token, if any
lookupUser :: (MonadIO m, Ord uid) =>
Tokens uid t
-> uid -- ^ user identifier e.g. @sub@
-> m (Maybe t)
lookupUser ts uid = atomically $ do
thp <- readTVar ts
pure $ M.lookup uid (thUsersMap thp)
-- | return a list representation of the 'Tokens' object
tokensToList :: MonadIO m => Tokens k a -> m [(k, a)]
tokensToList ts = atomically $ do
(TokensData m) <- readTVar ts
pure $ M.toList m
-- | Create an empty 'Tokens' object
newTokens :: (MonadIO m, Ord uid) => m (Tokens uid t)
newTokens = newTVarIO (TokensData mempty)
-- | transactional token store
type Tokens uid t = TVar (TokensData uid t)
newtype TokensData uid t = TokensData {
thUsersMap :: M.Map uid t
} deriving (Eq, Show)
-- class HasTokens r where
-- hasTokens :: r -> Tokens uid t
-- | Decode and validate ID token
-- https://learn.microsoft.com/en-us/azure/active-directory/develop/userinfo#consider-using-an-id-token-instead
decValidIdToken :: MonadIO m =>
IdToken -- ^ appears in the OAuth2Token if scopes include @openid@
-> m (Either (NonEmpty JWTException) UserSub) -- ^ (sub)
decValidIdToken (IdToken idt) = do
t <- liftIO getCurrentTime
let
ve = validationToEither $
case jwtClaims idt of
Just c -> (,,) <$> decValidSub c <*> decValidExp Nothing t c <*> decValidNbf t c
_ -> failure $ JEMalformedJWT (T.unwords ["cannot decode token string"])
case ve of
Right (usub, _, _) -> pure $ Right usub
Left e -> pure $ Left e
-- | Lift ExceptT to ActionM which is basically the handler Monad in Scotty.
excepttToActionM :: (MonadIO m, Show e) =>
ExceptT e IO b -> Action m b
excepttToActionM e = do
result <- liftIO $ runExceptT e
either (raise . TL.pack . show) pure result
-- playground
-- atomicallyWithAfter :: MonadUnliftIO m =>
-- TVar a
-- -> Int -- ^ delay in microseconds (see 'threadDelay')
-- -> (a -> a)
-- -> m ThreadId
-- atomicallyWithAfter tv dt f = forkFinally act (\_ -> pure ())
-- where
-- act = do
-- threadDelay dt
-- atomically $ modifyTVar tv f