firebase-hs-0.3.0.0: src/Firebase/Auth.hs
-- |
-- Module : Firebase.Auth
-- Description : Firebase ID token verification
--
-- Verify Firebase Authentication ID tokens (JWTs) against Google's
-- public keys using crypton for RS256 signature verification.
--
-- @
-- import Firebase.Auth
--
-- main :: IO ()
-- main = do
-- cache <- newTlsKeyCache
-- let cfg = defaultFirebaseConfig \"my-project-id\"
-- result <- verifyIdTokenCached cache cfg someJwtBytes
-- case result of
-- Left err -> putStrLn (\"Auth failed: \" ++ show err)
-- Right user -> putStrLn (\"Welcome, \" ++ show (fuUid user))
-- @
module Firebase.Auth
( -- * One-shot verification
verifyIdToken,
-- * Cached verification
KeyCache,
newKeyCache,
newTlsKeyCache,
verifyIdTokenCached,
-- * Configuration
FirebaseConfig (..),
defaultFirebaseConfig,
-- * Authenticated user
FirebaseUser (..),
lookupClaim,
hasClaim,
-- * Errors
AuthError (..),
authErrorMessage,
-- * Utilities
parseCacheMaxAge,
)
where
import Control.Exception (try)
import Control.Monad (guard)
import Crypto.Hash.Algorithms (SHA256 (..))
import Crypto.PubKey.RSA.PKCS15 (verify)
import Crypto.PubKey.RSA.Types (PublicKey)
import Data.Aeson (FromJSON (..), (.!=), (.:), (.:?))
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.Key as Key
import qualified Data.Aeson.KeyMap as KM
import qualified Data.Aeson.Types as Aeson
import Data.Bifunctor (first)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Base64.URL as B64URL
import qualified Data.ByteString.Char8 as BS8
import qualified Data.ByteString.Lazy as LBS
import Data.IORef (atomicModifyIORef', newIORef, readIORef)
import Data.List (find)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Set (Set)
import qualified Data.Set as Set
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time (NominalDiffTime, UTCTime, addUTCTime, diffUTCTime, getCurrentTime)
import Data.Time.Clock.POSIX (posixSecondsToUTCTime)
import Firebase.Auth.Internal (padBase64Url)
import Firebase.Auth.Types
import Network.HTTP.Client
( HttpException,
Manager,
Response,
httpLbs,
parseRequest,
responseBody,
responseHeaders,
)
import Network.HTTP.Client.TLS (newTlsManager)
import Network.HTTP.Types.Header (ResponseHeaders, hCacheControl)
-- ---------------------------------------------------------------------------
-- JWT internal types
-- ---------------------------------------------------------------------------
data JwtHeader = JwtHeader
{ jhAlg :: !Text,
jhKid :: !Text
}
instance FromJSON JwtHeader where
parseJSON = Aeson.withObject "JwtHeader" $ \o ->
JwtHeader
<$> o .: "alg"
<*> o .: "kid"
data JwtPayload = JwtPayload
{ jpSub :: !(Maybe Text),
jpIss :: !(Maybe Text),
jpAud :: !(Maybe Text),
jpExp :: !(Maybe Integer),
jpIat :: !(Maybe Integer),
jpAuthTime :: !(Maybe Integer),
jpEmail :: !(Maybe Text),
jpEmailVerified :: !Bool,
jpName :: !(Maybe Text),
jpPicture :: !(Maybe Text),
jpSignInProvider :: !(Maybe Text),
jpCustomClaims :: !(Map Text Aeson.Value)
}
instance FromJSON JwtPayload where
parseJSON = Aeson.withObject "JwtPayload" $ \o ->
JwtPayload
<$> o .:? "sub"
<*> o .:? "iss"
<*> o .:? "aud"
<*> o .:? "exp"
<*> o .:? "iat"
<*> o .:? "auth_time"
<*> o .:? "email"
<*> o .:? "email_verified" .!= False
<*> o .:? "name"
<*> o .:? "picture"
<*> parseSignInProvider o
<*> pure (customClaims o)
-- | Read @firebase.sign_in_provider@ out of the nested Firebase claim.
parseSignInProvider :: Aeson.Object -> Aeson.Parser (Maybe Text)
parseSignInProvider o =
o .:? "firebase"
>>= maybe (pure Nothing) (Aeson.withObject "firebase" (.:? "sign_in_provider"))
-- | Claim names Firebase reserves, which @setCustomUserClaims@ may not set.
--
-- Everything outside this set is a custom claim and is surfaced as one.
reservedClaims :: Set Text
reservedClaims =
Set.fromList
[ "acr",
"amr",
"at_hash",
"aud",
"auth_time",
"azp",
"c_hash",
"cnf",
"exp",
"email",
"email_verified",
"firebase",
"iat",
"iss",
"jti",
"name",
"nbf",
"nonce",
"phone_number",
"picture",
"sub",
"user_id"
]
-- | Every claim outside 'reservedClaims'.
customClaims :: Aeson.Object -> Map Text Aeson.Value
customClaims payload =
Map.fromList
[ (name, value)
| (key, value) <- KM.toList payload,
let name = Key.toText key,
not (Set.member name reservedClaims)
]
-- | The three dot-separated parts of a compact JWT, still base64url-encoded.
data JwtParts = JwtParts
{ jwtHeaderPart :: !BS.ByteString,
jwtPayloadPart :: !BS.ByteString,
jwtSignaturePart :: !BS.ByteString
}
-- ---------------------------------------------------------------------------
-- Constants
-- ---------------------------------------------------------------------------
googleJwkUrl :: String
googleJwkUrl =
"https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com"
-- | Cache lifetime assumed when Google's response carries no @max-age@.
defaultCacheDurationSeconds :: NominalDiffTime
defaultCacheDurationSeconds = 3600
firebaseIssuerPrefix :: Text
firebaseIssuerPrefix = "https://securetoken.google.com/"
expectedAlgorithm :: Text
expectedAlgorithm = "RS256"
-- | The @Cache-Control@ directive carrying a response's lifetime in seconds.
maxAgeDirective :: BS.ByteString
maxAgeDirective = "max-age="
-- | The separator between the three parts of a compact JWT.
jwtPartSeparator :: Char
jwtPartSeparator = '.'
-- ---------------------------------------------------------------------------
-- One-shot verification
-- ---------------------------------------------------------------------------
-- | Verify a Firebase ID token, fetching Google's public keys fresh.
--
-- For production servers, prefer 'verifyIdTokenCached' to avoid
-- re-fetching keys on every call.
verifyIdToken ::
Manager ->
FirebaseConfig ->
BS.ByteString ->
IO (Either AuthError FirebaseUser)
verifyIdToken mgr config token = do
fetched <- fetchGoogleKeys mgr
either (pure . Left) (verifyWithKeys config token . fst) fetched
-- ---------------------------------------------------------------------------
-- Cached verification
-- ---------------------------------------------------------------------------
-- | Create a key cache backed by the given HTTP manager.
--
-- The cache starts empty and expired, so the first verification fetches
-- Google's keys.
newKeyCache :: Manager -> IO KeyCache
newKeyCache mgr = do
epoch <- getCurrentTime
ref <- newIORef (JwkSet [], epoch)
pure KeyCache {kcKeysRef = ref, kcManager = mgr}
-- | Create a key cache with a fresh TLS-enabled HTTP manager.
newTlsKeyCache :: IO KeyCache
newTlsKeyCache = newTlsManager >>= newKeyCache
-- | Verify a Firebase ID token using cached public keys.
--
-- Keys are refreshed automatically when the cache expires (per Google's
-- @Cache-Control: max-age@ response header).
verifyIdTokenCached ::
KeyCache ->
FirebaseConfig ->
BS.ByteString ->
IO (Either AuthError FirebaseUser)
verifyIdTokenCached cache config token = do
keys <- currentKeys cache
either (pure . Left) (verifyWithKeys config token) keys
-- | The cached key set, refetching first if it has expired.
currentKeys :: KeyCache -> IO (Either AuthError JwkSet)
currentKeys cache = do
now <- getCurrentTime
(keys, expiry) <- readIORef (kcKeysRef cache)
if now < expiry
then pure (Right keys)
else refreshKeys cache
-- | Fetch a fresh key set and install it in the cache.
refreshKeys :: KeyCache -> IO (Either AuthError JwkSet)
refreshKeys cache = do
fetched <- fetchGoogleKeys (kcManager cache)
traverse install fetched
where
install entry = do
atomicModifyIORef' (kcKeysRef cache) (\cached -> (laterExpiring cached entry, ()))
pure (fst entry)
-- | Of two cache entries, the one that stays valid longer.
--
-- Concurrent verifications can each refresh on expiry; keeping the later
-- expiry stops a slow response from displacing a newer key set.
laterExpiring :: (JwkSet, UTCTime) -> (JwkSet, UTCTime) -> (JwkSet, UTCTime)
laterExpiring cached fetched
| snd fetched > snd cached = fetched
| otherwise = cached
-- ---------------------------------------------------------------------------
-- Key fetching
-- ---------------------------------------------------------------------------
-- | Fetch Google's current public keys.
--
-- Only 'HttpException' becomes a 'KeyFetchError': anything else
-- (asynchronous cancellation, for instance) is not a fetch result and
-- propagates.
fetchGoogleKeys :: Manager -> IO (Either AuthError (JwkSet, UTCTime))
fetchGoogleKeys mgr = do
req <- parseRequest googleJwkUrl
fetched <- try (httpLbs req mgr) :: IO (Either HttpException (Response LBS.ByteString))
case fetched of
Left err -> pure (Left (KeyFetchError (T.pack (show err))))
Right resp -> do
now <- getCurrentTime
pure (fmap (,cacheExpiry now resp) (decodeKeys (responseBody resp)))
-- | When a key response stops being usable, per its @Cache-Control@ header.
cacheExpiry :: UTCTime -> Response LBS.ByteString -> UTCTime
cacheExpiry now resp = addUTCTime duration now
where
duration =
maybe defaultCacheDurationSeconds fromIntegral $
parseCacheMaxAge (responseHeaders resp)
decodeKeys :: LBS.ByteString -> Either AuthError JwkSet
decodeKeys = first (KeyFetchError . T.pack) . Aeson.eitherDecode
-- | Parse the @max-age@ directive from a @Cache-Control@ response header.
--
-- Yields 'Nothing' when the directive is absent, unparseable, or
-- non-positive, leaving the caller to apply its own default.
--
-- >>> parseCacheMaxAge [("cache-control", "public, max-age=19845, must-revalidate")]
-- Just 19845
parseCacheMaxAge :: ResponseHeaders -> Maybe Int
parseCacheMaxAge headers = do
cacheControl <- lookup hCacheControl headers
let (_before, fromDirective) = BS.breakSubstring maxAgeDirective cacheControl
(seconds, _rest) <- BS8.readInt (BS.drop (BS.length maxAgeDirective) fromDirective)
seconds <$ guard (seconds > 0)
-- ---------------------------------------------------------------------------
-- JWT verification
-- ---------------------------------------------------------------------------
-- | Verify a token against a key set. Reads the clock, then decides purely.
verifyWithKeys ::
FirebaseConfig ->
BS.ByteString ->
JwkSet ->
IO (Either AuthError FirebaseUser)
verifyWithKeys config tokenBytes jwks = do
now <- getCurrentTime
pure (validateToken config now jwks tokenBytes)
validateToken ::
FirebaseConfig ->
UTCTime ->
JwkSet ->
BS.ByteString ->
Either AuthError FirebaseUser
validateToken config now jwks tokenBytes = do
parts <- splitCompactJwt tokenBytes
header <- decodeSegment "header" (jwtHeaderPart parts) >>= decodeJson "header"
payload <- decodeSegment "payload" (jwtPayloadPart parts) >>= decodeJson "payload"
signature <- decodeSegment "signature" (jwtSignaturePart parts)
validateAlgorithm header
pubKey <- findKeyByKid (jhKid header) jwks
verifySignature pubKey (signedData parts) signature
validateClaims config now payload
extractUser payload
-- | The bytes a JWT signature covers: the header and payload, as sent.
signedData :: JwtParts -> BS.ByteString
signedData parts =
jwtHeaderPart parts <> BS8.singleton jwtPartSeparator <> jwtPayloadPart parts
-- ---------------------------------------------------------------------------
-- JWT parsing
-- ---------------------------------------------------------------------------
splitCompactJwt :: BS.ByteString -> Either AuthError JwtParts
splitCompactJwt token =
case BS8.split jwtPartSeparator token of
[header, payload, signature] -> Right (JwtParts header payload signature)
_ -> Left (MalformedToken "expected 3 dot-separated parts")
decodeSegment :: Text -> BS.ByteString -> Either AuthError BS.ByteString
decodeSegment label = first (malformed label) . B64URL.decode . padBase64Url
decodeJson :: (FromJSON a) => Text -> BS.ByteString -> Either AuthError a
decodeJson label = first (malformed label) . Aeson.eitherDecodeStrict
malformed :: Text -> String -> AuthError
malformed label detail = MalformedToken (label <> ": " <> T.pack detail)
-- ---------------------------------------------------------------------------
-- Signature verification
-- ---------------------------------------------------------------------------
validateAlgorithm :: JwtHeader -> Either AuthError ()
validateAlgorithm header
| jhAlg header == expectedAlgorithm = Right ()
| otherwise = Left (MalformedToken ("unsupported algorithm: " <> jhAlg header))
findKeyByKid :: Text -> JwkSet -> Either AuthError PublicKey
findKeyByKid kid (JwkSet keys) =
maybe (Left InvalidSignature) (Right . jkKey) (find ((== kid) . jkKid) keys)
verifySignature :: PublicKey -> BS.ByteString -> BS.ByteString -> Either AuthError ()
verifySignature pubKey payload signature
| verify (Just SHA256) pubKey payload signature = Right ()
| otherwise = Left InvalidSignature
-- ---------------------------------------------------------------------------
-- Claims validation
-- ---------------------------------------------------------------------------
validateClaims :: FirebaseConfig -> UTCTime -> JwtPayload -> Either AuthError ()
validateClaims config now payload = do
requireClaim "iss" (jpIss payload) (== expectedIssuer) (InvalidClaims "issuer mismatch")
requireClaim "aud" (jpAud payload) (== projectId) (InvalidClaims "audience mismatch")
requireExpiry skew now (jpExp payload)
requireIssuedAt skew now (jpIat payload)
where
projectId = fcProjectId config
expectedIssuer = firebaseIssuerPrefix <> projectId
skew = fcClockSkew config
requireClaim :: Text -> Maybe Text -> (Text -> Bool) -> AuthError -> Either AuthError ()
requireClaim label mVal predicate err =
case mVal of
Just val | predicate val -> Right ()
Just _ -> Left err
Nothing -> Left (InvalidClaims ("missing " <> label <> " claim"))
requireExpiry :: NominalDiffTime -> UTCTime -> Maybe Integer -> Either AuthError ()
requireExpiry _skew _now Nothing = Left (InvalidClaims "missing exp claim")
requireExpiry skew now (Just expSeconds)
| addUTCTime skew (posixSecondsToUTCTime (fromInteger expSeconds)) >= now = Right ()
| otherwise = Left TokenExpired
requireIssuedAt :: NominalDiffTime -> UTCTime -> Maybe Integer -> Either AuthError ()
requireIssuedAt _skew _now Nothing = Left (InvalidClaims "missing iat claim")
requireIssuedAt skew now (Just iatSeconds)
| diffUTCTime (posixSecondsToUTCTime (fromInteger iatSeconds)) now <= skew = Right ()
| otherwise = Left (InvalidClaims "token issued in the future")
-- ---------------------------------------------------------------------------
-- User extraction
-- ---------------------------------------------------------------------------
extractUser :: JwtPayload -> Either AuthError FirebaseUser
extractUser payload =
case jpSub payload of
Nothing -> Left (InvalidClaims "missing sub claim")
Just sub
| T.null sub -> Left (InvalidClaims "empty sub claim")
| otherwise ->
Right
FirebaseUser
{ fuUid = sub,
fuEmail = jpEmail payload,
fuEmailVerified = jpEmailVerified payload,
fuName = jpName payload,
fuPicture = jpPicture payload,
fuAuthTime = posixSecondsToUTCTime . fromInteger <$> jpAuthTime payload,
fuSignInProvider = jpSignInProvider payload,
fuCustomClaims = jpCustomClaims payload
}