diff --git a/ms-auth/ms-auth.cabal b/ms-auth/ms-auth.cabal index dd2d082..d8777fc 100644 --- a/ms-auth/ms-auth.cabal +++ b/ms-auth/ms-auth.cabal @@ -1,7 +1,7 @@ name: ms-auth version: 0.1.0.0 synopsis: Microsoft Authentication API -description: Bindings to the Microsoft Authentication API / Azure ActiveDirectory (AAD) for building applications that use either Delegated or App-only permissions. Helper functions are provided for building OAuth2 authentication flows and keep tokens transactionally secure and up to date. +description: Bindings to the Microsoft Identity API / Active Directory (AD) for building applications that use either Delegated or App-only permissions. Helper functions are provided for building OAuth2 authentication flows and keep tokens transactionally secure and up to date. homepage: https://github.com/unfoldml/ms-auth license: BSD3 license-file: LICENSE diff --git a/ms-auth/src/Network/OAuth2/JWT.hs b/ms-auth/src/Network/OAuth2/JWT.hs index 00e602d..f9508b5 100644 --- a/ms-auth/src/Network/OAuth2/JWT.hs +++ b/ms-auth/src/Network/OAuth2/JWT.hs @@ -4,7 +4,18 @@ {-# language GeneralizedNewtypeDeriving #-} {-# LANGUAGE LambdaCase #-} {-# language OverloadedStrings #-} -module Network.OAuth2.JWT where +{-# options_ghc -Wno-unused-top-binds #-} +-- | Decode and validate a JWT token +-- +-- provides 'Validation' function for the individual fields as well +module Network.OAuth2.JWT ( + -- * 1) Decode a string into claims + jwtClaims + -- * 2) Extract and validate the individual claims + , decValidSub, decValidExp, decValidNbf, decValidEmail, decValidAud + , UserSub, userSub, UserEmail, userEmail, ApiAudience, apiAudience + , JWTException(..) + ) where import Control.Monad.IO.Class (MonadIO(..)) import qualified Data.List.NonEmpty as NE (NonEmpty(..)) @@ -27,7 +38,7 @@ import qualified Data.Text as T (Text, unpack) -- time import Data.Time (UTCTime(..), NominalDiffTime, getCurrentTime, fromGregorian, addUTCTime, diffUTCTime) -- validation-micro -import Validation.Micro (Validation(..), failure, validationToEither, maybeToSuccess) +import Validation.Micro (Validation(..), bindValidation, failure, validationToEither, maybeToSuccess) -- | 'sub' field @@ -43,6 +54,7 @@ newtype UserEmail = UserEmail { userEmail :: T.Text } newtype ApiAudience = ApiAudience { apiAudience :: T.Text } deriving (Eq, Ord, Show, Generic, Typeable, IsString) instance A.ToJSON ApiAudience +-- | Decode a string into a 'J.JWTClaimsSet' jwtClaims :: T.Text -> Maybe J.JWTClaimsSet jwtClaims t = J.claims <$> J.decode t @@ -58,30 +70,30 @@ data JWTClaims = , jcEmail :: UserEmail } deriving (Eq, Show) +-- | @sub@ decValidSub :: J.JWTClaimsSet -> Validation (NE.NonEmpty JWTException) UserSub decValidSub jc = decSub (J.sub jc) +-- | @exp@ decValidExp :: Maybe NominalDiffTime - -> UTCTime + -> UTCTime -- ^ current time -> J.JWTClaimsSet -> Validation (NE.NonEmpty JWTException) UTCTime decValidExp nsecs t jc = decExp (J.exp jc) `bindValidation` validateExp nsecs t -decValidNbf :: UTCTime -> J.JWTClaimsSet -> Validation (NE.NonEmpty JWTException) UTCTime +-- | @nbf@ +decValidNbf :: UTCTime -- ^ current time + -> J.JWTClaimsSet -> Validation (NE.NonEmpty JWTException) UTCTime decValidNbf t jc = decNbf (J.nbf jc) `bindValidation` validateNbf t +-- | @email@ decValidEmail :: J.JWTClaimsSet -> Validation (NE.NonEmpty JWTException) UserEmail decValidEmail jc = decEmail (J.unClaimsMap $ J.unregisteredClaims jc) +-- | @aud@ decValidAud :: ApiAudience -> J.JWTClaimsSet -> Validation (NE.NonEmpty JWTException) T.Text decValidAud a jc = decAud (J.aud jc) `bindValidation` validateAud a --- | NB Validation is not a monad though -bindValidation :: Validation e a -> (a -> Validation e b) -> Validation e b -bindValidation v f = case v of - Failure e -> Failure e - Success a -> f a - -- | Decode and validate the 'aud', 'exp' and 'nbf' fields of the JWT decodeValidateJWT :: MonadIO f => diff --git a/ms-auth/src/Network/OAuth2/Provider/AzureAD.hs b/ms-auth/src/Network/OAuth2/Provider/AzureAD.hs index 59b8eca..16850de 100644 --- a/ms-auth/src/Network/OAuth2/Provider/AzureAD.hs +++ b/ms-auth/src/Network/OAuth2/Provider/AzureAD.hs @@ -4,15 +4,18 @@ {-# language DataKinds, TypeFamilies, TypeApplications #-} {-# LANGUAGE DuplicateRecordFields #-} {-# options_ghc -Wno-ambiguous-fields #-} +-- | Settings for using Azure Active Directory as OAuth identity provider +-- +-- Both @Delegated@ (On-Behalf-Of) and @App-only@ (i.e. Client Credentials) authentication flows are supported. The former is useful when a user needs to login and delegate some permissions to the application (i.e. accessing personal data), whereas the second is for server processes and automation accounts. module Network.OAuth2.Provider.AzureAD ( AzureAD -- * App flow , azureADApp - -- * OAuth2 flow - , OAuthCfg(..) - , AzureADUser - , azureOAuthADApp - ) where + -- * Delegated permissions OAuth2 flow + , OAuthCfg(..) + , AzureADUser + , azureOAuthADApp + ) where -- import Data.String (IsString(..)) -- import GHC.Generics @@ -38,11 +41,18 @@ data AzureAD = AzureAD deriving (Eq, Show) -- * App-only flow --- | create app at https://go.microsoft.com/fwlink/?linkid=2083908 +-- | Azure OAuth application (i.e. with user consent screen) +-- +-- NB : scope @offline_access@ is ALWAYS requested +-- +-- create app at https://go.microsoft.com/fwlink/?linkid=2083908 -- -- also be aware to find the right client id. -- see https://stackoverflow.com/a/70670961 -azureADApp :: TL.Text -> ClientId -> ClientSecret -> [Scope] -> IdpApplication 'ClientCredentials AzureAD +azureADApp :: TL.Text -- ^ application name + -> ClientId -> ClientSecret + -> [Scope] -- ^ scopes + -> IdpApplication 'ClientCredentials AzureAD azureADApp appname clid sec scopes = defaultAzureADApp{ idpAppName = appname , idpAppClientId = clid @@ -55,15 +65,14 @@ defaultAzureADApp = ClientCredentialsIDPAppConfig { idpAppClientId = "" , idpAppClientSecret = "" - , idpAppScope = Set.fromList ["openid", "offline_access", "profile", "email"] -- https://learn.microsoft.com/EN-US/azure/active-directory/develop/scopes-oidc#openid-connect-scopes + , idpAppScope = Set.fromList ["offline_access"] -- https://learn.microsoft.com/EN-US/azure/active-directory/develop/scopes-oidc#openid-connect-scopes , idpAppTokenRequestExtraParams = Map.empty , idpAppName = "default-azure-app" -- , idp = defaultAzureADIdp } --- * OAuth flow - +-- * Delegated permissions flow type instance IdpUserInfo AzureAD = AzureADUser @@ -83,7 +92,7 @@ data OAuthCfg = OAuthCfg { -- -- Reference on Microsoft Graph permissions : https://learn.microsoft.com/en-us/graph/permissions-reference -- --- | create app at https://go.microsoft.com/fwlink/?linkid=2083908 +-- create app at https://go.microsoft.com/fwlink/?linkid=2083908 -- -- also be aware to find the right client id. -- see https://stackoverflow.com/a/70670961 diff --git a/ms-auth/src/Network/OAuth2/Session.hs b/ms-auth/src/Network/OAuth2/Session.hs index a9976f0..f77a58b 100644 --- a/ms-auth/src/Network/OAuth2/Session.hs +++ b/ms-auth/src/Network/OAuth2/Session.hs @@ -149,11 +149,11 @@ readToken ts = atomically $ readTVar ts -- -- https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow fetchUpdateToken :: MonadIO m => - Token OAuth2Token - -> IdpApplication 'ClientCredentials AzureAD + IdpApplication 'ClientCredentials AzureAD + -> Token OAuth2Token -> Manager -> m () -fetchUpdateToken ts idpApp mgr = liftIO $ void $ forkFinally loop cleanup +fetchUpdateToken idpApp ts mgr = liftIO $ void $ forkFinally loop cleanup where cleanup = \case Left e -> throwIO e