firebase-hs-0.1.0.0: src/Firebase/Auth.hs
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE StrictData #-}
-- |
-- Module : Firebase.Auth
-- Description : Firebase ID token verification
-- License : MIT
--
-- Verify Firebase Authentication ID tokens (JWTs) against Google's
-- public keys. Supports both one-shot verification and cached key
-- stores for production servers.
--
-- @
-- 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
newKeyCache,
newTlsKeyCache,
verifyIdTokenCached,
-- * Utilities
parseCacheMaxAge,
-- * Re-exports
module Firebase.Auth.Types,
)
where
import Control.Concurrent.STM (atomically, newTVarIO, readTVarIO, writeTVar)
import Control.Exception (SomeException, evaluate, try)
import Control.Lens (view, (&), (.~))
import Control.Monad.Trans.Except (ExceptT, runExceptT, withExceptT)
import Crypto.JOSE.Error (Error)
import Crypto.JOSE.JWK (JWKSet (..))
import Crypto.JWT
( ClaimsSet,
HasClaimsSet (..),
JWTError (..),
JWTValidationSettings,
SignedJWT,
StringOrURI,
claimSub,
decodeCompact,
defaultJWTValidationSettings,
jwtValidationSettingsAllowedSkew,
jwtValidationSettingsIssuerPredicate,
verifyJWT,
)
import Data.Aeson (FromJSON (..), (.:?))
import qualified Data.Aeson as Aeson
import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as BS8
import qualified Data.ByteString.Lazy as LBS
import Data.String (fromString)
import qualified Data.Text as T
import Data.Time (NominalDiffTime, UTCTime, addUTCTime, getCurrentTime)
import Firebase.Auth.Types
import Network.HTTP.Client
( Manager,
Response,
httpLbs,
parseRequest,
responseBody,
responseHeaders,
)
import Network.HTTP.Client.TLS (newTlsManager)
import Network.HTTP.Types.Header (ResponseHeaders, hCacheControl)
-- ---------------------------------------------------------------------------
-- Firebase JWT claims (jose subtype pattern)
-- ---------------------------------------------------------------------------
-- | JWT claims carrying both the standard 'ClaimsSet' and Firebase-specific
-- custom fields. This is the jose-recommended subtype approach: 'verifyJWT'
-- decodes and validates everything in one pass, with the types driving
-- which fields get extracted.
data FirebaseClaims = FirebaseClaims
{ _fcClaimsSet :: !ClaimsSet,
_fcEmail :: !(Maybe T.Text),
_fcName :: !(Maybe T.Text)
}
instance HasClaimsSet FirebaseClaims where
claimsSet f (FirebaseClaims cs e n) =
(\cs' -> FirebaseClaims cs' e n) <$> f cs
instance FromJSON FirebaseClaims where
parseJSON = Aeson.withObject "FirebaseClaims" $ \o ->
FirebaseClaims
<$> parseJSON (Aeson.Object o)
<*> o .:? "email"
<*> o .:? "name"
-- ---------------------------------------------------------------------------
-- Constants
-- ---------------------------------------------------------------------------
-- | Google's JWK endpoint for Firebase token verification.
googleJwkUrl :: String
googleJwkUrl =
"https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com"
-- | Fallback cache duration when @Cache-Control@ header is missing (1 hour).
defaultCacheDurationSeconds :: NominalDiffTime
defaultCacheDurationSeconds = 3600
-- | Firebase issuer URL prefix. Full issuer is this plus the project ID.
firebaseIssuerPrefix :: T.Text
firebaseIssuerPrefix = "https://securetoken.google.com/"
-- ---------------------------------------------------------------------------
-- One-shot verification
-- ---------------------------------------------------------------------------
-- | Verify a Firebase ID token, fetching Google's public keys fresh.
--
-- For production servers handling many requests, prefer 'verifyIdTokenCached'
-- to avoid re-fetching keys on every call.
verifyIdToken ::
Manager ->
FirebaseConfig ->
-- | Raw JWT bytes (compact serialization)
BS.ByteString ->
IO (Either AuthError FirebaseUser)
verifyIdToken mgr config token = do
keysResult <- fetchGoogleKeys mgr
case keysResult of
Left err -> pure (Left err)
Right (jwks, _expiry) -> verifyWithKeys jwks config token
-- ---------------------------------------------------------------------------
-- Cached verification
-- ---------------------------------------------------------------------------
-- | Create a key cache backed by the given HTTP manager.
--
-- The manager must support HTTPS (e.g. from @Network.HTTP.Client.TLS@).
-- Keys are fetched lazily on first verification and refreshed when expired.
newKeyCache :: Manager -> IO KeyCache
newKeyCache mgr = do
epoch <- getCurrentTime
ref <- newTVarIO (JWKSet [], epoch)
pure
KeyCache
{ kcKeysRef = ref,
kcManager = mgr
}
-- | Create a key cache with a fresh TLS-enabled HTTP manager.
--
-- Convenience wrapper around 'newKeyCache' for the common case.
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 ->
-- | Raw JWT bytes (compact serialization)
BS.ByteString ->
IO (Either AuthError FirebaseUser)
verifyIdTokenCached cache config token = do
now <- getCurrentTime
(jwks, expiry) <- readTVarIO (kcKeysRef cache)
keysResult <-
if now >= expiry
then do
result <- fetchGoogleKeys (kcManager cache)
case result of
Left err -> pure (Left err)
Right (newKeys, newExpiry) -> do
atomically $ writeTVar (kcKeysRef cache) (newKeys, newExpiry)
pure (Right newKeys)
else pure (Right jwks)
case keysResult of
Left err -> pure (Left err)
Right keys -> verifyWithKeys keys config token
-- ---------------------------------------------------------------------------
-- Key fetching
-- ---------------------------------------------------------------------------
-- | Fetch Google's public JWK set and compute the cache expiry from the
-- @Cache-Control: max-age@ response header.
fetchGoogleKeys :: Manager -> IO (Either AuthError (JWKSet, UTCTime))
fetchGoogleKeys mgr = do
req <- parseRequest googleJwkUrl
respResult <- try (httpLbs req mgr) :: IO (Either SomeException (Response LBS.ByteString))
case respResult of
Left err -> pure (Left (KeyFetchError (T.pack (show err))))
Right resp -> do
now <- getCurrentTime
let body = responseBody resp
maxAge = parseCacheMaxAge (responseHeaders resp)
duration = maybe defaultCacheDurationSeconds fromIntegral maxAge
expiry = addUTCTime duration now
case Aeson.eitherDecode body of
Left err -> pure (Left (KeyFetchError (T.pack err)))
Right jwks -> do
-- Force the decoded JWKSet to WHNF so verification doesn't pay
-- deferred parse cost on the hot path.
!_ <- evaluate jwks
pure (Right (jwks, expiry))
-- | Parse the @max-age@ directive from a @Cache-Control@ response header.
--
-- Returns 'Nothing' if the header is absent or has no valid @max-age@ value.
--
-- >>> parseCacheMaxAge [("cache-control", "public, max-age=19845, must-revalidate")]
-- Just 19845
parseCacheMaxAge :: ResponseHeaders -> Maybe Int
parseCacheMaxAge headers = do
cc <- lookup hCacheControl headers
let (_before, rest) = BS.breakSubstring "max-age=" cc
if BS.null rest
then Nothing
else do
let numPart = BS.drop maxAgePrefixLen rest
case BS8.readInt numPart of
Just (n, _) | n > 0 -> Just n
_ -> Nothing
where
maxAgePrefixLen :: Int
maxAgePrefixLen = 8
-- ---------------------------------------------------------------------------
-- JWT verification
-- ---------------------------------------------------------------------------
-- | Verify a JWT against the given JWK set and Firebase configuration.
--
-- Uses jose's polymorphic 'verifyJWT' with our 'FirebaseClaims' subtype,
-- so signature verification, claims validation, and custom field extraction
-- all happen in a single type-directed pass.
verifyWithKeys ::
JWKSet ->
FirebaseConfig ->
BS.ByteString ->
IO (Either AuthError FirebaseUser)
verifyWithKeys jwks config tokenBytes = do
let token = LBS.fromStrict tokenBytes
projectId = fcProjectId config
expectedIssuer = firebaseIssuerPrefix <> projectId
settings = makeValidationSettings projectId expectedIssuer (fcClockSkew config)
result <- runExceptT $ do
jwt <-
withExceptT
mapDecodeError
(decodeCompact token :: ExceptT Error IO SignedJWT)
withExceptT
mapVerifyError
(verifyJWT settings jwks jwt :: ExceptT JWTError IO FirebaseClaims)
pure $ case result of
Left err -> Left err
Right fc -> toFirebaseUser fc
-- | Build JWT validation settings for Firebase token verification.
--
-- Validates: audience = project ID, issuer = Firebase issuer URL,
-- expiry and issued-at within allowed clock skew.
makeValidationSettings ::
T.Text -> T.Text -> NominalDiffTime -> JWTValidationSettings
makeValidationSettings projectId expectedIssuer skew =
defaultJWTValidationSettings (== textToStringOrURI projectId)
& jwtValidationSettingsIssuerPredicate .~ (== textToStringOrURI expectedIssuer)
& jwtValidationSettingsAllowedSkew .~ skew
-- | Convert verified 'FirebaseClaims' to a 'FirebaseUser'.
--
-- The @sub@ claim is guaranteed present by Firebase's token contract,
-- but we handle the missing case defensively.
toFirebaseUser :: FirebaseClaims -> Either AuthError FirebaseUser
toFirebaseUser fc = do
sub <- maybe (Left (InvalidClaims "missing sub claim")) Right (view claimSub fc)
uid <- maybe (Left (InvalidClaims "empty sub claim")) Right (stringOrUriToText sub)
Right
FirebaseUser
{ fuUid = uid,
fuEmail = _fcEmail fc,
fuName = _fcName fc
}
-- ---------------------------------------------------------------------------
-- Helpers
-- ---------------------------------------------------------------------------
-- | Convert 'Data.Text.Text' to jose 'StringOrURI' via the 'Data.String.IsString' instance.
textToStringOrURI :: T.Text -> StringOrURI
textToStringOrURI = fromString . T.unpack
-- | Extract text from a jose 'StringOrURI' by round-tripping through JSON.
stringOrUriToText :: StringOrURI -> Maybe T.Text
stringOrUriToText s = case Aeson.toJSON s of
Aeson.String t | not (T.null t) -> Just t
_ -> Nothing
-- | Map a JOSE decode error to 'AuthError'.
mapDecodeError :: Error -> AuthError
mapDecodeError = MalformedToken . T.pack . show
-- | Map a JWT verification error to 'AuthError'.
mapVerifyError :: JWTError -> AuthError
mapVerifyError JWTExpired = TokenExpired
mapVerifyError JWTNotInAudience = InvalidClaims "audience mismatch"
mapVerifyError JWTNotInIssuer = InvalidClaims "issuer mismatch"
mapVerifyError JWTIssuedAtFuture = InvalidClaims "token issued in the future"
mapVerifyError JWTNotYetValid = InvalidClaims "token not yet valid"
mapVerifyError (JWTClaimsSetDecodeError msg) = MalformedToken (T.pack msg)
mapVerifyError (JWSError _) = InvalidSignature