{-# LANGUAGE AllowAmbiguousTypes #-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PackageImports #-} {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE RankNTypes #-} {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeFamilies #-} {-# OPTIONS_GHC -Wno-unused-imports #-} -- | A yesod-auth plugin for per-tenant SSO via OpenID Connect, using -- Authorization Code flow (AKA server flow) with client_secret_post -- client authentication. -- -- Reserves "ya-oidc-*" as session keys. -- -- Referenced standards: -- * OIDC Core: https://openid.net/specs/openid-connect-core-1_0.html -- * RFC 6749, OAuth 2.0: https://tools.ietf.org/html/rfc6749 -- * RFC 6750, OAuth 2.0 Bearer Token Usage: https://tools.ietf.org/html/rfc6750 module Yesod.Auth.OIDC ( oidcPluginName , authOIDC , ClientId(..) , ClientSecret(..) , UserInfo , UserInfoPreference(..) , YesodAuthOIDC(..) , OAuthErrorResponse(..) , oidcSessionExpiryMiddleware -- * Routes , oidcLoginR , oidcForwardR , oidcCallbackR -- * Re-exported from oidc-client , Configuration(..) , Provider(..) , IssuerLocation , Tokens(..) , IdTokenClaims(..) -- * Exposed or re-exported for testing and mocking , MockOidcProvider(..) , SessionStore(..) , OIDC(..) , JwsAlgJson(..) , JwsAlg(..) , Jwt(..) , IntDate(..) , CallbackInput(..) ) where import ClassyPrelude.Yesod import qualified "cryptonite" Crypto.Random as Crypto import qualified Data.Aeson as J import qualified Data.ByteString.Base64.URL as Base64Url import qualified Data.HashMap.Strict as HM import qualified Data.HashSet as HashSet import qualified Data.Text as T import Data.Time.Clock import Data.Time.Clock.POSIX import qualified Network.HTTP.Client as HTTP import Web.OIDC.Client as Client import Web.OIDC.Client.Discovery.Provider (JwsAlgJson(..)) import Web.OIDC.Client.Settings import qualified Web.OIDC.Client.Types as Scopes import Yesod.Auth -- For re-export for mocking: import Jose.Jwa (JwsAlg(..)) import Jose.Jwt (IntDate(..), Jwt(..)) data YesodAuthOIDCException = InvalidQueryParamsException Text | BadLoginHint | NoProviderConfigException | InvalidSecurityTokenException | TLSNotUsedException Text | UnknownTokenType Text deriving Show instance Exception YesodAuthOIDCException -- | Add this value to your YesodAuth instance's 'authPlugins' list authOIDC :: YesodAuthOIDC site => AuthPlugin site authOIDC = AuthPlugin oidcPluginName dispatch loginW -- | The login hint is sent as the `login_hint` query parameter to the -- service provider's authentication URL. It is commonly an email -- address and hence why oidcForwardR takes an "email" post -- parameter. It can be used not only for this purpose but also as a -- hint to your own app about which tenant configuration to use (based -- on the email domain perhaps). type LoginHint = Text -- | Response of call to the UserInfo Endpoint. This library does not -- currently support signed or encrypted responses to this particular -- request (unlike the ID Token response which must be signed). C.f. -- OIDC Core 5.3.2 type UserInfo = J.Object -- | Write an instance of this class for your Yesod App class (YesodAuth site) => YesodAuthOIDC site where -- | (Optional). If this is False, there will be no '/auth/page/oidc/login' with -- its default form asking for an email. This can be used if you -- consolidate your various yesod auth plugins into one login page -- outside of this plugin. In that case, you would initialise OIDC -- login by POSTing to 'oidcForwardR' with "email" and Yesod's -- 'defaultCsrfParamName' from your own page. Defaut is True. enableLoginPage :: Bool enableLoginPage = True -- | (Optional) A callback to your app in case oidcForwardR is -- called without the login_hint query parameter. Default -- implementation throws a 'BadLoginHint' exception. onBadLoginHint :: AuthHandler site TypedContent onBadLoginHint = throwIO BadLoginHint -- | Looks up configuration. If none can be found, you should handle -- the fallback / error call yourself. Returns the ClientID for the -- given identity provider, and either the provider configuration -- itself, or otherwise just the Issuer URI. If the latter, this -- library will use OIDC discovery to retrieve the configuration. -- -- The Issuer URI should only consist of the scheme (which must be -- "https:") and fully qualified host name (e.g. example.com), with -- no path etc. -- -- The full configuration could be hard-coded or the cached result -- of a previous discovery. Cf 'onProviderConfigDiscovered'. -- -- Note that the 'Provider' is both the configuration and the result of -- retrieving the keyset from jwks_uri. getProviderConfig :: LoginHint -> AuthHandler site (Either Provider IssuerLocation, ClientId) -- | (Optional). If the tenant is configured via a discovery URL, -- this function will be called with the discovered result and that -- result's retrieved keyset. This can be used to cache the -- configuration for the given duration. Since the oidc-client -- library combines discovery with key retrieval, the given time is -- the minimum of the two remaining cache lifetimes returned by both -- http requests. onProviderConfigDiscovered :: Provider -> ClientId -> DiffTime -> AuthHandler site () onProviderConfigDiscovered _ _ _ = pure () -- | (Optional). Do something if the 'oidcCallbackR' was called with -- incorrect parameters or the Identity Provider returned an -- error. This could happen if the request is not legitimate or if -- the identity provider doesn't provide the required `state` or -- `code` query or post parameters. -- -- Defaults to a simple page showing the error (sans the error_uri). onBadCallbackRequest :: Maybe OAuthErrorResponse -- ^ The OAuth Error Response if present (See RFC6749 §5.2 and -- OIDC §3.1.2.6). This will only be 'Just' if the "state" param -- (anti-CSRF token) is valid. -> AuthHandler site a onBadCallbackRequest mError = do errHtml <- authLayout $ toWidget widg sendResponseStatus status400 errHtml where widg = [whamlet|

Error

There has been some miscommunication between your Identity Provider and our application.

Please try logging in again and contact support if the problem persists. $maybe OAuthErrorResponse err mErrDesc _ <- mError

Error code: #{err} $maybe errDesc <- mErrDesc

Error description: #{errDesc} $maybe errUri <- mErrDesc

More information: #{errUri} |] -- | The printable-ASCII client_secret which you've set up with the -- provider ahead of time (this library does not support the dynamic -- registration spec). getClientSecret :: ClientId -> Configuration -> AuthHandler site ClientSecret -- | (Optional). The scopes that you are requesting. The "openid" -- scope will always be included in the eventual request whether or -- not you specify it here. Defaults to ["email"]. getScopes :: ClientId -> Configuration -> AuthHandler site [ScopeValue] getScopes _ _ = pure [email] -- | (Optional). Configure the behaviour of when to request user -- information. The default behaviour is to only make this request -- if it's necessary satisfy the scopes in 'getScopes'. getUserInfoPreference :: LoginHint -> ClientId -> Configuration -> AuthHandler site UserInfoPreference getUserInfoPreference _ _ _ = pure GetUserInfoOnlyToSatisfyRequestedScopes -- | (Required). Should return a unique identifier for this user to -- use as the key in the yesod app's session backend. Sent after the -- user has successfully authenticated and right before telling -- Yesod that the user is authenticated. This function can still -- cancel authentication if it throws an error or short-circuits. -- -- If you are using the underlying OAuth spec for non-OIDC reasons, -- you can do extra work here, such as storing the access and -- refresh tokens. onSuccessfulAuthentication :: LoginHint -- ^ *Warning*: This is original login hint (typically an email), -- does *not* assert anything about the user's identity. The user -- could have logged in with an email different to this one, or -- their email at the Identity Provider could just be different to -- this hint. Use the information in the ID Token and UserInfo -- Response for authentic identity information. -> ClientId -> Provider -> Tokens J.Object -- ^ The OIDC 'Token Response', including a fully validated ID -- Token. The 'otherClaims' value is purposefully an unparsed JSON -- object to provide maximum flexibility. -> Maybe UserInfo -- ^ The response of the userinfo endpoint is given depending on -- the 'getUserInfoPreference' and whether the request was -- actually successful. For flexibility, any exceptions in the -- course of getting the UserInfo are caught by this library; -- such errors only manifest as an unexpected 'Nothing' here. -> AuthHandler site Text -- | Defaults to clearing the credentials from the session and -- redirecting to the site's logoutDest (if not currently there -- already or out loginDest) onSessionExpiry :: HandlerFor site () onSessionExpiry = clearCreds True -- | Should return your app's 'HttpManager' or a mock for -- testing. Allows high-level mocking of the 3 functions that use -- the HttpManager (as opposed to a lower-level mock of the 3 HTTP -- responses themselves). getHttpManagerForOidc :: AuthHandler site (Either MockOidcProvider HTTP.Manager) data MockOidcProvider = MockOidcProvider { mopDiscover :: Text -> Provider , mopGetValidTokens :: LoginHint -> CallbackInput -> SessionStore IO -> OIDC -> Tokens J.Object , mopRequestUserInfo :: HTTP.Request -> Tokens (J.Object) -> Maybe J.Object } data UserInfoPreference = GetUserInfoIfAvailable -- ^ Always requests the userinfo, as long as the 'Provider' -- configuration has a userinfo endpoint. | GetUserInfoOnlyToSatisfyRequestedScopes -- ^ (Default). Only requests the user info if a) it's available -- and b) the token endpoint did not return all the scoped claims -- requested (cf 'getScopes'). For example, many Identity -- Providers will return "email" in the token response, and thus -- there is no need to request the user info if that's all your -- app wants. | NeverGetUserInfo deriving (Show, Eq) -- | The name used to render this plugin's routes, "oidc". oidcPluginName :: Text oidcPluginName = "oidc" -- | Optional route that reads in the "login hint" (commonly an email -- address). Your app can use this for its main login screen, or it -- could have a separate login screen not managed by this plugin but -- which redirects to 'oidcForwardR' with the login_hint when -- appropriate. -- -- /auth/page/oidc/login oidcLoginR :: AuthRoute oidcLoginR = PluginR oidcPluginName ["login"] -- | This accepts an `email` post param. Looks up or discovers -- the OIDC provider associated with this login_hint, and redirects -- the user to the provider's Authorization Endpoint. -- -- /auth/page/oidc/forward oidcForwardR :: AuthRoute oidcForwardR = PluginR oidcPluginName ["forward"] -- | This route is given to the provider so that the provider can -- redirect the user here with the appropriate Authorisation Code, at -- which point the library continues the authentication process. -- -- /auth/page/oidc/callback oidcCallbackR :: AuthRoute oidcCallbackR = PluginR oidcPluginName ["callback"] dispatch :: forall site. YesodAuthOIDC site => Text -> [Text] -> AuthHandler site TypedContent dispatch httpMethod uriPath = case (httpMethod, uriPath) of ("GET", ["login"]) -> if enableLoginPage @site then getLoginR else notFound ("POST", ["forward"]) -> postForwardR -- These two handlers are ultimately the same handler. Identity -- Providers may use GET or POST for the callback. ("GET", ["callback"]) -> handleCallback GET ("POST", ["callback"]) -> handleCallback POST _ -> notFound loginW :: (Route Auth -> Route site) -> WidgetFor site () loginW toParentRoute = do mToken <- reqToken <$> liftHandler getRequest [whamlet|

Sign in

Sign in with OpenID Connect (single sign on). Enter your email, and we'll redirect you to your company's login page.

$maybe token <- mToken