Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ All notable changes to this project will be documented in this file. From versio
- Add config `client-error-verbosity` to customize error verbosity by @taimoorzaeem in #4088, #3980, #3824
- Add `Vary` header to responses by @develop7 in #4609
- Add config `db-timezone-enabled` for optional querying of timezones by @taimoorzaeem in #4751
- Add config `jwt-schema-claim-key` for schema selection in JWT by @taimoorzaeem in #4608

### Changed

Expand Down
34 changes: 24 additions & 10 deletions docs/references/auth.rst
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ JWT Authentication
------------------

We use `JSON Web Tokens <https://datatracker.ietf.org/doc/html/rfc7519/>`_ to authenticate API requests, this allows us to be stateless and not require database lookups for verification.
As you'll recall a JWT contains a list of cryptographically signed claims. All claims are allowed but PostgREST cares specifically about a claim called role (configurable with :ref:`jwt_role_extract`).
As you'll recall a JWT contains a list of cryptographically signed claims. All claims are allowed but PostgREST cares specifically about ``role`` and ``schema`` claim (configurable with :ref:`jwt_role_and_schema_extract`).

.. code:: json

Expand Down Expand Up @@ -219,12 +219,28 @@ It's recommended to leave the JWT cache enabled as our load tests indicate ~20%
- JWTs that pass :ref:`jwt_signature` are cached, regardless if they pass :ref:`jwt_claims_validation`. We do this to ensure responses stays fast under common failure cases (such as expired JWTs).
- You can use the :ref:`server-timing_header` to see the performance benefit of JWT caching.

.. _jwt_role_extract:
.. _jwt_role_and_schema_extract:

JWT Role Extraction
-------------------
JWT Role and Schema Extraction
------------------------------

A JSPath DSL that specifies the location of the :code:`role` key in the JWT claims. It's configured by :ref:`jwt-role-claim-key`. This can be used to consume a JWT provided by a third party service like Auth0, Okta, Microsoft Entra or Keycloak.
A JSPath DSL that specifies the location of the :code:`role` and :code:`schema` key in the JWT claims. It's configured by :ref:`jwt-role-claim-key` and :ref:`jwt-schema-claim-key` respectively. This can be used to consume a JWT provided by a third party service like Auth0, Okta, Microsoft Entra or Keycloak.

Usage examples:

.. code::

# {"postgrest":{"roles": ["other", "author"], "schema": ["v1", "test"]}}
# the DSL accepts characters that are alphanumerical or one of "_$@" as keys
jwt-role-claim-key = ".postgrest.roles[1]"
jwt-schema-claim-key = ".postgrest.schema[0]"

See :ref:`jspath_dsl_grammar` for more details on how to specify the location.

.. _jspath_dsl_grammar:

JSPath DSL Grammar
~~~~~~~~~~~~~~~~~~

The DSL follows the `JSONPath <https://goessner.net/articles/JsonPath/>`_ expression grammar with extended string comparison operators. Supported operators are:

Expand All @@ -234,7 +250,7 @@ The DSL follows the `JSONPath <https://goessner.net/articles/JsonPath/>`_ expres
- ``==^`` selects the first array element that ends with the right operand
- ``*==`` selects the first array element that contains the right operand

The selected role value can also be sliced using the slice operator ``[a:b]``. It is similar to `slice operator in python <https://docs.python.org/3/library/functions.html#slice>`_. Negative index values are also supported. The syntax is as:
The selected value can also be sliced using the slice operator ``[a:b]``. It is similar to `slice operator in python <https://docs.python.org/3/library/functions.html#slice>`_. Negative index values are also supported. The syntax is as:

- ``[a:b]`` take slice from index ``a`` up to ``b``
- ``[a:]`` take slice from index ``a`` to end
Expand All @@ -249,10 +265,6 @@ Usage examples:

.. code:: bash

# {"postgrest":{"roles": ["other", "author"]}}
# the DSL accepts characters that are alphanumerical or one of "_$@" as keys
jwt-role-claim-key = ".postgrest.roles[1]"

# {"https://www.example.com/role": { "key": "author" }}
# non-alphanumerical characters can go inside quotes(escaped in the config value)
jwt-role-claim-key = ".\"https://www.example.com/role\".key"
Expand All @@ -271,6 +283,8 @@ Usage examples:
jwt-role-claim-key = ".postgrest.wlcg[0][1:]"
jwt-role-claim-key = ".postgrest.wlcg[1][1:-1]"

These examples also apply to :ref:`jwt-schema-claim-key`.

.. note::

The string comparison operators are implemented as a custom extension to the JSPath and does not strictly follow the `RFC 9535 <https://www.rfc-editor.org/rfc/rfc9535.html>`_.
Expand Down
17 changes: 16 additions & 1 deletion docs/references/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -662,7 +662,22 @@ jwt-role-claim-key

*For backwards compatibility, this config parameter is also available without prefix as "role-claim-key".*

See :ref:`jwt_role_extract` on how to specify key paths and usage examples.
See :ref:`jwt_role_and_schema_extract` on how to specify key paths and usage examples.

.. _jwt-schema-claim-key:

jwt-schema-claim-key
--------------------

=============== =================================
**Type** String
**Default** .schema
**Reloadable** Y
**Environment** PGRST_JWT_SCHEMA_CLAIM_KEY
**In-Database** pgrst.jwt_schema_claim_key
=============== =================================

See :ref:`jwt_role_and_schema_extract` on how to specify key paths and usage examples.

.. _jwt-secret:

Expand Down
32 changes: 22 additions & 10 deletions src/PostgREST/ApiRequest.hs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import PostgREST.ApiRequest.Types (Action (..), DbAction (..),
InvokeMethod (..),
Mutation (..), Payload (..),
RequestBody, Resource (..))
import PostgREST.Auth.Types (AuthResult (..))
import PostgREST.Config (AppConfig (..),
OpenAPIMode (..))
import PostgREST.Config.Database (TimezoneNames)
Expand Down Expand Up @@ -76,10 +77,10 @@ data ApiRequest = ApiRequest {
}

-- | Examines HTTP request and translates it into user intent.
userApiRequest :: AppConfig -> Preferences.Preferences -> Request -> RequestBody -> Either ApiRequestError ApiRequest
userApiRequest conf prefs req reqBody = do
userApiRequest :: AppConfig -> Preferences.Preferences -> Request -> AuthResult -> RequestBody -> Either ApiRequestError ApiRequest
userApiRequest conf prefs req authResult reqBody = do
resource <- getResource conf $ pathInfo req
(schema, negotiatedByProfile) <- getSchema conf hdrs method
(schema, negotiatedByProfile) <- getSchema conf hdrs method authResult
act <- getAction resource schema method
qPrms <- first QueryParamError $ QueryParams.parse (actIsInvokeSafe act) $ rawQueryString req
(topLevelRange, ranges) <- getRanges method qPrms hdrs
Expand Down Expand Up @@ -151,14 +152,25 @@ getAction resource schema method =
where
qi = QualifiedIdentifier schema


getSchema :: AppConfig -> RequestHeaders -> ByteString -> Either ApiRequestError (Schema, Bool)
getSchema AppConfig{configDbSchemas} hdrs method = do
case profile of
Just p | p `notElem` configDbSchemas -> Left $ UnacceptableSchema p $ toList configDbSchemas
| otherwise -> Right (p, True)
Nothing -> Right (defaultSchema, length configDbSchemas /= 1) -- if we have many schemas, assume the default schema was negotiated
-- |
-- We get schema in the following order:
-- 1. Check JWT for a schema claim
-- 2. If no schema claim, then check "Accept-Profile" and "Content-Profile" headers
-- 3. If profile headers not sent, then default to first schema in "db-schemas"
getSchema :: AppConfig -> RequestHeaders -> ByteString -> AuthResult -> Either ApiRequestError (Schema, Bool)
getSchema AppConfig{configDbSchemas} hdrs method AuthResult{authSchema} = do
case authSchema of
Just s -> checkSchemaAcceptable (decodeUtf8 s) False
Nothing ->
case profile of
Just p -> checkSchemaAcceptable p True
Nothing -> Right (defaultSchema, length configDbSchemas /= 1) -- if we have many schemas, assume the default schema was negotiated
where
checkSchemaAcceptable :: Text -> Bool -> Either ApiRequestError (Schema,Bool)
checkSchemaAcceptable schema isNegotiated
| schema `notElem` configDbSchemas = Left $ UnacceptableSchema schema $ toList configDbSchemas
| otherwise = Right (schema, isNegotiated)

defaultSchema = NonEmptyList.head configDbSchemas
profile = case method of
-- POST/PATCH/PUT/DELETE don't use the same header as per the spec
Expand Down
2 changes: 1 addition & 1 deletion src/PostgREST/App.hs
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ postgrestResponse appState conf@AppConfig{..} maybeSchemaCache authResult@AuthRe
timezones = dbTimezones sCache
prefs = ApiRequest.userPreferences conf req timezones

(parseTime, apiReq@ApiRequest{..}) <- withTiming $ liftEither . mapLeft Error.ApiRequestErr $ ApiRequest.userApiRequest conf prefs req body
(parseTime, apiReq@ApiRequest{..}) <- withTiming $ liftEither . mapLeft Error.ApiRequestErr $ ApiRequest.userApiRequest conf prefs req authResult body
(planTime, plan) <- withTiming $ liftEither $ Plan.actionPlan iAction conf apiReq sCache

let mainQ = Query.mainQuery plan conf apiReq authResult configDbPreRequest
Expand Down
6 changes: 4 additions & 2 deletions src/PostgREST/Auth/Jwt.hs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ This module provides functions to deal with JWT parsing and validation (http://j
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE ImpredicativeTypes #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE QuantifiedConstraints #-}
{-# LANGUAGE RecordWildCards #-}

module PostgREST.Auth.Jwt
( parseAndDecodeClaims
Expand Down Expand Up @@ -110,14 +110,16 @@ parseToken secret tkn = do
jwtDecodeError _ = JwtDecodeErr UnreachableDecodeError

parseClaims :: (MonadError Error m, MonadIO m) => AppConfig -> UTCTime -> JSON.Object -> m AuthResult
parseClaims cfg@AppConfig{configJwtRoleClaimKey, configDbAnonRole} time mclaims = do
parseClaims cfg@AppConfig{..} time mclaims = do
validateClaims time (audMatchesCfg cfg) mclaims
-- role defaults to anon if not specified in jwt
role <- liftEither . maybeToRight (JwtErr JwtTokenRequired) $
unquoted <$> walkJSPath (Just $ JSON.Object mclaims) configJwtRoleClaimKey <|> configDbAnonRole
let schema = unquoted <$> walkJSPath (Just $ JSON.Object mclaims) configJwtSchemaClaimKey
pure AuthResult
{ authClaims = mclaims
, authRole = role
, authSchema = schema
}
where
unquoted :: JSON.Value -> BS.ByteString
Expand Down
3 changes: 3 additions & 0 deletions src/PostgREST/Auth/Types.hs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@ import qualified Data.Aeson as JSON
import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteString as BS

import Protolude

-- |
-- Parse and store result for JWT Claims. Can be accessed in
-- db through GUCs (for RLS etc)
data AuthResult = AuthResult
{ authClaims :: KM.KeyMap JSON.Value
, authRole :: BS.ByteString
, authSchema :: Maybe BS.ByteString
}
15 changes: 14 additions & 1 deletion src/PostgREST/Config.hs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ import PostgREST.Config.Database (RoleIsolationLvl,
RoleSettings)
import PostgREST.Config.JSPath (FilterExp (..), JSPath,
JSPathExp (..), dumpJSPath,
pRoleClaimKey)
pRoleClaimKey,
pSchemaClaimKey)
import PostgREST.Config.Proxy (Proxy (..),
isMalformedProxyUri, toURI)
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..),
Expand Down Expand Up @@ -102,6 +103,7 @@ data AppConfig = AppConfig
, configJWKS :: Maybe JwkSet
, configJwtAudience :: Maybe Text
, configJwtRoleClaimKey :: JSPath
, configJwtSchemaClaimKey :: JSPath
, configJwtSecret :: Maybe BS.ByteString
, configJwtSecretIsBase64 :: Bool
, configJwtCacheMaxEntries :: Int
Expand Down Expand Up @@ -187,6 +189,7 @@ toText conf =
,("db-uri", q . configDbUri)
,("jwt-aud", q . fromMaybe mempty . configJwtAudience)
,("jwt-role-claim-key", q . T.intercalate mempty . fmap dumpJSPath . configJwtRoleClaimKey)
,("jwt-schema-claim-key", q . T.intercalate mempty . fmap dumpJSPath . configJwtSchemaClaimKey)
,("jwt-secret", q . T.decodeUtf8 . showJwtSecret)
,("jwt-secret-is-base64", T.toLower . show . configJwtSecretIsBase64)
,("jwt-cache-max-entries", show . configJwtCacheMaxEntries)
Expand Down Expand Up @@ -300,6 +303,7 @@ parser optPath env dbSettings roleSettings roleIsolationLvl =
<*> pure Nothing
<*> optStringOrURI "jwt-aud"
<*> parseRoleClaimKey "jwt-role-claim-key" "role-claim-key"
<*> parseSchemaClaimKey "jwt-schema-claim-key"
<*> (fmap encodeUtf8 <$> optString "jwt-secret")
<*> (fromMaybe False <$> optWithAlias
(optBool "jwt-secret-is-base64")
Expand Down Expand Up @@ -421,6 +425,12 @@ parser optPath env dbSettings roleSettings roleIsolationLvl =
Nothing -> pure [JSPKey "role"]
Just rck -> either (fail . show) pure $ pRoleClaimKey rck

parseSchemaClaimKey :: C.Key -> C.Parser C.Config JSPath
parseSchemaClaimKey k =
optString k >>= \case
Nothing -> pure [JSPKey "schema"]
Just sck -> either (fail . show) pure $ pSchemaClaimKey sck

parseCORSAllowedOrigins k =
optString k >>= \case
Nothing -> pure Nothing
Expand Down Expand Up @@ -741,6 +751,9 @@ exampleConfigFile = S.unlines
, ""
, "## Jspath to the role claim key"
, "jwt-role-claim-key = \".role\""
, ""
, "## Jspath to the schema claim key"
, "jwt-schema-claim-key = \".schema\""
, ""
, "## Choose a secret, JSON Web Key (or set) to enable JWT auth"
, "## (use \"@filename\" to load from separate file)"
Expand Down
1 change: 1 addition & 0 deletions src/PostgREST/Config/Database.hs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ dbSettingsNames =
,"db_hoisted_tx_settings"
,"jwt_aud"
,"jwt_role_claim_key"
,"jwt_schema_claim_key"
,"jwt_secret"
,"jwt_secret_is_base64"
,"jwt_cache_max_lifetime"
Expand Down
6 changes: 6 additions & 0 deletions src/PostgREST/Config/JSPath.hs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ module PostgREST.Config.JSPath
, FilterExp(..)
, dumpJSPath
, pRoleClaimKey
, pSchemaClaimKey
, walkJSPath
) where

Expand Down Expand Up @@ -91,6 +92,11 @@ pRoleClaimKey :: Text -> Either Text JSPath
pRoleClaimKey selStr =
mapLeft show $ P.parse pJSPath ("failed to parse role-claim-key value (" <> toS selStr <> ")") (toS selStr)

-- Used for the config value "jwt-schema-claim-key"
pSchemaClaimKey :: Text -> Either Text JSPath
pSchemaClaimKey selStr =
mapLeft show $ P.parse pJSPath ("failed to parse jwt-schema-claim-key value (" <> toS selStr <> ")") (toS selStr)

pJSPath :: P.Parser JSPath
pJSPath = P.many1 pJSPathExp <* P.eof

Expand Down
1 change: 1 addition & 0 deletions test/io/configs/expected/aliases.config
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ db-tx-end = "commit"
db-uri = "postgresql://"
jwt-aud = ""
jwt-role-claim-key = ".\"aliased\""
jwt-schema-claim-key = ".\"schema\""
jwt-secret = ""
jwt-secret-is-base64 = true
jwt-cache-max-entries = 1000
Expand Down
1 change: 1 addition & 0 deletions test/io/configs/expected/boolean-numeric.config
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ db-tx-end = "commit"
db-uri = "postgresql://"
jwt-aud = ""
jwt-role-claim-key = ".\"role\""
jwt-schema-claim-key = ".\"schema\""
jwt-secret = ""
jwt-secret-is-base64 = true
jwt-cache-max-entries = 1000
Expand Down
1 change: 1 addition & 0 deletions test/io/configs/expected/boolean-string.config
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ db-tx-end = "commit"
db-uri = "postgresql://"
jwt-aud = ""
jwt-role-claim-key = ".\"role\""
jwt-schema-claim-key = ".\"schema\""
jwt-secret = ""
jwt-secret-is-base64 = true
jwt-cache-max-entries = 1000
Expand Down
1 change: 1 addition & 0 deletions test/io/configs/expected/defaults.config
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ db-tx-end = "commit"
db-uri = "postgresql://"
jwt-aud = ""
jwt-role-claim-key = ".\"role\""
jwt-schema-claim-key = ".\"schema\""
jwt-secret = ""
jwt-secret-is-base64 = false
jwt-cache-max-entries = 1000
Expand Down
1 change: 1 addition & 0 deletions test/io/configs/expected/jspath-str-op-dump1.config
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ db-tx-end = "commit"
db-uri = "postgresql://"
jwt-aud = ""
jwt-role-claim-key = ".\"roles\"[?(@ == \"role1\")]"
jwt-schema-claim-key = ".\"schema\""
jwt-secret = ""
jwt-secret-is-base64 = false
jwt-cache-max-entries = 1000
Expand Down
1 change: 1 addition & 0 deletions test/io/configs/expected/jspath-str-op-dump2.config
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ db-tx-end = "commit"
db-uri = "postgresql://"
jwt-aud = ""
jwt-role-claim-key = ".\"roles\"[?(@ != \"role1\")]"
jwt-schema-claim-key = ".\"schema\""
jwt-secret = ""
jwt-secret-is-base64 = false
jwt-cache-max-entries = 1000
Expand Down
1 change: 1 addition & 0 deletions test/io/configs/expected/jspath-str-op-dump3.config
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ db-tx-end = "commit"
db-uri = "postgresql://"
jwt-aud = ""
jwt-role-claim-key = ".\"roles\"[?(@ ^== \"role1\")]"
jwt-schema-claim-key = ".\"schema\""
jwt-secret = ""
jwt-secret-is-base64 = false
jwt-cache-max-entries = 1000
Expand Down
1 change: 1 addition & 0 deletions test/io/configs/expected/jspath-str-op-dump4.config
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ db-tx-end = "commit"
db-uri = "postgresql://"
jwt-aud = ""
jwt-role-claim-key = ".\"roles\"[?(@ ==^ \"role1\")]"
jwt-schema-claim-key = ".\"schema\""
jwt-secret = ""
jwt-secret-is-base64 = false
jwt-cache-max-entries = 1000
Expand Down
1 change: 1 addition & 0 deletions test/io/configs/expected/jspath-str-op-dump5.config
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ db-tx-end = "commit"
db-uri = "postgresql://"
jwt-aud = ""
jwt-role-claim-key = ".\"roles\"[?(@ *== \"role1\")]"
jwt-schema-claim-key = ".\"schema\""
jwt-secret = ""
jwt-secret-is-base64 = false
jwt-cache-max-entries = 1000
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ db-tx-end = "rollback-allow-override"
db-uri = "postgresql://"
jwt-aud = "https://otherexample.org"
jwt-role-claim-key = ".\"other\".\"pre_config_role\""
jwt-schema-claim-key = ".\"other\".\"pre_config_schema\""
jwt-secret = "ODERREALLYREALLYREALLYREALLYVERYSAFE"
jwt-secret-is-base64 = false
jwt-cache-max-entries = 86400
Expand Down
1 change: 1 addition & 0 deletions test/io/configs/expected/no-defaults-with-db.config
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ db-tx-end = "commit-allow-override"
db-uri = "postgresql://"
jwt-aud = "https://example.org"
jwt-role-claim-key = ".\"a\".\"role\""
jwt-schema-claim-key = ".\"a\".\"schema\""
jwt-secret = "OVERRIDE=REALLY=REALLY=REALLY=REALLY=VERY=SAFE"
jwt-secret-is-base64 = false
jwt-cache-max-entries = 86400
Expand Down
1 change: 1 addition & 0 deletions test/io/configs/expected/no-defaults.config
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ db-tx-end = "rollback-allow-override"
db-uri = "tmp_db"
jwt-aud = "https://postgrest.org"
jwt-role-claim-key = ".\"user\"[0].\"real-role\""
jwt-schema-claim-key = ".\"user\"[0].\"real-schema\""
jwt-secret = "c2VjdXJpdHl0aHJvdWdob2JzY3VyaXR5aW5iYXNlNjQ="
jwt-secret-is-base64 = true
jwt-cache-max-entries = 86400
Expand Down
1 change: 1 addition & 0 deletions test/io/configs/expected/types.config
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ db-tx-end = "commit"
db-uri = "postgresql://"
jwt-aud = ""
jwt-role-claim-key = ".\"role\""
jwt-schema-claim-key = ".\"schema\""
jwt-secret = ""
jwt-secret-is-base64 = false
jwt-cache-max-entries = 1000
Expand Down
Loading