Skip to content

Commit c74fe0e

Browse files
committed
fix: improve jwt errors
BREAKING CHANGE We were exposing internal errors via `Show`. This commit improves error messages for better user experience.
1 parent 53ca035 commit c74fe0e

File tree

4 files changed

+62
-41
lines changed

4 files changed

+62
-41
lines changed

src/PostgREST/Auth.hs

+34-19
Original file line numberDiff line numberDiff line change
@@ -54,33 +54,44 @@ import Protolude
5454

5555
-- | Receives the JWT secret and audience (from config) and a JWT and returns a
5656
-- JSON object of JWT claims.
57-
parseToken :: AppConfig -> ByteString -> UTCTime -> ExceptT Error IO JSON.Value
58-
parseToken _ "" _ = return JSON.emptyObject
59-
parseToken AppConfig{..} token time = do
60-
secret <- liftEither . maybeToRight (JwtErr JwtTokenMissing) $ configJWKS
57+
parseToken :: AppConfig -> Maybe ByteString -> UTCTime -> ExceptT Error IO JSON.Value
58+
parseToken _ Nothing _ = return JSON.emptyObject
59+
parseToken _ (Just "") _ = throwE . JwtErr $ JwtDecodeError "Empty JWT is sent in Authorization header" -- should we validate JWT ourselves before decoding or let the library (not very user friendly with errors) handle it?
60+
parseToken AppConfig{..} (Just token) time = do
61+
secret <- liftEither . maybeToRight (JwtErr JwtSecretMissing) $ configJWKS
6162
eitherContent <- liftIO $ JWT.decode (JWT.keys secret) Nothing token
6263
content <- liftEither . mapLeft (JwtErr . jwtDecodeError) $ eitherContent
6364
liftEither $ mapLeft JwtErr $ verifyClaims content
6465
where
65-
-- TODO: Improve errors, those were just taken as-is from hs-jose to avoid
66-
-- breaking changes.
6766
jwtDecodeError :: JWT.JwtError -> JwtError
68-
jwtDecodeError (JWT.KeyError _) = JwtTokenInvalid "JWSError JWSInvalidSignature"
69-
jwtDecodeError JWT.BadCrypto = JwtTokenInvalid "JWSError (CompactDecodeError Invalid number of parts: Expected 3 parts; got 2)"
70-
jwtDecodeError (JWT.BadAlgorithm _) = JwtTokenInvalid "JWSError JWSNoSignatures"
71-
jwtDecodeError e = JwtTokenInvalid $ show e
67+
-- The only errors we can get from JWT.decode function are:
68+
-- BadAlgorithm
69+
-- KeyError
70+
-- BadCrypto
71+
-- So others should have a generic message because they can't
72+
-- be covered by tests?
73+
jwtDecodeError (JWT.KeyError _) = JwtDecodeError "No suitable key or wrong key type"
74+
jwtDecodeError (JWT.BadAlgorithm _) = JwtDecodeError "Wrong or unsupported encoding algorithm"
75+
jwtDecodeError JWT.BadCrypto = JwtDecodeError "JWT parsing failed"
76+
-- We can't test below cases with current implementation
77+
-- TODO: Remove them or replace with a single generic message?
78+
jwtDecodeError (JWT.BadDots dots) = JwtDecodeError ("Wrong number of '.' periods in JWT: Expected 3, got " <> show dots)
79+
jwtDecodeError (JWT.BadHeader _) = JwtDecodeError "Header couldn't be decoded or contains bad data"
80+
jwtDecodeError JWT.BadClaims = JwtClaimsError "JWT claims couldn't be decoded or contains bad data"
81+
jwtDecodeError JWT.BadSignature = JwtDecodeError "The JWT signature couldn't be decoded or is invalid"
82+
jwtDecodeError (JWT.Base64Error _) = JwtDecodeError "A base64 decoding error has occured"
7283

7384
verifyClaims :: JWT.JwtContent -> Either JwtError JSON.Value
7485
verifyClaims (JWT.Jws (_, claims)) = case JSON.decodeStrict claims of
75-
Nothing -> Left $ JwtTokenInvalid "Parsing claims failed"
86+
Nothing -> Left $ JwtClaimsError "Parsing claims failed"
7687
Just (JSON.Object mclaims)
77-
| failedExpClaim mclaims -> Left $ JwtTokenInvalid "JWT expired"
78-
| failedNbfClaim mclaims -> Left $ JwtTokenInvalid "JWTNotYetValid"
79-
| failedIatClaim mclaims -> Left $ JwtTokenInvalid "JWTIssuedAtFuture"
80-
| failedAudClaim mclaims -> Left $ JwtTokenInvalid "JWTNotInAudience"
88+
| failedExpClaim mclaims -> Left $ JwtClaimsError "JWT expired"
89+
| failedNbfClaim mclaims -> Left $ JwtClaimsError "JWT not yet valid"
90+
| failedIatClaim mclaims -> Left $ JwtClaimsError "JWT issued at future"
91+
| failedAudClaim mclaims -> Left $ JwtClaimsError "JWT not in audience"
8192
Just jclaims -> Right jclaims
8293
-- TODO: We could enable JWE support here (encrypted tokens)
83-
verifyClaims _ = Left $ JwtTokenInvalid "Unsupported token type"
94+
verifyClaims _ = Left $ JwtDecodeError "Unsupported token type"
8495

8596
allowedSkewSeconds = 30 :: Int64
8697
now = floor . nominalDiffTimeToSeconds $ utcTimeToPOSIXSeconds time
@@ -148,7 +159,7 @@ middleware appState app req respond = do
148159
conf <- getConfig appState
149160
time <- getTime appState
150161

151-
let token = fromMaybe "" $ Wai.extractBearerAuth =<< lookup HTTP.hAuthorization (Wai.requestHeaders req)
162+
let token = Wai.extractBearerAuth =<< lookup HTTP.hAuthorization (Wai.requestHeaders req)
152163
parseJwt = runExceptT $ parseToken conf token time >>= parseClaims conf
153164
jwtCacheState = getJwtCacheState appState
154165

@@ -160,15 +171,19 @@ middleware appState app req respond = do
160171
return $ req { Wai.vault = Wai.vault req & Vault.insert authResultKey authResult & Vault.insert jwtDurKey dur }
161172

162173
(True, maxLifetime) -> do
163-
(dur, authResult) <- timeItT $ lookupJwtCache jwtCacheState token maxLifetime parseJwt time
174+
(dur, authResult) <- timeItT $ case token of
175+
Just tkn -> lookupJwtCache jwtCacheState tkn maxLifetime parseJwt time
176+
Nothing -> parseJwt
164177
return $ req { Wai.vault = Wai.vault req & Vault.insert authResultKey authResult & Vault.insert jwtDurKey dur }
165178

166179
(False, 0) -> do
167180
authResult <- parseJwt
168181
return $ req { Wai.vault = Wai.vault req & Vault.insert authResultKey authResult }
169182

170183
(False, maxLifetime) -> do
171-
authResult <- lookupJwtCache jwtCacheState token maxLifetime parseJwt time
184+
authResult <- case token of
185+
Just tkn -> lookupJwtCache jwtCacheState tkn maxLifetime parseJwt time
186+
Nothing -> parseJwt
172187
return $ req { Wai.vault = Wai.vault req & Vault.insert authResultKey authResult }
173188

174189
app req' respond

src/PostgREST/Error.hs

+19-10
Original file line numberDiff line numberDiff line change
@@ -577,9 +577,10 @@ data Error
577577
| PgErr PgError
578578

579579
data JwtError
580-
= JwtTokenInvalid Text
581-
| JwtTokenMissing
580+
= JwtDecodeError Text
581+
| JwtSecretMissing
582582
| JwtTokenRequired
583+
| JwtClaimsError Text
583584

584585
instance PgrstError Error where
585586
status (ApiRequestError err) = status err
@@ -593,13 +594,15 @@ instance PgrstError Error where
593594
headers _ = mempty
594595

595596
instance PgrstError JwtError where
596-
status JwtTokenInvalid{} = HTTP.unauthorized401
597-
status JwtTokenMissing = HTTP.status500
598-
status JwtTokenRequired = HTTP.unauthorized401
597+
status JwtDecodeError{} = HTTP.unauthorized401
598+
status JwtSecretMissing = HTTP.status500
599+
status JwtTokenRequired = HTTP.unauthorized401
600+
status JwtClaimsError{} = HTTP.unauthorized401
599601

600-
headers (JwtTokenInvalid m) = [invalidTokenHeader m]
601-
headers JwtTokenRequired = [requiredTokenHeader]
602-
headers _ = mempty
602+
headers (JwtDecodeError m) = [invalidTokenHeader m]
603+
headers JwtTokenRequired = [requiredTokenHeader]
604+
headers (JwtClaimsError m) = [invalidTokenHeader m]
605+
headers _ = mempty
603606

604607
instance JSON.ToJSON Error where
605608
toJSON (ApiRequestError err) = JSON.toJSON err
@@ -608,16 +611,20 @@ instance JSON.ToJSON Error where
608611
toJSON NoSchemaCacheError = toJsonPgrstError
609612
ConnectionErrorCode02 "Could not query the database for the schema cache. Retrying." Nothing Nothing
610613

614+
-- Should we provide hints and description or explain the error in docs or both?
611615
instance JSON.ToJSON JwtError where
612-
toJSON JwtTokenMissing = toJsonPgrstError
616+
toJSON JwtSecretMissing = toJsonPgrstError
613617
JWTErrorCode00 "Server lacks JWT secret" Nothing Nothing
614618

615-
toJSON (JwtTokenInvalid message) = toJsonPgrstError
619+
toJSON (JwtDecodeError message) = toJsonPgrstError
616620
JWTErrorCode01 message Nothing Nothing
617621

618622
toJSON JwtTokenRequired = toJsonPgrstError
619623
JWTErrorCode02 "Anonymous access is disabled" Nothing Nothing
620624

625+
toJSON (JwtClaimsError message) = toJsonPgrstError
626+
JWTErrorCode03 message Nothing Nothing
627+
621628

622629
invalidTokenHeader :: Text -> Header
623630
invalidTokenHeader m =
@@ -710,6 +717,7 @@ data ErrorCode
710717
| JWTErrorCode00
711718
| JWTErrorCode01
712719
| JWTErrorCode02
720+
| JWTErrorCode03
713721
-- Internal errors related to the Hasql library
714722
| InternalErrorCode00
715723

@@ -758,5 +766,6 @@ buildErrorCode code = case code of
758766
JWTErrorCode00 -> "PGRST300"
759767
JWTErrorCode01 -> "PGRST301"
760768
JWTErrorCode02 -> "PGRST302"
769+
JWTErrorCode03 -> "PGRST303"
761770

762771
InternalErrorCode00 -> "PGRSTX00"

test/io/test_io.py

+6-9
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ def test_jwt_errors(defaultenv):
9292
headers = jwtauthheader({}, "other secret")
9393
response = postgrest.session.get("/", headers=headers)
9494
assert response.status_code == 401
95-
assert response.json()["message"] == "JWSError JWSInvalidSignature"
95+
assert response.json()["message"] == "No suitable key or wrong key type"
9696

9797
headers = jwtauthheader({"role": "not_existing"}, SECRET)
9898
response = postgrest.session.get("/", headers=headers)
@@ -110,35 +110,32 @@ def test_jwt_errors(defaultenv):
110110
headers = jwtauthheader({"nbf": relativeSeconds(31)}, SECRET)
111111
response = postgrest.session.get("/", headers=headers)
112112
assert response.status_code == 401
113-
assert response.json()["message"] == "JWTNotYetValid"
113+
assert response.json()["message"] == "JWT not yet valid"
114114

115115
# 31 seconds, because we allow clock skew of 30 seconds
116116
headers = jwtauthheader({"iat": relativeSeconds(31)}, SECRET)
117117
response = postgrest.session.get("/", headers=headers)
118118
assert response.status_code == 401
119-
assert response.json()["message"] == "JWTIssuedAtFuture"
119+
assert response.json()["message"] == "JWT issued at future"
120120

121121
headers = jwtauthheader({"aud": "not set"}, SECRET)
122122
response = postgrest.session.get("/", headers=headers)
123123
assert response.status_code == 401
124-
assert response.json()["message"] == "JWTNotInAudience"
124+
assert response.json()["message"] == "JWT not in audience"
125125

126126
# partial token, no signature
127127
headers = authheader("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.bm90IGFuIG9iamVjdA")
128128
response = postgrest.session.get("/", headers=headers)
129129
assert response.status_code == 401
130-
assert (
131-
response.json()["message"]
132-
== "JWSError (CompactDecodeError Invalid number of parts: Expected 3 parts; got 2)"
133-
)
130+
assert response.json()["message"] == "JWT parsing failed"
134131

135132
# token with algorithm "none"
136133
headers = authheader(
137134
"eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.e30.yOBhlOIqn56T-4NvyEXCjfi3UmyQZ-BzXtePMO2NgRI"
138135
)
139136
response = postgrest.session.get("/", headers=headers)
140137
assert response.status_code == 401
141-
assert response.json()["message"] == "JWSError JWSNoSignatures"
138+
assert response.json()["message"] == "Wrong or unsupported encoding algorithm"
142139

143140

144141
def test_fail_with_invalid_password(defaultenv):

test/spec/Feature/Auth/AuthSpec.hs

+3-3
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ spec = describe "authorization" $ do
8888
it "fails with an expired token" $ do
8989
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE0NDY2NzgxNDksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.f8__E6VQwYcDqwHmr9PG03uaZn8Zh1b0vbJ9DYS0AdM"
9090
request methodGet "/authors_only" [auth] ""
91-
`shouldRespondWith` [json| {"message":"JWT expired","code":"PGRST301","hint":null,"details":null} |]
91+
`shouldRespondWith` [json| {"message":"JWT expired","code":"PGRST303","hint":null,"details":null} |]
9292
{ matchStatus = 401
9393
, matchHeaders = [
9494
"WWW-Authenticate" <:>
@@ -99,11 +99,11 @@ spec = describe "authorization" $ do
9999
it "hides tables from users with invalid JWT" $ do
100100
let auth = authHeaderJWT "ey9zdGdyZXN0X3Rlc3RfYXV0aG9yIiwiaWQiOiJqZG9lIn0.y4vZuu1dDdwAl0-S00MCRWRYMlJ5YAMSir6Es6WtWx0"
101101
request methodGet "/authors_only" [auth] ""
102-
`shouldRespondWith` [json| {"message":"JWSError (CompactDecodeError Invalid number of parts: Expected 3 parts; got 2)","code":"PGRST301","hint":null,"details":null} |]
102+
`shouldRespondWith` [json| {"message":"JWT parsing failed","code":"PGRST301","hint":null,"details":null} |]
103103
{ matchStatus = 401
104104
, matchHeaders = [
105105
"WWW-Authenticate" <:>
106-
"Bearer error=\"invalid_token\", error_description=\"JWSError (CompactDecodeError Invalid number of parts: Expected 3 parts; got 2)\""
106+
"Bearer error=\"invalid_token\", error_description=\"JWT parsing failed\""
107107
]
108108
}
109109

0 commit comments

Comments
 (0)