Implement confidential OIDC authentication boundary
This commit is contained in:
parent
0f888986d7
commit
a4e615096c
1 changed files with 1896 additions and 329 deletions
|
|
@ -18,7 +18,6 @@ import qualified Data.Aeson as A
|
|||
import qualified Data.ByteArray as BA
|
||||
import qualified Data.ByteString as BS
|
||||
import qualified Data.ByteString.Base16 as B16
|
||||
import qualified Data.ByteString.Char8 as BS8
|
||||
import qualified Data.ByteString.Lazy as BL
|
||||
import Data.Either (isRight)
|
||||
import Data.Maybe (fromMaybe, listToMaybe)
|
||||
|
|
@ -30,17 +29,18 @@ import Data.UUID.V4 (nextRandom)
|
|||
import Database.PostgreSQL.Simple (Only (..))
|
||||
import Network.HTTP.Types
|
||||
( hContentType
|
||||
, hCookie
|
||||
, hLocation
|
||||
, status400
|
||||
, urlDecode
|
||||
)
|
||||
import qualified Network.HTTP.Types as HTTP
|
||||
import Network.Wai (Request (..), responseLBS)
|
||||
import Nuthatch.Api
|
||||
import Nuthatch.Audit (audit)
|
||||
import Nuthatch.Config (Config (..))
|
||||
import Nuthatch.Db (Db, ensurePersonalPool, withTransaction)
|
||||
import qualified Nuthatch.Db as Db
|
||||
import Nuthatch.Oidc
|
||||
import Nuthatch.Paths
|
||||
import Nuthatch.Protocol
|
||||
import Nuthatch.Redis (Redis, cacheGet, cacheInvalidatePrefixes, cacheSet, throttleTake)
|
||||
|
|
@ -57,13 +57,26 @@ import Nuthatch.S3
|
|||
)
|
||||
import Nuthatch.Session
|
||||
import Nuthatch.Types
|
||||
import Nuthatch.Ui (browsePage, browserScript, loginPage)
|
||||
import Nuthatch.Ui (browsePage, browserScript)
|
||||
import Servant
|
||||
import Servant.Server.Experimental.Auth
|
||||
( AuthHandler
|
||||
, mkAuthHandler
|
||||
)
|
||||
import Servant.Types.SourceT (runSourceT, source)
|
||||
import Web.Cookie
|
||||
( defaultSetCookie
|
||||
, parseCookies
|
||||
, renderSetCookieBS
|
||||
, sameSiteLax
|
||||
, setCookieHttpOnly
|
||||
, setCookieMaxAge
|
||||
, setCookieName
|
||||
, setCookiePath
|
||||
, setCookieSameSite
|
||||
, setCookieSecure
|
||||
, setCookieValue
|
||||
)
|
||||
|
||||
data Env = Env
|
||||
{ envConfig :: Config
|
||||
|
|
@ -105,9 +118,9 @@ server :: Env -> Server Api
|
|||
server env =
|
||||
pure (HealthResponse True)
|
||||
:<|> handleReady env
|
||||
:<|> pure (HtmlPage (loginPage ""))
|
||||
:<|> pure (BrowserScript browserScript)
|
||||
:<|> handleLoginRequest env
|
||||
:<|> handleOidcCallback env
|
||||
:<|> handleLogoutRequest env
|
||||
:<|> handleBrowseRequest env
|
||||
:<|> handleDownloadRequest env
|
||||
|
|
@ -117,7 +130,7 @@ server env =
|
|||
|
||||
authenticationHandler :: Config -> AuthHandler Request SessionContext
|
||||
authenticationHandler cfg = mkAuthHandler $ \request -> do
|
||||
let cookies = parseCookies (requestHeaders request)
|
||||
let cookies = requestCookies request
|
||||
sessionName = TE.encodeUtf8 (cfgCookieName cfg)
|
||||
csrfName = TE.encodeUtf8 (cfgCsrfCookieName cfg)
|
||||
token <- maybe (throwError loginRequired) pure (lookup sessionName cookies)
|
||||
|
|
@ -155,36 +168,68 @@ handleReady env = do
|
|||
|
||||
handleLoginRequest
|
||||
:: Env
|
||||
-> LoginRequest
|
||||
-> Handler LoginStart
|
||||
handleLoginRequest env = do
|
||||
let cfg = envConfig env
|
||||
authorizationResult <- liftIO (beginAuthorization cfg)
|
||||
authorization <- either
|
||||
(const (throwOperationError err502 OidcAuthorizationFailed))
|
||||
pure
|
||||
authorizationResult
|
||||
pure $
|
||||
addHeader
|
||||
(cookieValue
|
||||
(cfgOidcTransactionCookieName cfg)
|
||||
(authorizationTransaction authorization)
|
||||
"/oidc/callback"
|
||||
True) $
|
||||
addHeader (authorizationLocation authorization) NoContent
|
||||
|
||||
handleOidcCallback
|
||||
:: Env
|
||||
-> Text
|
||||
-> Text
|
||||
-> Text
|
||||
-> Handler LoginSuccess
|
||||
handleLoginRequest env request = do
|
||||
let username = unDevUsername (loginUsername request)
|
||||
let uid = encodeUserId "nuthatch-dev" username
|
||||
cfg = envConfig env
|
||||
handleOidcCallback env code state cookieHeader = do
|
||||
let cfg = envConfig env
|
||||
cookies = parseCookies (TE.encodeUtf8 cookieHeader)
|
||||
transactionName = TE.encodeUtf8 (cfgOidcTransactionCookieName cfg)
|
||||
transaction <- maybe
|
||||
(throwOperationError err400 OidcCallbackRejected)
|
||||
(parseCookieValueText OidcCallbackRejected)
|
||||
(lookup transactionName cookies)
|
||||
identityResult <- liftIO (redeemAuthorizationCode cfg transaction state code)
|
||||
identity <- case identityResult of
|
||||
Left reason -> do
|
||||
liftIO $ audit "login" "rejected" Nothing [("reason", A.String reason)]
|
||||
throwOperationError err400 OidcCallbackRejected
|
||||
Right accepted -> pure accepted
|
||||
let uid = encodeUserId (identityIssuer identity) (identitySubject identity)
|
||||
liftIO (ensurePersonalPool (envDb env) cfg uid)
|
||||
tokenResult <- liftIO (mintSession cfg "nuthatch-dev" username (Just username))
|
||||
tokenResult <- liftIO $
|
||||
mintSession
|
||||
cfg
|
||||
(identityIssuer identity)
|
||||
(identitySubject identity)
|
||||
(identityDisplay identity)
|
||||
token <- either
|
||||
(throwOperationError err500 . SessionCreationFailed)
|
||||
(const (throwOperationError err500 (SessionCreationFailed "session creation failed")))
|
||||
pure
|
||||
tokenResult
|
||||
csrf <- liftIO newCsrfToken
|
||||
liftIO $ audit "login" "ok" (Just (unUserId uid)) []
|
||||
pure (loginResponse cfg uid token csrf)
|
||||
pure (loginResponse cfg token csrf)
|
||||
|
||||
loginResponse
|
||||
:: Config
|
||||
-> UserId
|
||||
-> SessionToken
|
||||
-> CsrfToken
|
||||
-> LoginSuccess
|
||||
loginResponse cfg uid token csrf =
|
||||
addHeader (cookieValue cfg (cfgCookieName cfg) (unSessionToken token) True) $
|
||||
addHeader (cookieValue cfg (cfgCsrfCookieName cfg) (unCsrfToken csrf) False) $
|
||||
addHeader csrf (LoginView
|
||||
{ loginViewHtml = HtmlPage
|
||||
"<!doctype html><html><head><meta charset=\"utf-8\"><meta http-equiv=\"refresh\" content=\"0;url=/\"></head><body><a href=\"/\">Continue to Nuthatch</a></body></html>"
|
||||
, loginViewJson = LoginResponse uid
|
||||
})
|
||||
loginResponse :: Config -> SessionToken -> CsrfToken -> LoginSuccess
|
||||
loginResponse cfg token csrf =
|
||||
addHeader (cookieValue (cfgCookieName cfg) (unSessionToken token) "/" True) $
|
||||
addHeader (cookieValue (cfgCsrfCookieName cfg) (unCsrfToken csrf) "/" False) $
|
||||
addHeader
|
||||
(expiredCookieValue
|
||||
(cfgOidcTransactionCookieName cfg)
|
||||
"/oidc/callback") $
|
||||
addHeader "/" NoContent
|
||||
|
||||
handleLogoutRequest
|
||||
:: Env
|
||||
|
|
@ -195,8 +240,13 @@ handleLogoutRequest env session providedCsrf = do
|
|||
requireCsrf LogoutCsrfRejected session providedCsrf
|
||||
let cfg = envConfig env
|
||||
pure $
|
||||
addHeader (expiredCookieValue (cfgCookieName cfg)) $
|
||||
addHeader (expiredCookieValue (cfgCsrfCookieName cfg)) NoContent
|
||||
addHeader (expiredCookieValue (cfgCookieName cfg) "/") $
|
||||
addHeader (expiredCookieValue (cfgCsrfCookieName cfg) "/") $
|
||||
addHeader
|
||||
(expiredCookieValue
|
||||
(cfgOidcTransactionCookieName cfg)
|
||||
"/oidc/callback")
|
||||
NoContent
|
||||
|
||||
handleBrowseRequest
|
||||
:: Env
|
||||
|
|
@ -396,15 +446,32 @@ browsePrefixText :: BrowsePrefix -> Text
|
|||
browsePrefixText (BrowsePrefix Nothing) = ""
|
||||
browsePrefixText (BrowsePrefix (Just path)) = unRelativePath path
|
||||
|
||||
cookieValue :: Config -> Text -> Text -> Bool -> Text
|
||||
cookieValue _cfg name value httpOnly =
|
||||
name <> "=" <> value
|
||||
<> "; Path=/; SameSite=Lax"
|
||||
<> if httpOnly then "; HttpOnly" else ""
|
||||
cookieValue :: Text -> Text -> BS.ByteString -> Bool -> Text
|
||||
cookieValue name value path httpOnly =
|
||||
TE.decodeUtf8 $
|
||||
renderSetCookieBS $
|
||||
defaultSetCookie
|
||||
{ setCookieName = TE.encodeUtf8 name
|
||||
, setCookieValue = TE.encodeUtf8 value
|
||||
, setCookiePath = Just path
|
||||
, setCookieHttpOnly = httpOnly
|
||||
, setCookieSecure = True
|
||||
, setCookieSameSite = Just sameSiteLax
|
||||
}
|
||||
|
||||
expiredCookieValue :: Text -> Text
|
||||
expiredCookieValue name =
|
||||
name <> "=; Path=/; Max-Age=0; SameSite=Lax"
|
||||
expiredCookieValue :: Text -> BS.ByteString -> Text
|
||||
expiredCookieValue name path =
|
||||
TE.decodeUtf8 $
|
||||
renderSetCookieBS $
|
||||
defaultSetCookie
|
||||
{ setCookieName = TE.encodeUtf8 name
|
||||
, setCookieValue = ""
|
||||
, setCookiePath = Just path
|
||||
, setCookieMaxAge = Just 0
|
||||
, setCookieHttpOnly = True
|
||||
, setCookieSecure = True
|
||||
, setCookieSameSite = Just sameSiteLax
|
||||
}
|
||||
|
||||
mutationAuditResult :: MutationResult -> Text
|
||||
mutationAuditResult MutationSucceeded = "ok"
|
||||
|
|
@ -725,18 +792,16 @@ lastSeg t =
|
|||
(x : _) -> x
|
||||
[] -> t
|
||||
|
||||
parseCookies :: [HTTP.Header] -> [(BS.ByteString, BS.ByteString)]
|
||||
parseCookies headers =
|
||||
case lookup "Cookie" headers of
|
||||
Nothing -> []
|
||||
Just raw ->
|
||||
[ (trimBS k, trimBS (BS8.drop 1 v))
|
||||
| part <- BS8.split ';' raw
|
||||
, let (k, v) = BS8.break (== '=') part
|
||||
, not (BS.null v)
|
||||
]
|
||||
requestCookies :: Request -> [(BS.ByteString, BS.ByteString)]
|
||||
requestCookies request =
|
||||
maybe [] parseCookies (lookup hCookie (requestHeaders request))
|
||||
|
||||
trimBS :: BS.ByteString -> BS.ByteString
|
||||
trimBS = BS8.dropWhile isSpace . BS8.dropWhileEnd isSpace
|
||||
where
|
||||
isSpace c = c == ' ' || c == '\t'
|
||||
parseCookieValueText
|
||||
:: A.ToJSON error
|
||||
=> error
|
||||
-> BS.ByteString
|
||||
-> Handler Text
|
||||
parseCookieValueText operationError bytes =
|
||||
case TE.decodeUtf8' bytes of
|
||||
Right value | not (T.null value) && T.length value <= 8192 -> pure value
|
||||
_ -> throwOperationError err400 operationError
|
||||
|
|
|
|||
Loading…
Reference in a new issue