-- SPDX-FileCopyrightText: 2023-2024 Sarah Vaupel , David Mosbach -- -- SPDX-License-Identifier: AGPL-3.0-or-later {-# OPTIONS_GHC -fno-warn-orphans #-} module Auth.OAuth2 ( apAzure , azurePrimaryKey, azureUserPrincipalName, azureUserDisplayName, azureUserGivenName, azureUserSurname, azureUserMail, azureUserTelephone, azureUserMobile, azureUserPreferredLanguage -- , azureUser, azureUser' , AzureUserException(..), _AzureUserError, _AzureUserNoResult, _AzureUserAmbiguous , apAzureMock , azureMockServer , queryOAuth2User , refreshOAuth2Token , singleSignOut ) where -- import qualified Data.CaseInsensitive as CI import Data.Maybe (fromJust) import Data.Text import Import.NoFoundation hiding (pack, unpack) import Network.HTTP.Simple (httpJSONEither, getResponseBody, JSONException) import System.Environment (lookupEnv) import Yesod.Auth.OAuth2 import Yesod.Auth.OAuth2.Prelude hiding (encodeUtf8) -- | Plugin name of the OAuth2 yesod plugin for Azure ADv2 apAzure :: Text apAzure = "AzureADv2" -- TODO: deprecate in favour of FetchUserDataException data AzureUserException = AzureUserError | AzureUserNoResult | AzureUserAmbiguous deriving (Show, Eq, Generic) instance Exception AzureUserException makePrisms ''AzureUserException azurePrimaryKey, azureUserPrincipalName, azureUserDisplayName, azureUserGivenName, azureUserSurname, azureUserMail, azureUserTelephone, azureUserMobile, azureUserPreferredLanguage :: Text azurePrimaryKey = "id" azureUserPrincipalName = "userPrincipalName" azureUserDisplayName = "displayName" azureUserGivenName = "givenName" azureUserSurname = "surname" azureUserMail = "mail" azureUserTelephone = "businessPhones" azureUserMobile = "mobilePhone" azureUserPreferredLanguage = "preferredLanguage" -- | User lookup in Microsoft Graph with given credentials -- TODO: deprecate in favour of fetchUserData -- azureUser :: ( MonadMask m -- , MonadHandler m -- -- , HandlerSite m ~ site -- -- , BackendCompatible SqlBackend (YesodPersistBackend site) -- -- , BaseBackend (YesodPersistBackend site) ~ SqlBackend -- -- , YesodPersist site -- -- , PersistUniqueWrite (YesodPersistBackend site) -- ) -- => AzureConf -- -> Creds site -- -> m [(Text, [ByteString])] -- (Either AzureUserException [(Text, [ByteString])]) -- azureUser AzureConf{..} Creds{..} = fmap throwLeft . runExceptT $ do -- now <- liftIO getCurrentTime -- results <- queryOAuth2User @[(Text, [ByteString])] credsIdent -- case results of -- Right [res] -> do -- -- void . liftHandler . runDB $ upsert ExternalUser -- -- { externalUserUser = error "no userid" -- TODO: use azureUserPrimaryKey once UserIdent is referenced instead of UserId -- -- , externalUserSource = AuthSourceIdAzure azureConfClientId -- -- , externalUserData = toJSON res -- -- , externalUserLastSync = now -- -- } -- -- [ ExternalUserData =. toJSON res -- -- , ExternalUserLastSync =. now -- -- ] -- return res -- Right _multiple -> throwE AzureUserAmbiguous -- Left _ -> throwE AzureUserNoResult -- | User lookup in Microsoft Graph with given user -- azureUser' :: ( MonadMask m -- , MonadHandler m -- , HandlerSite m ~ site -- , BaseBackend (YesodPersistBackend site) ~ SqlBackend -- , YesodPersist site -- , PersistUniqueWrite (YesodPersistBackend site) -- ) -- => AzureConf -- -> User -- -> ReaderT (YesodPersistBackend site) m (Maybe [(Text, [ByteString])]) -- (Either AzureUserException [(Text, [ByteString])]) -- azureUser' conf User{userIdent} -- = runMaybeT . catchIfMaybeT (is _AzureUserNoResult) $ azureUser conf (Creds apAzure (CI.original userIdent) []) ----------------------------------------------- ---- OAuth2 + OIDC development auth plugin ---- ----------------------------------------------- apAzureMock :: Text apAzureMock = "uniworx_dev" newtype UserID = UserID Text instance FromJSON UserID where parseJSON = withObject "UserID" $ \o -> UserID <$> o .: "id" azureMockServer :: YesodAuth m => String -> AuthPlugin m azureMockServer port = let oa = OAuth2 { oauthClientId = "42" , oauthClientSecret = Just "shhh" , oauthOAuthorizeEndpoint = fromString (mockServerURL <> "/auth") `withQuery` [ scopeParam " " ["openid", "profile", "email", "offline_access"] -- TODO read scopes from config , ("response_type", "code id_token") , ("nonce", "Foo") -- TODO generate meaningful value ] , oauthAccessTokenEndpoint = fromString $ mockServerURL <> "/token" , oauthCallback = Nothing } mockServerURL = "http://localhost:" <> fromString port profileSrc = fromString $ mockServerURL <> "/users/me" in authOAuth2 apAzureMock oa $ \manager token -> do (UserID userID, userResponse) <- authGetProfile apAzureMock manager token profileSrc return Creds { credsPlugin = apAzureMock , credsIdent = userID , credsExtra = setExtra token userResponse } ---------------------- ---- User Queries ---- ---------------------- data UserDataException = UserDataJSONException JSONException | UserDataInternalException Text deriving Show instance Exception UserDataException queryOAuth2User :: forall j m. ( FromJSON j , MonadHandler m , MonadThrow m ) => Text -- ^ User identifier (arbitrary needle) -> m (Either UserDataException j) queryOAuth2User userID = runExceptT $ do (queryUrl, tokenUrl) <- liftIO mkBaseUrls req <- parseRequest $ "GET " ++ queryUrl ++ unpack userID mTokens <- lookupSessionJson SessionOAuth2Token unless (isJust mTokens) . throwE $ UserDataInternalException "Tried to load session Oauth2 tokens, but there are none" # ifdef DEVELOPMENT let secure = False # else let secure = True # endif newTokens <- refreshOAuth2Token @m (fromJust mTokens) tokenUrl secure setSessionJson SessionOAuth2Token (Just $ accessToken newTokens, refreshToken newTokens) eResult <- lift $ getResponseBody <$> httpJSONEither @m @j (req { secure = secure , requestHeaders = [("Authorization", encodeUtf8 . ("Bearer " <>) . atoken $ accessToken newTokens)] }) case eResult of Left x -> throwE $ UserDataJSONException x Right x -> return x mkBaseUrls :: IO (String, String) mkBaseUrls = do # ifndef DEVELOPMENT Just tenantID <- lookupEnv "AZURE_TENANT_ID" return ( "https://graph.microsoft.com/v1.0/users/" , "https://login.microsoftonline.com/" ++ tenantID ++ "/oauth2/v2.0" ) # else Just port <- lookupEnv "OAUTH2_SERVER_PORT" let base = "http://localhost:" ++ port return ( base ++ "/users/query?id=" , base ++ "/token" ) # endif refreshOAuth2Token :: forall m. ( MonadHandler m , MonadThrow m ) => (Maybe AccessToken, Maybe RefreshToken) -> String -> Bool -> ExceptT UserDataException m OAuth2Token refreshOAuth2Token (_, rToken) url secure | isJust rToken = do req <- parseRequest $ "POST " ++ url let body = [ ("grant_type", "refresh_token") , ("refresh_token", encodeUtf8 . rtoken $ fromJust rToken) ] body' <- if secure then do clientID <- liftIO $ fromJust <$> lookupEnv "CLIENT_ID" clientSecret <- liftIO $ fromJust <$> lookupEnv "CLIENT_SECRET" return $ body ++ [("client_id", fromString clientID), ("client_secret", fromString clientSecret), scopeParam " " ["openid","profile"," offline_access"]] -- TODO read from config else return $ scopeParam " " ["openid","profile","offline_access"] : body -- TODO read from config $logDebugS "\27[31mAdmin Handler\27[0m" $ tshow (requestBody $ urlEncodedBody body' req{ secure = secure }) eResult <- lift $ getResponseBody <$> httpJSONEither @m @OAuth2Token (urlEncodedBody body' req{ secure = secure }) case eResult of Left x -> throwE $ UserDataJSONException x Right x -> return x | otherwise = throwE $ UserDataInternalException "Could not refresh access token. Refresh token is missing." instance Show RequestBody where show (RequestBodyLBS x) = show x show _ = error ":(" ----------------------- ---- Single Sign-Out ---- ----------------------- singleSignOut :: forall a m. (MonadHandler m) => Maybe Text -- ^ redirect uri -> m a singleSignOut mRedirect = do # ifdef DEVELOPMENT port <- liftIO $ fromJust <$> lookupEnv "OAUTH2_SERVER_PORT" let base = "http://localhost:" <> pack port <> "/logout" # else let base = "" -- TODO find out fraport oidc end_session_endpoint # endif endpoint = case mRedirect of Just r -> base <> "?post_logout_redirect_uri=" <> r Nothing -> base $logDebugS "\n\27[31mSSO\27[0m" endpoint redirect endpoint