diff --git a/README.md b/README.md index 358602d5..2eb9fb77 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,8 @@ See `local.env` for example values which are used for local development. - `SHARE_SERVER_PORT`: Which port the server should bind to on startup. This may differ from `SHARE_API_ORIGIN` if you're using a reverse proxy. - `SHARE_REDIS`: The URL of the redis server. - `SHARE_POSTGRES`: The URL of the postgres server. -- `SHARE_HMAC_KEY`: A secret key used for cryptographic signing. This should be at least 32 characters long. +- `SHARE_HMAC_KEY`: A secret key used for cryptographic signing of HashJWTs. This should be at least 32 characters long. +- `SHARE_EDDSA_KEY`: A secret key used for cryptographic signing of user sessions. This should be at least 32 characters long. - `SHARE_DEPLOYMENT`: The deployment environment. One of: `local`, `staging`, `prod`. - `SHARE_POSTGRES_CONN_TTL`: The maximum time a connection to the postgres server should be kept alive. - `SHARE_POSTGRES_CONN_MAX`: The maximum number of connections to the postgres server. diff --git a/app/Env.hs b/app/Env.hs index 240e1c2a..08ca8c3e 100644 --- a/app/Env.hs +++ b/app/Env.hs @@ -52,6 +52,7 @@ withEnv action = do githubClientID <- fromEnv "SHARE_GITHUB_CLIENTID" (pure . Right . Text.pack) githubClientSecret <- fromEnv "SHARE_GITHUB_CLIENT_SECRET" (pure . Right . Text.pack) hs256Key <- fromEnv "SHARE_HMAC_KEY" (pure . Right . BS.pack) + edDSAKey <- fromEnv "SHARE_EDDSA_KEY" (pure . Right . BS.pack) zendeskAPIUser <- fromEnv "SHARE_ZENDESK_API_USER" (pure . Right . BS.pack) zendeskAPIToken <- fromEnv "SHARE_ZENDESK_API_TOKEN" (pure . Right . BS.pack) let zendeskAuth = Servant.BasicAuthData zendeskAPIUser zendeskAPIToken @@ -88,7 +89,18 @@ withEnv action = do | otherwise = Nothing in r {Redis.connectTLSParams = tlsParams} let acceptedAudiences = Set.singleton apiOrigin - let jwtSettings = JWT.defaultJWTSettings hs256Key acceptedAudiences apiOrigin + let legacyKey = JWT.KeyDescription {JWT.key = hs256Key, JWT.alg = JWT.HS256} + let signingKey = JWT.KeyDescription {JWT.key = edDSAKey, JWT.alg = JWT.Ed25519} + hashJWTJWK <- case JWT.keyDescToJWK legacyKey of + Left err -> throwIO err + Right (_thumbprint, jwk) -> pure jwk + -- I explicitly add the legacy key to the validation keys, so that the thumbprinted + -- version of the key is used for validation, which is needed for HashJWTs which are signed + -- with a 'kid'. + let validationKeys = Set.fromList [legacyKey] + jwtSettings <- case JWT.defaultJWTSettings signingKey (Just legacyKey) validationKeys acceptedAudiences apiOrigin of + Left cryptoError -> throwIO cryptoError + Right settings -> pure settings let cookieSettings = Cookies.defaultCookieSettings Deployment.onLocal (Just (realToFrac cookieSessionTTL)) let sessionCookieKey = tShow Deployment.deployment <> "-share-session" redisConnection <- Redis.checkedConnect redisConfig diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 213ed5b5..62f743a2 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -52,7 +52,8 @@ services: - SHARE_SERVER_PORT=5424 - SHARE_REDIS=redis://redis:6379 - SHARE_POSTGRES=postgresql://postgres:sekrit@postgres:5432 - - SHARE_HMAC_KEY=test-key-test-key-test-key-test-key- + - SHARE_HMAC_KEY=hmac-key-test-key-test-key-test- + - SHARE_EDDSA_KEY=eddsa-key-test-key-test-key-test - SHARE_DEPLOYMENT=local - SHARE_POSTGRES_CONN_TTL=30 - SHARE_POSTGRES_CONN_MAX=10 diff --git a/local.env b/local.env index cf2b2ad6..5f67ce62 100644 --- a/local.env +++ b/local.env @@ -9,7 +9,8 @@ export SHARE_REDIS=redis://localhost:6379 export SHARE_POSTGRES=postgresql://postgres:sekrit@localhost:5432 export SHARE_POSTGRES_CONN_TTL=30 export SHARE_POSTGRES_CONN_MAX=10 -export SHARE_HMAC_KEY="test-key-test-key-test-key-test-key-" +export SHARE_HMAC_KEY="hmac-key-test-key-test-key-test-" +export SHARE_EDDSA_KEY="eddsa-key-test-key-test-key-test" export SHARE_SHARE_UI_ORIGIN="http://localhost:1234" export SHARE_CLOUD_UI_ORIGIN="http://localhost:5678" export SHARE_HOMEPAGE_ORIGIN="http://localhost:1111" diff --git a/share-auth/example/src/Lib.hs b/share-auth/example/src/Lib.hs index 11fdd585..79e7706f 100644 --- a/share-auth/example/src/Lib.hs +++ b/share-auth/example/src/Lib.hs @@ -8,6 +8,10 @@ import Data.Set qualified as Set import Data.Text (Text) import Data.Time (DiffTime) import Database.Redis qualified as R +import GHC.Stack (HasCallStack) +import Network.URI qualified as URI +import Network.Wai.Handler.Warp qualified as Warp +import Servant import Share.JWT qualified as JWT import Share.OAuth.API (ServiceProviderAPI) import Share.OAuth.IdentityProvider.Share qualified as Share @@ -18,10 +22,6 @@ import Share.OAuth.ServiceProvider qualified as Auth import Share.OAuth.Session (AuthCheckCtx, AuthenticatedUserId, MaybeAuthenticatedUserId, addAuthCheckCtx) import Share.OAuth.Types (OAuthClientId (..), OAuthClientSecret (OAuthClientSecret), RedirectReceiverErr, UserId) import Share.Utils.Servant.Cookies qualified as Cookies -import GHC.Stack (HasCallStack) -import Network.URI qualified as URI -import Network.Wai.Handler.Warp qualified as Warp -import Servant import UnliftIO -- | An example application endpoint which is optionally authenticated. @@ -44,7 +44,7 @@ type MyAPI = :<|> "error" :> ErrorEndpoint -- | A handler which checks if the user is authenticated. -mayAuthedEndpoint :: MonadIO m => Maybe UserId -> m String +mayAuthedEndpoint :: (MonadIO m) => Maybe UserId -> m String mayAuthedEndpoint mayCallerUserId = do case mayCallerUserId of Nothing -> pure "no user" @@ -52,22 +52,22 @@ mayAuthedEndpoint mayCallerUserId = do pure $ "Hello, " <> show userId -- | A handler which requires an authenticated user. -authedEndpoint :: MonadIO m => UserId -> m String +authedEndpoint :: (MonadIO m) => UserId -> m String authedEndpoint callerUserId = do pure $ "Hello, " <> show callerUserId -- | A handler which displays errors from the OAuth2 flow. -errorEndpoint :: Applicative m => Maybe String -> m String +errorEndpoint :: (Applicative m) => Maybe String -> m String errorEndpoint err = do pure $ fromMaybe "no error" err -- | A helper function for constructing URIs from constant strings. -unsafeURI :: HasCallStack => String -> URI.URI +unsafeURI :: (HasCallStack) => String -> URI.URI unsafeURI = fromJust . URI.parseURI -- | A session callback which redirects the user to either an error page -- or the authed handler endpoint depending on whether the oauth2 login succeeds. -mySessionCallback :: Applicative m => Either RedirectReceiverErr SessionCallbackData -> m URI +mySessionCallback :: (Applicative m) => Either RedirectReceiverErr SessionCallbackData -> m URI mySessionCallback (Left err) = pure . fromJust . URI.parseURI $ "http://cloud:3030/error?error=" <> show err mySessionCallback (Right _session) = pure $ unsafeURI "http://cloud:3030/authed" @@ -78,7 +78,12 @@ main = do redisConn <- R.checkedConnect R.defaultConnectInfo putStrLn "booting up" - Warp.run 3030 $ serveWithContext (Proxy @MyAPI) ctx (myServer redisConn) + jwtSettings <- case JWT.defaultJWTSettings signingKey (Just legacyKey) rotatedKeys acceptedAudiences issuer of + Left cryptoError -> throwIO cryptoError + Right jwtS -> do + pure jwtS + + Warp.run 3030 $ serveWithContext (Proxy @MyAPI) (ctx jwtSettings) (myServer redisConn jwtSettings) putStrLn "exiting" pure () where @@ -87,18 +92,18 @@ main = do apiProxy :: Proxy MyAPI apiProxy = Proxy -- The api context required by servant-auth - appCtx :: (Context '[Cookies.CookieSettings, JWT.JWTSettings]) - appCtx = cookieSettings :. jwtSettings :. EmptyContext + appCtx :: JWT.JWTSettings -> (Context '[Cookies.CookieSettings, JWT.JWTSettings]) + appCtx jwtSettings = cookieSettings :. jwtSettings :. EmptyContext sessionCookieKey :: Text sessionCookieKey = "session" - ctx :: Context (AuthCheckCtx .++ '[Cookies.CookieSettings, JWT.JWTSettings]) - ctx = addAuthCheckCtx cookieSettings jwtSettings "session" appCtx - serviceProviderEndpoints :: ServerT ServiceProviderAPI R.Redis - serviceProviderEndpoints = Auth.serviceProviderServer Share.localShareIdentityProvider spConfig mySessionCallback - myServer :: R.Connection -> Server MyAPI - myServer conn = + ctx :: JWT.JWTSettings -> Context (AuthCheckCtx .++ '[Cookies.CookieSettings, JWT.JWTSettings]) + ctx jwtSettings = addAuthCheckCtx cookieSettings jwtSettings "session" (appCtx jwtSettings) + serviceProviderEndpoints :: JWT.JWTSettings -> ServerT ServiceProviderAPI R.Redis + serviceProviderEndpoints jwtSettings = Auth.serviceProviderServer Share.localShareIdentityProvider (spConfig jwtSettings) mySessionCallback + myServer :: R.Connection -> JWT.JWTSettings -> Server MyAPI + myServer conn jwtSettings = Servant.hoistServerWithContext apiProxy ctxProxy (unRedis conn) $ - serviceProviderEndpoints + serviceProviderEndpoints jwtSettings :<|> mayAuthedEndpoint :<|> authedEndpoint :<|> errorEndpoint @@ -108,10 +113,8 @@ main = do cookieDefaultTTL = Just $ 60 * 60 * 24 * 7 -- 1 week cookieSettings :: Cookies.CookieSettings cookieSettings = Cookies.defaultCookieSettings onLocal cookieDefaultTTL - jwtSettings :: JWT.JWTSettings - jwtSettings = JWT.defaultJWTSettings hs256Key acceptedAudiences issuer - spConfig :: ServiceProviderConfig - spConfig = + spConfig :: JWT.JWTSettings -> ServiceProviderConfig + spConfig jwtSettings = ServiceProviderConfig { cookieSettings, jwtSettings = jwtSettings, @@ -124,7 +127,13 @@ main = do sessionCookieKey } onLocal = True - hs256Key = "gpeakbroleymbscyqzrcalpemrjayhur" + -- Ensure you use cryptographically secure 32-byte keys in production use. + -- And don't re-use keys. + hs256Key = "example-32-byte-hs256Key-jayhuxr" + edDSAKey = "example-32-byte-edDSAKey-dxencne" + legacyKey = JWT.KeyDescription {JWT.key = hs256Key, JWT.alg = JWT.HS256} + signingKey = JWT.KeyDescription {JWT.key = edDSAKey, JWT.alg = JWT.Ed25519} + rotatedKeys = Set.empty api = unsafeURI "http://cloud:3030" serviceAudience = api acceptedAudiences = Set.singleton serviceAudience diff --git a/share-auth/package.yaml b/share-auth/package.yaml index 9456a97f..128d1858 100644 --- a/share-auth/package.yaml +++ b/share-auth/package.yaml @@ -63,13 +63,14 @@ dependencies: - mtl - transformers - aeson +- base64-bytestring - binary - binary-instances - bytestring - case-insensitive - containers - cookie -- cryptonite +- crypton - share-utils - hasql - hasql-interpolate diff --git a/share-auth/share-auth.cabal b/share-auth/share-auth.cabal index 41767386..782e73de 100644 --- a/share-auth/share-auth.cabal +++ b/share-auth/share-auth.cabal @@ -1,6 +1,6 @@ cabal-version: 1.12 --- This file has been generated from package.yaml by hpack version 0.36.0. +-- This file has been generated from package.yaml by hpack version 0.37.0. -- -- see: https://github.com/sol/hpack @@ -73,13 +73,14 @@ library MonadRandom , aeson , base >=4.7 && <5 + , base64-bytestring , binary , binary-instances , bytestring , case-insensitive , containers , cookie - , cryptonite + , crypton , hasql , hasql-interpolate , hedis diff --git a/share-auth/src/Share/JWT.hs b/share-auth/src/Share/JWT.hs index 8d917ebc..18b094fc 100644 --- a/share-auth/src/Share/JWT.hs +++ b/share-auth/src/Share/JWT.hs @@ -1,35 +1,56 @@ {-# LANGUAGE RecordWildCards #-} +-- | This module provides helpers for working with JSON Web Tokens (JWTs). module Share.JWT - ( JWTSettings (..), + ( JWTSettings, defaultJWTSettings, + SupportedAlg (..), + KeyDescription (..), JWTParam (..), ServantAuth.ToJWT (..), ServantAuth.FromJWT (..), signJWT, + signJWTWithJWK, verifyJWT, + keyDescToJWK, -- * Additional Helpers textToSignedJWT, signedJWTToText, createSignedCookie, + publicJWKSet, + + -- * Re-exports + CryptoError (..), ) where +import Control.Applicative (empty) import Control.Lens +import Control.Monad (guard) import Control.Monad.Except +import Crypto.Error (CryptoError (..), CryptoFailable (..)) import Crypto.JOSE qualified as Jose +import Crypto.JOSE.JWA.JWS qualified as JWS import Crypto.JOSE.JWK qualified as JWK import Crypto.JWT qualified as CryptoJWT import Crypto.JWT qualified as JWT +import Crypto.PubKey.Ed25519 qualified as Ed25519 import Data.Aeson (FromJSON (..), ToJSON (..)) import Data.Aeson qualified as Aeson import Data.Binary +import Data.ByteArray qualified as ByteArray import Data.ByteString qualified as BS +import Data.ByteString.Base64.URL qualified as Base64URL +import Data.List qualified as List +import Data.Map (Map) +import Data.Map qualified as Map +import Data.Maybe (maybeToList) import Data.Set (Set) import Data.Set qualified as Set import Data.Text (Text) import Data.Text qualified as Text +import Data.Text.Encoding qualified as Text import Data.Text.Lazy qualified as TL import Data.Text.Lazy.Encoding qualified as TL import Data.Typeable (Typeable, typeRep) @@ -43,9 +64,9 @@ import UnliftIO (MonadIO (..)) -- | @JWTSettings@ are used to generate and verify JWTs. data JWTSettings = JWTSettings { -- | Key used to sign JWT. - jwk :: Jose.JWK, + signingJWK :: Jose.JWK, -- | Keys used to validate JWT. - validationKeys :: IO Jose.JWKSet, + validationKeys :: KeyMap, -- | An @aud@ predicate. The @aud@ is a string or URI that identifies the -- intended recipient of the JWT. audienceMatches :: JWT.StringOrURI -> Bool, @@ -55,27 +76,116 @@ data JWTSettings = JWTSettings } deriving (Show) via Censored JWTSettings --- | Create a 'JWTSettings' using the given secret key and accepted audiences. +-- | Get the JWK Set value which is safe to expose to the public, e.g. in a JWKS endpoint. +-- This will only include public keys. +-- +-- Note that this will not include the legacy key or any HS256 keys, since those don't have a +-- safe public component. +publicJWKSet :: JWTSettings -> JWK.JWKSet +publicJWKSet JWTSettings {validationKeys = KeyMap {byKeyId}} = + JWK.JWKSet + ( byKeyId + & foldMap (\jwk -> jwk ^.. JWK.asPublicKey . _Just) + ) + +data SupportedAlg = HS256 | Ed25519 + deriving (Eq, Ord) + +data KeyDescription = KeyDescription {alg :: SupportedAlg, key :: BS.ByteString} + deriving (Eq, Ord) + +newtype KeyThumbprint = KeyThumbprint Text + deriving newtype (Eq, Ord) + +data KeyMap = KeyMap + { byKeyId :: (Map KeyThumbprint JWT.JWK), + -- | The key from before the introduction of key ids. + -- This will be used to verify legacy tokens, but is also used to sign HashJWTs on share. + legacyKey :: Maybe JWT.JWK + } + deriving (Show) via (Censored KeyMap) + +-- | This instance is used to look up the verification keys for a given JWT, assuring that the +-- expected algorithm and key id matches. +instance (Applicative m) => JWT.VerificationKeyStore m (JWT.JWSHeader ()) JWT.ClaimsSet KeyMap where + getVerificationKeys header _claims (KeyMap km legacyKey) = + case (header ^? JWT.kid . _Just . JWT.param) of + Nothing -> pure $ maybeToList legacyKey + Just jwtKid -> pure . maybe [] List.singleton $ do + let jwtAlg = header ^. JWT.alg . JWT.param + case Map.lookup (KeyThumbprint jwtKid) km of + Just key | Just (JWT.JWSAlg keyAlg) <- key ^. JWT.jwkAlg -> do + guard (keyAlg == jwtAlg) + pure key + _ -> empty + +-- | Create a 'JWTSettings' using the required information. defaultJWTSettings :: - -- | The secret key used to sign and verify JWTs. - BS.ByteString -> - -- | The audiences which represent acceptable audiences on tokens. + -- | The key used to sign JWTs. + KeyDescription -> + -- | The legacy key used to verify old JWTs from before key IDs were used. This will be used to verify tokens that don't have a key id. + Maybe KeyDescription -> + -- | Any old keys which we still want to accept tokens from. This is useful for key rotation. + Set KeyDescription -> + -- | The audiences which represent acceptable audiences on tokens for this service. + -- Tokens must have an audience which is present in this set. + -- -- E.g. https://api.unison.cloud Set URI -> - -- | Issuers for tokens. + -- | The token issuer. + -- + -- E.g. https://api.unison-lang.org URI -> - JWTSettings -defaultJWTSettings hs256Key acceptedAudiences issuer = - JWTSettings - { jwk, - validationKeys = pure $ Jose.JWKSet [jwk], - audienceMatches = \s -> - (review JWT.stringOrUri s) `Set.member` (Set.map (show @URI) acceptedAudiences), - acceptedAudiences, - issuer - } + Either CryptoError JWTSettings +defaultJWTSettings signingKey legacyKey oldValidKeys acceptedAudiences issuer = do + sjwk@(_, signingJWK) <- keyDescToJWK signingKey + verificationJWKs <- (sjwk :) <$> traverse keyDescToJWK (Set.toList oldValidKeys) + let byKeyId = Map.fromList verificationJWKs + legacyKey <- traverse keyDescToJWK legacyKey <&> fmap snd + pure $ + JWTSettings + { signingJWK, + validationKeys = KeyMap {byKeyId, legacyKey}, + audienceMatches = \s -> + (review JWT.stringOrUri s) `Set.member` (Set.map (show @URI) acceptedAudiences), + acceptedAudiences, + issuer + } + +-- | Converts a 'KeyDescription' to a 'JWK' and a 'KeyThumbprint'. +keyDescToJWK :: KeyDescription -> Either CryptoError (KeyThumbprint, JWK.JWK) +keyDescToJWK (KeyDescription {key, alg}) = cryptoFailableToEither $ do + case alg of + HS256 -> do + let jwk = + JWK.fromOctets key + & JWK.jwkUse .~ Just JWK.Sig + & JWK.jwkAlg .~ Just (JWK.JWSAlg JWS.HS256) + let thumbprint = jwkThumbprint jwk + pure (KeyThumbprint thumbprint, jwk & JWK.jwkKid .~ Just thumbprint) + Ed25519 -> do + privKey <- Ed25519.secretKey key + let pubKey = Ed25519.toPublic privKey + let jwk = + (JWT.Ed25519Key pubKey (Just privKey)) + & JWT.OKPKeyMaterial + & JWK.fromKeyMaterial + & JWK.jwkUse .~ Just JWK.Sig + & JWK.jwkAlg .~ Just (JWK.JWSAlg JWS.EdDSA) + let thumbprint = jwkThumbprint jwk + pure (KeyThumbprint thumbprint, jwk & JWK.jwkKid .~ Just thumbprint) where - jwk = JWK.fromOctets hs256Key + cryptoFailableToEither :: CryptoFailable a -> Either CryptoError a + cryptoFailableToEither (CryptoFailed err) = Left err + cryptoFailableToEither (CryptoPassed a) = Right a + + jwkThumbprint :: JWK.JWK -> Text + jwkThumbprint jwk = + jwk ^. JWK.thumbprint @JWK.SHA256 + & ByteArray.unpack + & BS.pack + & Base64URL.encodeUnpadded + & Text.decodeUtf8 newtype JWTParam = JWTParam JWT.SignedJWT deriving (Show) via (Censored JWTParam) @@ -110,20 +220,20 @@ textToSignedJWT :: Text -> Either JWT.JWTError JWT.SignedJWT textToSignedJWT jwtText = JWT.decodeCompact (TL.encodeUtf8 . TL.fromStrict $ jwtText) -- | Signs and encodes a JWT using the given 'JWTSettings'. -signJWT :: (MonadIO m, ServantAuth.ToJWT v) => JWTSettings -> v -> m (Either JWT.JWTError JWT.SignedJWT) -signJWT JWTSettings {jwk} v = do - let claimsSet = ServantAuth.encodeJWT v - liftIO $ runExceptT (JWT.signClaims jwk jwtHeader claimsSet) - -jwtHeader :: JWT.JWSHeader () -jwtHeader = JWT.newJWSHeader ((), jwtAlgorithm) +signJWT :: forall m v. (MonadIO m, ServantAuth.ToJWT v) => JWTSettings -> v -> m (Either JWT.JWTError JWT.SignedJWT) +signJWT JWTSettings {signingJWK} v = signJWTWithJWK signingJWK v --- | We currently only support hs256 JWTs, we can easily change this later if needed, but --- be wary of algorithm subtitution attacks: https://datatracker.ietf.org/doc/html/rfc7515#section-10.7 -jwtAlgorithm :: JWT.Alg -jwtAlgorithm = JWT.HS256 +-- | Signs and encodes a JWT using the given JWK, you should typically use 'signJWT' instead +-- unless you have a specific reason to use a different JWK. +signJWTWithJWK :: forall m v. (MonadIO m, ServantAuth.ToJWT v) => JWK.JWK -> v -> m (Either JWT.JWTError JWT.SignedJWT) +signJWTWithJWK jwk v = runExceptT $ do + let claimsSet = ServantAuth.encodeJWT v + jwtHeader <- mapExceptT liftIO (JWT.makeJWSHeader jwk) + mapExceptT liftIO (JWT.signClaims jwk jwtHeader claimsSet) -- | Decodes a JWT and verifies the following: +-- * algorithm (except for legacy tokens) +-- * key id (except for legacy tokens) -- * issuer -- * audience -- * expiry @@ -131,8 +241,8 @@ jwtAlgorithm = JWT.HS256 -- -- Any other checks should be performed on the returned claims. verifyJWT :: forall claims m. (Typeable claims, MonadIO m, ServantAuth.FromJWT claims) => JWTSettings -> JWT.SignedJWT -> m (Either JWT.JWTError claims) -verifyJWT JWTSettings {jwk, issuer, acceptedAudiences} signedJWT = do - result :: Either JWT.JWTError JWT.ClaimsSet <- liftIO . runExceptT $ JWT.verifyClaims validators jwk signedJWT +verifyJWT JWTSettings {validationKeys, issuer, acceptedAudiences} signedJWT = do + result :: Either JWT.JWTError JWT.ClaimsSet <- liftIO . runExceptT $ JWT.verifyClaims validators validationKeys signedJWT pure $ do claimsSet <- result case ServantAuth.decodeJWT claimsSet of @@ -149,8 +259,8 @@ verifyJWT JWTSettings {jwk, issuer, acceptedAudiences} signedJWT = do & CryptoJWT.issuerPredicate .~ (== review CryptoJWT.uri issuer) & CryptoJWT.validationSettings .~ ( CryptoJWT.defaultValidationSettings - -- Hard-coding the algorithm prevents algorithm substitution attacks. - & CryptoJWT.validationSettingsAlgorithms .~ Set.singleton jwtAlgorithm + -- Limiting the algorithms to ones we use helps limit algorithm substitution attacks. + & CryptoJWT.validationSettingsAlgorithms .~ Set.fromList [JWS.HS256, JWS.EdDSA] ) -- | Create a signed session cookie using a ToJWT instance. diff --git a/share-auth/src/Share/OAuth/ServiceProvider.hs b/share-auth/src/Share/OAuth/ServiceProvider.hs index 94ab6846..13e5fe89 100644 --- a/share-auth/src/Share/OAuth/ServiceProvider.hs +++ b/share-auth/src/Share/OAuth/ServiceProvider.hs @@ -9,7 +9,7 @@ module Share.OAuth.ServiceProvider serviceProviderServer, Cookies.defaultCookieSettings, JWT.defaultJWTSettings, - JWT.JWTSettings (..), + JWT.JWTSettings, verifySessionToken, -- Endpoints loginEndpoint, diff --git a/src/Share/Env.hs b/src/Share/Env.hs index e201b21b..41336e1f 100644 --- a/src/Share/Env.hs +++ b/src/Share/Env.hs @@ -3,6 +3,7 @@ module Share.Env ) where +import Crypto.JOSE.JWK qualified as JWK import Database.Redis qualified as R import Hasql.Pool qualified as Hasql import Network.URI (URI) @@ -34,6 +35,7 @@ data Env ctx = Env githubClientID :: Text, githubClientSecret :: Text, jwtSettings :: JWT.JWTSettings, + hashJWTJWK :: JWK.JWK, cookieSettings :: Cookies.CookieSettings, sessionCookieKey :: Text, sandboxedRuntime :: Runtime Symbol, diff --git a/src/Share/Web/API.hs b/src/Share/Web/API.hs index 58a666ec..98704c82 100644 --- a/src/Share/Web/API.hs +++ b/src/Share/Web/API.hs @@ -3,6 +3,7 @@ module Share.Web.API where +import Crypto.JOSE.JWK qualified as JWK import Servant import Share.OAuth.API qualified as OAuth import Share.OAuth.Session (MaybeAuthenticatedSession, MaybeAuthenticatedUserId) @@ -27,8 +28,15 @@ type API = :<|> ("search-definitions" :> Share.SearchDefinitionsEndpoint) :<|> ("account" :> Share.AccountAPI) :<|> ("catalog" :> Projects.CatalogAPI) - -- This path is part of the standard: https://datatracker.ietf.org/doc/html/rfc5785 - :<|> (".well-known" :> "openid-configuration" :> DiscoveryEndpoint) + :<|> ( ".well-known" + :> ( + -- This path is part of the standard: https://datatracker.ietf.org/doc/html/rfc5785 + ("openid-configuration" :> DiscoveryEndpoint) + -- This path is convention, the location is provided explicitly as part of + -- the discovery document's jwks_uri field. + :<|> ("jwks.json" :> JWKSEndpoint) + ) + ) :<|> ("user-info" :> UserInfoEndpoint) :<|> ("support" :> Support.API) :<|> ("local" :> Local.API) @@ -47,6 +55,9 @@ api = Proxy @API type DiscoveryEndpoint = Get '[JSON] DiscoveryDocument +type JWKSEndpoint = + Get '[JSON] JWK.JWKSet + type UserInfoEndpoint = MaybeAuthenticatedSession :> Get '[JSON] UserInfo diff --git a/src/Share/Web/Authentication/HashJWT.hs b/src/Share/Web/Authentication/HashJWT.hs index d7768374..5a5d36f1 100644 --- a/src/Share/Web/Authentication/HashJWT.hs +++ b/src/Share/Web/Authentication/HashJWT.hs @@ -10,7 +10,7 @@ import Unison.Share.API.Hash (HashJWTClaims) signHashJWT :: HashJWTClaims -> WebApp SignedJWT signHashJWT claims = do - jSettings <- asks Env.jwtSettings - JWT.signJWT jSettings claims >>= \case + hashJWTJWK <- asks Env.hashJWTJWK + JWT.signJWTWithJWK hashJWTJWK claims >>= \case Left err -> respondError (InternalServerError "jwt:signing-error" err) Right a -> pure a diff --git a/src/Share/Web/Impl.hs b/src/Share/Web/Impl.hs index 04b576f3..cd4575f0 100644 --- a/src/Share/Web/Impl.hs +++ b/src/Share/Web/Impl.hs @@ -2,10 +2,13 @@ module Share.Web.Impl (server) where +import Crypto.JOSE.JWK qualified as JWK import Data.Set qualified as Set import Servant import Share.App +import Share.Env qualified as Env import Share.IDs qualified as IDs +import Share.JWT qualified as JWT import Share.OAuth.Session import Share.OAuth.Types (ResponseType (ResponseTypeCode)) import Share.Postgres.Ops qualified as PGO @@ -32,9 +35,15 @@ discoveryEndpoint = do authorizationE <- URIParam <$> sharePath ["oauth", "authorize"] tokenE <- URIParam <$> sharePath ["oauth", "token"] userInfoE <- URIParam <$> sharePath ["user-info"] + jwksURI <- URIParam <$> sharePath [".well-known", "jwks.json"] let responseTypesSupported = Set.singleton ResponseTypeCode pure $ DiscoveryDocument {..} +-- | JWK RFC: https://tools.ietf.org/html/rfc7517 +jwksEndpoint :: WebApp JWK.JWKSet +jwksEndpoint = do + asks (JWT.publicJWKSet . Env.jwtSettings) + -- | https://openid.net/specs/openid-connect-core-1_0.html#UserInfo userInfoEndpoint :: Maybe Session -> WebApp UserInfo userInfoEndpoint sess = do @@ -65,7 +74,9 @@ server = :<|> Share.searchDefinitionsEndpoint :<|> Share.accountServer :<|> Projects.catalogServer - :<|> discoveryEndpoint + :<|> ( discoveryEndpoint + :<|> jwksEndpoint + ) :<|> userInfoEndpoint :<|> Support.server :<|> Local.server diff --git a/src/Share/Web/Types.hs b/src/Share/Web/Types.hs index cd8a0f75..5bd2641b 100644 --- a/src/Share/Web/Types.hs +++ b/src/Share/Web/Types.hs @@ -17,18 +17,18 @@ data DiscoveryDocument = DiscoveryDocument authorizationE :: URIParam, tokenE :: URIParam, userInfoE :: URIParam, - -- We should probably support this eventually - -- jwksURI :: URIParam + jwksURI :: URIParam, responseTypesSupported :: Set ResponseType } instance ToJSON DiscoveryDocument where - toJSON (DiscoveryDocument issuer authE tokenE userInfoE responseTypesSupported) = + toJSON (DiscoveryDocument issuer authE tokenE userInfoE jwksURI responseTypesSupported) = Aeson.object [ "issuer" .= issuer, "authorization_endpoint" .= authE, "token_endpoint" .= tokenE, "userinfo_endpoint" .= userInfoE, + "jwks_uri" .= jwksURI, "response_types_supported" .= responseTypesSupported ] diff --git a/transcripts/share-apis/releases/project-release-ucm-create.json b/transcripts/share-apis/releases/project-release-ucm-create.json index ceb5f9e0..f45d105e 100644 --- a/transcripts/share-apis/releases/project-release-ucm-create.json +++ b/transcripts/share-apis/releases/project-release-ucm-create.json @@ -1,12 +1,12 @@ { "body": { "payload": { - "branch-head": "eyJhbGciOiJIUzI1NiJ9.eyJoIjoic2c2MGJ2am85MWZzb283cGtoOWdlamJuMHFnYzk1dnJhODdhcDZsNWQzNXJpMGxrYXVkbDdiczEyZDcxc2YzZmg2cDIzdGVlbXVvcjdtazFpOW41NjdtNTBpYmFrY2doamVjNWFqZyIsInQiOiJoaiIsInUiOiJVLWQzMmY0ZGRmLTI0MjMtNGYxMC1hNGRlLTQ2NTkzOTk1MTM1NCJ9.Cy7vswarIBxyh-HzT7bckfZu6CVzTCm2F3kMLBiHf0s", + "branch-head": "eyJhbGciOiJIUzI1NiIsImtpZCI6IkxCVkJwSlZBZ2hJVHctbll1VlMxZUw3RjFyaWp5VXNqcV9Xd2QzNWJkYXcifQ.eyJoIjoic2c2MGJ2am85MWZzb283cGtoOWdlamJuMHFnYzk1dnJhODdhcDZsNWQzNXJpMGxrYXVkbDdiczEyZDcxc2YzZmg2cDIzdGVlbXVvcjdtazFpOW41NjdtNTBpYmFrY2doamVjNWFqZyIsInQiOiJoaiIsInUiOiJVLWQzMmY0ZGRmLTI0MjMtNGYxMC1hNGRlLTQ2NTkzOTk1MTM1NCJ9.X-GqvqfKSn4im2wDpQq3Zj3Rli4ge4SmmPuNgtvZL0A", "branch-id": "R-", "branch-name": "releases/4.5.6", "project-id": "P-", "project-name": "@test/publictestproject", - "squashed-branch-head": "eyJhbGciOiJIUzI1NiJ9.eyJoIjoic2c2MGJ2am85MWZzb283cGtoOWdlamJuMHFnYzk1dnJhODdhcDZsNWQzNXJpMGxrYXVkbDdiczEyZDcxc2YzZmg2cDIzdGVlbXVvcjdtazFpOW41NjdtNTBpYmFrY2doamVjNWFqZyIsInQiOiJoaiIsInUiOiJVLWQzMmY0ZGRmLTI0MjMtNGYxMC1hNGRlLTQ2NTkzOTk1MTM1NCJ9.Cy7vswarIBxyh-HzT7bckfZu6CVzTCm2F3kMLBiHf0s" + "squashed-branch-head": "eyJhbGciOiJIUzI1NiIsImtpZCI6IkxCVkJwSlZBZ2hJVHctbll1VlMxZUw3RjFyaWp5VXNqcV9Xd2QzNWJkYXcifQ.eyJoIjoic2c2MGJ2am85MWZzb283cGtoOWdlamJuMHFnYzk1dnJhODdhcDZsNWQzNXJpMGxrYXVkbDdiczEyZDcxc2YzZmg2cDIzdGVlbXVvcjdtazFpOW41NjdtNTBpYmFrY2doamVjNWFqZyIsInQiOiJoaiIsInUiOiJVLWQzMmY0ZGRmLTI0MjMtNGYxMC1hNGRlLTQ2NTkzOTk1MTM1NCJ9.X-GqvqfKSn4im2wDpQq3Zj3Rli4ge4SmmPuNgtvZL0A" }, "type": "success" },