diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d95cb8da..377c15b03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - #933, OpenAPI externals docs url to current version - @steve-chavez - #962, OpenAPI don't err on nonexistent schema - @steve-chavez - #954, make OpenAPI rpc output dependent on user privileges - @steve-chavez +- #955, Support configurable aud claim - @statik ## [0.4.3.0] - 2017-09-06 diff --git a/app.json b/app.json index bc4b29a1a..cbdfcb47e 100644 --- a/app.json +++ b/app.json @@ -43,6 +43,10 @@ "required": false, "value": "false" }, + "JWT_AUD": { + "description": "The audience that should be validated if the JWT token contains an aud claim", + "required": false + }, "MAX_ROWS": { "description": "A hard limit to the number of rows PostgREST will fetch from a view, table, or stored procedure", "required": false diff --git a/docker/Dockerfile b/docker/Dockerfile index 91bed99ac..2f93e7a1f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -33,6 +33,7 @@ ENV PGRST_DB_URI= \ PGRST_SERVER_PROXY_URI= \ PGRST_JWT_SECRET= \ PGRST_SECRET_IS_BASE64=false \ + PGRST_JWT_AUD= \ PGRST_MAX_ROWS= \ PGRST_PRE_REQUEST= diff --git a/docker/postgrest.conf b/docker/postgrest.conf index 094484745..9cf359040 100644 --- a/docker/postgrest.conf +++ b/docker/postgrest.conf @@ -9,6 +9,7 @@ server-port = "$(PGRST_SERVER_PORT)" server-proxy-uri = "$(PGRST_SERVER_PROXY_URI)" jwt-secret = "$(PGRST_JWT_SECRET)" secret-is-base64 = "$(PGRST_SECRET_IS_BASE64)" +jwt-aud = "$(PGRST_JWT_AUD)" max-rows = "$(PGRST_MAX_ROWS)" pre-request = "$(PGRST_PRE_REQUEST)" diff --git a/src/PostgREST/App.hs b/src/PostgREST/App.hs index 4b7b30467..622e3c9a5 100644 --- a/src/PostgREST/App.hs +++ b/src/PostgREST/App.hs @@ -76,7 +76,7 @@ postgrest conf refDbStructure pool worker = response <- case userApiRequest (configSchema conf) req body of Left err -> return $ apiRequestError err Right apiRequest -> do - eClaims <- jwtClaims jwtSecret (toS $ iJWT apiRequest) + eClaims <- jwtClaims jwtSecret (configJwtAudience conf) (toS $ iJWT apiRequest) let authed = containsRole eClaims handleReq = runWithClaims conf eClaims (app dbStructure conf) apiRequest diff --git a/src/PostgREST/Auth.hs b/src/PostgREST/Auth.hs index 1f2708e63..775ba38b6 100644 --- a/src/PostgREST/Auth.hs +++ b/src/PostgREST/Auth.hs @@ -39,16 +39,16 @@ data JWTAttempt = JWTInvalid JWTError deriving (Eq, Show) {-| - Receives the JWT secret (from config) and a JWT and returns a map + Receives the JWT secret and audience (from config) and a JWT and returns a map of JWT claims. -} -jwtClaims :: Maybe JWK -> BL.ByteString -> IO JWTAttempt -jwtClaims _ "" = return $ JWTClaims M.empty -jwtClaims secret payload = +jwtClaims :: Maybe JWK -> Text -> BL.ByteString -> IO JWTAttempt +jwtClaims _ "" "" = return $ JWTClaims M.empty +jwtClaims secret audience payload = case secret of Nothing -> return JWTMissingSecret Just jwk -> do - let validation = defaultJWTValidationSettings + let validation = set audiencePredicate (== fromString audience) defaultJWTValidationSettings eJwt <- runExceptT $ do jwt <- decodeCompact payload validateJWSJWT validation jwk jwt diff --git a/src/PostgREST/Config.hs b/src/PostgREST/Config.hs index e5d6cdcb0..b16393d2d 100644 --- a/src/PostgREST/Config.hs +++ b/src/PostgREST/Config.hs @@ -59,6 +59,7 @@ data AppConfig = AppConfig { , configJwtSecret :: Maybe B.ByteString , configJwtSecretIsBase64 :: Bool + , configJwtAudience :: Text , configPool :: Int , configMaxRows :: Maybe Integer @@ -117,6 +118,7 @@ readOptions = do <*> (fromMaybe 3000 . join . fmap coerceInt <$> C.key "server-port") <*> (fmap encodeUtf8 . mfilter (/= "") <$> C.key "jwt-secret") <*> (fromMaybe False . join . fmap coerceBool <$> C.key "secret-is-base64") + <*> (fromMaybe "" <$> C.key "jwt-aud") <*> (fromMaybe 10 . join . fmap coerceInt <$> C.key "db-pool") <*> (join . fmap coerceInt <$> C.key "max-rows") <*> (mfilter (/= "") <$> C.key "pre-request") @@ -177,6 +179,7 @@ readOptions = do |## (use "@filename" to load from separate file) |# jwt-secret = "foo" |# secret-is-base64 = false + |# jwt-aud = "your_audience_claim" | |## limit rows in response |# max-rows = 1000 diff --git a/test/Feature/AudienceJwtSecretSpec.hs b/test/Feature/AudienceJwtSecretSpec.hs new file mode 100644 index 000000000..843ecf855 --- /dev/null +++ b/test/Feature/AudienceJwtSecretSpec.hs @@ -0,0 +1,44 @@ +module Feature.AudienceJwtSecretSpec where + +-- {{{ Imports +import Test.Hspec +import Test.Hspec.Wai +import Network.HTTP.Types + +import SpecHelper +import Network.Wai (Application) + +import Protolude hiding (get) +-- }}} + +spec :: SpecWith Application +spec = describe "test handling of aud claims in JWT" $ do + + -- this test will stop working 9999999999s after the UNIX EPOCH + it "succeeds with jwt token containing with an audience claim" $ do + {- This is the decoded contents of authHeaderJWT + + { + "exp": 9999999999, + "role": "postgrest_test_author", + "id": "jdoe", + "aud": "youraudience" + } + + -} + let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UiLCJhdWQiOiJ5b3VyYXVkaWVuY2UifQ.fJ4tLKSmolWGWehWN20qiU9dMO-WY0RI2VvacL7-ZGo" + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` 200 + + it "succeeds with jwt token that does not contain an audience claim" $ do + {- This is the decoded contents of authHeaderJWT + + { + "exp": 9999999999, + "role": "postgrest_test_author", + "id": "jdoe" + } + -} + let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA" + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` 200 \ No newline at end of file diff --git a/test/Main.hs b/test/Main.hs index 643c7a5d3..be2911a97 100644 --- a/test/Main.hs +++ b/test/Main.hs @@ -13,6 +13,7 @@ import Data.IORef import qualified Feature.AuthSpec import qualified Feature.AsymmetricJwtSpec import qualified Feature.BinaryJwtSecretSpec +import qualified Feature.AudienceJwtSecretSpec import qualified Feature.ConcurrentSpec import qualified Feature.CorsSpec import qualified Feature.DeleteSpec @@ -40,14 +41,15 @@ main = do result <- P.use pool $ getDbStructure "test" refDbStructure <- newIORef $ Just $ either (panic.show) id result - let withApp = return $ postgrest (testCfg testDbConn) refDbStructure pool $ pure () - ltdApp = return $ postgrest (testLtdRowsCfg testDbConn) refDbStructure pool $ pure () - unicodeApp = return $ postgrest (testUnicodeCfg testDbConn) refDbStructure pool $ pure () - proxyApp = return $ postgrest (testProxyCfg testDbConn) refDbStructure pool $ pure () - noJwtApp = return $ postgrest (testCfgNoJWT testDbConn) refDbStructure pool $ pure () - binaryJwtApp = return $ postgrest (testCfgBinaryJWT testDbConn) refDbStructure pool $ pure () - asymJwkApp = return $ postgrest (testCfgAsymJWK testDbConn) refDbStructure pool $ pure () - nonexistentSchemaApp = return $ postgrest (testNonexistentSchemaCfg testDbConn) refDbStructure pool $ pure () + let withApp = return $ postgrest (testCfg testDbConn) refDbStructure pool $ pure () + ltdApp = return $ postgrest (testLtdRowsCfg testDbConn) refDbStructure pool $ pure () + unicodeApp = return $ postgrest (testUnicodeCfg testDbConn) refDbStructure pool $ pure () + proxyApp = return $ postgrest (testProxyCfg testDbConn) refDbStructure pool $ pure () + noJwtApp = return $ postgrest (testCfgNoJWT testDbConn) refDbStructure pool $ pure () + binaryJwtApp = return $ postgrest (testCfgBinaryJWT testDbConn) refDbStructure pool $ pure () + audJwtApp = return $ postgrest (testCfgAudienceJWT testDbConn) refDbStructure pool $ pure () + asymJwkApp = return $ postgrest (testCfgAsymJWK testDbConn) refDbStructure pool $ pure () + nonexistentSchemaApp = return $ postgrest (testNonexistentSchemaCfg testDbConn) refDbStructure pool $ pure () let reset = resetDb testDbConn hspec $ do @@ -73,6 +75,10 @@ main = do beforeAll_ reset . before binaryJwtApp $ describe "Feature.BinaryJwtSecretSpec" Feature.BinaryJwtSecretSpec.spec + -- this test runs with a binary JWT secret and an audience claim + beforeAll_ reset . before audJwtApp $ + describe "Feature.AudienceJwtSecretSpec" Feature.AudienceJwtSecretSpec.spec + -- this test runs with asymmetric JWK beforeAll_ reset . before asymJwkApp $ describe "Feature.AsymmetricJwtSpec" Feature.AsymmetricJwtSpec.spec diff --git a/test/SpecHelper.hs b/test/SpecHelper.hs index 40af84578..59d0f69c7 100644 --- a/test/SpecHelper.hs +++ b/test/SpecHelper.hs @@ -68,7 +68,7 @@ _baseCfg :: AppConfig _baseCfg = -- Connection Settings AppConfig mempty "postgrest_test_anonymous" Nothing "test" "localhost" 3000 -- Jwt settings - (Just $ encodeUtf8 "reallyreallyreallyreallyverysafe") False + (Just $ encodeUtf8 "reallyreallyreallyreallyverysafe") False "" -- Connection Modifiers 10 Nothing (Just "test.switch_role") -- Debug Settings @@ -95,6 +95,13 @@ testCfgBinaryJWT testDbConn = (testCfg testDbConn) { "cmVhbGx5cmVhbGx5cmVhbGx5cmVhbGx5dmVyeXNhZmU=" } +testCfgAudienceJWT :: Text -> AppConfig +testCfgAudienceJWT testDbConn = (testCfg testDbConn) { + configJwtSecret = Just . B64.decodeLenient $ + "cmVhbGx5cmVhbGx5cmVhbGx5cmVhbGx5dmVyeXNhZmU=", + configJwtAudience = "youraudience" + } + testCfgAsymJWK :: Text -> AppConfig testCfgAsymJWK testDbConn = (testCfg testDbConn) { configJwtSecret = Just $ encodeUtf8