diff --git a/changelog.d/2-features/pr-2931 b/changelog.d/2-features/pr-2931 new file mode 100644 index 00000000000..3d74bc1d037 --- /dev/null +++ b/changelog.d/2-features/pr-2931 @@ -0,0 +1 @@ +Endpoints can now be configured to enable OAuth access. diff --git a/charts/nginz/templates/conf/_nginx.conf.tpl b/charts/nginz/templates/conf/_nginx.conf.tpl index 2cc30632bee..58920d968c0 100644 --- a/charts/nginz/templates/conf/_nginx.conf.tpl +++ b/charts/nginz/templates/conf/_nginx.conf.tpl @@ -264,6 +264,10 @@ http { {{- end }} {{- end }} + {{- if ($location.enable_oauth) }} + oauth on; + {{- end }} + {{- if hasKey $location "specific_user_rate_limit" }} limit_req zone={{ $location.specific_user_rate_limit }}{{ if hasKey $location "specific_user_rate_limit_burst" }} burst={{ $location.specific_user_rate_limit_burst }}{{ end }} nodelay; {{- end }} @@ -295,10 +299,6 @@ http { proxy_set_header Connection ""; {{ end -}} - {{- if not ($location.disable_zauth) }} - proxy_set_header Authorization ""; - {{- end }} - proxy_set_header Z-Type $zauth_type; proxy_set_header Z-User $zauth_user; proxy_set_header Z-Client $zauth_client; diff --git a/charts/nginz/values.yaml b/charts/nginz/values.yaml index 5824e5d16cb..37622324926 100644 --- a/charts/nginz/values.yaml +++ b/charts/nginz/values.yaml @@ -160,6 +160,7 @@ nginx_conf: envs: - staging - path: /self$ # Matches exactly /self + enable_oauth: true envs: - all - path: /self/name diff --git a/libs/libzauth/libzauth-c/src/lib.rs b/libs/libzauth/libzauth-c/src/lib.rs index de419b7f775..12850c48082 100644 --- a/libs/libzauth/libzauth-c/src/lib.rs +++ b/libs/libzauth/libzauth-c/src/lib.rs @@ -27,7 +27,7 @@ use std::ptr; use std::slice; use std::str; use std::panic::{self, UnwindSafe}; -use zauth::{Acl, Error, Keystore, Token, TokenType}; +use zauth::{Acl, Error, Keystore, Token, TokenType, TokenVerification}; use zauth::acl; /// Variant of std::try! that returns the unwrapped error. @@ -119,11 +119,18 @@ pub extern fn zauth_token_parse(cs: *const u8, n: size_t, zt: *mut *mut ZauthTok } #[no_mangle] -pub extern fn zauth_token_verify(t: &ZauthToken, s: &ZauthKeystore) -> ZauthResult { - catch_unwind(|| { +pub extern fn zauth_token_verify(t: &mut ZauthToken, s: &ZauthKeystore) -> ZauthResult { + let result = catch_unwind(|| { try_unwrap!(t.0.verify(&s.0)); ZauthResult::Ok - }) + }); + unsafe { + match result { + ZauthResult::Ok => t.0.verification = TokenVerification::Verified, + _ => t.0.verification = TokenVerification::Invalid + }; + }; + result } #[no_mangle] @@ -146,6 +153,11 @@ pub extern fn zauth_token_type(t: &ZauthToken) -> ZauthTokenType { From::from(t.0.token_type) } +#[no_mangle] +pub extern fn zauth_token_verification(t: &ZauthToken) -> ZauthTokenVerification { + From::from(t.0.verification) +} + // Commented out, looks unused, and causing portability issues with ia32. //#[no_mangle] //pub extern fn zauth_token_time(t: &ZauthToken) -> c_long { @@ -259,3 +271,21 @@ fn catch_unwind(f: F) -> ZauthResult Err(_) => ZauthResult::Panic } } + +#[repr(C)] +#[derive(Clone, Copy, Debug)] +pub enum ZauthTokenVerification { + Verified = 0, + Invalid = 1, + Pending = 2, +} + +impl From for ZauthTokenVerification { + fn from(t: TokenVerification) -> ZauthTokenVerification { + match t { + TokenVerification::Verified => ZauthTokenVerification::Verified, + TokenVerification::Invalid => ZauthTokenVerification::Invalid, + TokenVerification::Pending => ZauthTokenVerification::Pending, + } + } +} diff --git a/libs/libzauth/libzauth-c/src/zauth.h b/libs/libzauth/libzauth-c/src/zauth.h index e8878e36092..32cbcb38f5f 100644 --- a/libs/libzauth/libzauth-c/src/zauth.h +++ b/libs/libzauth/libzauth-c/src/zauth.h @@ -39,6 +39,12 @@ typedef enum { ZAUTH_TOKEN_TYPE_LEGAL_HOLD_ACCESS = 6, } ZauthTokenType; +typedef enum { + ZAUTH_TOKEN_VERIFICATION_SUCCESS = 0, + ZAUTH_TOKEN_VERIFICATION_FAILURE = 1, + ZAUTH_TOKEN_VERIFICATION_PENDING = 2, +} ZauthTokenVerification; + typedef struct ZauthAcl ZauthAcl; typedef struct ZauthKeystore ZauthKeystore; typedef struct ZauthToken ZauthToken; @@ -49,14 +55,15 @@ void zauth_keystore_delete(ZauthKeystore * store); ZauthResult zauth_acl_open(uint8_t const * fname, size_t len, ZauthAcl **); void zauth_acl_delete(ZauthAcl * store); -ZauthResult zauth_token_parse(uint8_t const * str, size_t len, ZauthToken **); -ZauthResult zauth_token_verify(ZauthToken const *, ZauthKeystore const *); -ZauthTokenType zauth_token_type(ZauthToken const *); -long zauth_token_time(ZauthToken const *); -uint8_t zauth_token_version(ZauthToken const *); -Range zauth_token_lookup(ZauthToken const *, uint8_t); -ZauthResult zauth_token_allowed(ZauthToken const *, ZauthAcl const *, uint8_t const * path, size_t len, uint8_t * result); -void zauth_token_delete(ZauthToken *); +ZauthResult zauth_token_parse(uint8_t const * str, size_t len, ZauthToken **); +ZauthResult zauth_token_verify(ZauthToken const *, ZauthKeystore const *); +ZauthTokenType zauth_token_type(ZauthToken const *); +ZauthTokenVerification zauth_token_verification(ZauthToken const *); +long zauth_token_time(ZauthToken const *); +uint8_t zauth_token_version(ZauthToken const *); +Range zauth_token_lookup(ZauthToken const *, uint8_t); +ZauthResult zauth_token_allowed(ZauthToken const *, ZauthAcl const *, uint8_t const * path, size_t len, uint8_t * result); +void zauth_token_delete(ZauthToken *); #ifdef __cplusplus } diff --git a/libs/libzauth/libzauth/src/lib.rs b/libs/libzauth/libzauth/src/lib.rs index 7aed0ff3713..9d13e25627d 100644 --- a/libs/libzauth/libzauth/src/lib.rs +++ b/libs/libzauth/libzauth/src/lib.rs @@ -29,4 +29,4 @@ mod matcher; pub use acl::Acl; pub use error::Error; -pub use zauth::{Keystore, Token, TokenType}; +pub use zauth::{Keystore, Token, TokenType, TokenVerification}; diff --git a/libs/libzauth/libzauth/src/zauth.rs b/libs/libzauth/libzauth/src/zauth.rs index 136b6e423bc..3010e06a70e 100644 --- a/libs/libzauth/libzauth/src/zauth.rs +++ b/libs/libzauth/libzauth/src/zauth.rs @@ -85,6 +85,15 @@ impl FromStr for TokenType { } } +// Token Verification //////////////////////////////////////////////////////// + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TokenVerification { + Verified, + Invalid, + Pending +} + // Token //////////////////////////////////////////////////////////////////// // Used when parsing tokens. @@ -103,14 +112,15 @@ macro_rules! to_field { #[derive(Debug)] pub struct Token<'r> { - pub signature: Signature, - pub version: u8, - pub key_idx: usize, - pub timestamp: i64, - pub token_type: TokenType, - pub token_tag: Option<&'r str>, - meta: HashMap, - data: &'r [u8] + pub signature: Signature, + pub version: u8, + pub key_idx: usize, + pub timestamp: i64, + pub token_type: TokenType, + pub token_tag: Option<&'r str>, + pub verification: TokenVerification, + meta: HashMap, + data: &'r [u8] } impl<'r> Token<'r> { @@ -141,14 +151,15 @@ impl<'r> Token<'r> { Ok(Token { signature, - version: to_field!(meta.remove(&'v'), "version"), - key_idx: to_field!(meta.remove(&'k'), "key index"), - timestamp: to_field!(meta.remove(&'d'), "timestamp"), - token_type: to_field!(meta.remove(&'t'), "type"), - token_tag: meta.remove(&'l') + version: to_field!(meta.remove(&'v'), "version"), + key_idx: to_field!(meta.remove(&'k'), "key index"), + timestamp: to_field!(meta.remove(&'d'), "timestamp"), + token_type: to_field!(meta.remove(&'t'), "type"), + token_tag: meta.remove(&'l') .and_then(|t| if t == "" { None } else { Some(t) }), + verification: TokenVerification::Pending, meta, - data: data[1..].as_bytes() + data: data[1..].as_bytes() }) } diff --git a/libs/wire-api/default.nix b/libs/wire-api/default.nix index 8564a5767ac..816c540079f 100644 --- a/libs/wire-api/default.nix +++ b/libs/wire-api/default.nix @@ -51,6 +51,7 @@ , iproute , iso3166-country-codes , iso639 +, jose , lens , lib , memory @@ -152,6 +153,7 @@ mkDerivation { iproute iso3166-country-codes iso639 + jose lens memory metrics-wai diff --git a/libs/wire-api/src/Wire/API/OAuth.hs b/libs/wire-api/src/Wire/API/OAuth.hs new file mode 100644 index 00000000000..3a300411612 --- /dev/null +++ b/libs/wire-api/src/Wire/API/OAuth.hs @@ -0,0 +1,444 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.API.OAuth where + +import Cassandra hiding (Set) +import Control.Lens (preview, view) +import Control.Monad.Except +import Crypto.JWT hiding (Context, params, uri, verify) +import qualified Data.Aeson.KeyMap as M +import qualified Data.Aeson.Types as A +import Data.ByteString.Conversion +import Data.ByteString.Lazy (toStrict) +import qualified Data.HashMap.Strict as HM +import Data.Id as Id +import Data.Range +import Data.Schema +import qualified Data.Set as Set +import Data.String.Conversions (cs) +import qualified Data.Swagger as S +import qualified Data.Text as T +import Data.Text.Ascii +import qualified Data.Text.Encoding as TE +import Data.Text.Encoding.Error as TErr +import Data.Time (NominalDiffTime) +import Imports hiding (exp, head) +import Servant hiding (Handler, JSON, Tagged, addHeader, respond) +import Servant.Swagger.Internal.Orphans () +import URI.ByteString +import Web.FormUrlEncoded (Form (..), FromForm (..), ToForm (..), parseUnique) +import Wire.API.Error + +-------------------------------------------------------------------------------- +-- Types + +newtype RedirectUrl = RedirectUrl {unRedirectUrl :: URIRef Absolute} + deriving (Eq, Show, Generic) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema RedirectUrl) + +instance ToByteString RedirectUrl where + builder = serializeURIRef . unRedirectUrl + +instance FromByteString RedirectUrl where + parser = RedirectUrl <$> uriParser strictURIParserOptions + +instance ToSchema RedirectUrl where + schema = + (TE.decodeUtf8 . serializeURIRef' . unRedirectUrl) + .= (RedirectUrl <$> parsedText "RedirectUrl" (runParser (uriParser strictURIParserOptions) . TE.encodeUtf8)) + +instance ToHttpApiData RedirectUrl where + toUrlPiece = TE.decodeUtf8With TErr.lenientDecode . toHeader + toHeader = serializeURIRef' . unRedirectUrl + +instance FromHttpApiData RedirectUrl where + parseUrlPiece = parseHeader . TE.encodeUtf8 + parseHeader = bimap (T.pack . show) RedirectUrl . parseURI strictURIParserOptions + +newtype OAuthApplicationName = OAuthApplicationName {unOAuthApplicationName :: Range 1 256 Text} + deriving (Eq, Show, Generic) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema OAuthApplicationName) + +instance ToSchema OAuthApplicationName where + schema = OAuthApplicationName <$> unOAuthApplicationName .= schema + +data NewOAuthClient = NewOAuthClient + { nocApplicationName :: OAuthApplicationName, + nocRedirectUrl :: RedirectUrl + } + deriving (Eq, Show, Generic) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema NewOAuthClient) + +instance ToSchema NewOAuthClient where + schema = + object "NewOAuthClient" $ + NewOAuthClient + <$> nocApplicationName .= field "applicationName" schema + <*> nocRedirectUrl .= field "redirectUrl" schema + +newtype OAuthClientPlainTextSecret = OAuthClientPlainTextSecret {unOAuthClientPlainTextSecret :: AsciiBase16} + deriving (Eq, Generic) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema OAuthClientPlainTextSecret) + +instance Show OAuthClientPlainTextSecret where + show _ = "" + +instance ToSchema OAuthClientPlainTextSecret where + schema = (toText . unOAuthClientPlainTextSecret) .= parsedText "OAuthClientPlainTextSecret" (fmap OAuthClientPlainTextSecret . validateBase16) + +instance FromHttpApiData OAuthClientPlainTextSecret where + parseQueryParam = bimap cs OAuthClientPlainTextSecret . validateBase16 . cs + +instance ToHttpApiData OAuthClientPlainTextSecret where + toQueryParam = toText . unOAuthClientPlainTextSecret + +data OAuthClientCredentials = OAuthClientCredentials + { occClientId :: OAuthClientId, + occClientSecret :: OAuthClientPlainTextSecret + } + deriving (Eq, Show, Generic) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema OAuthClientCredentials) + +instance ToSchema OAuthClientCredentials where + schema = + object "OAuthClientCredentials" $ + OAuthClientCredentials + <$> occClientId .= field "clientId" schema + <*> occClientSecret .= field "clientSecret" schema + +data OAuthClient = OAuthClient + { ocId :: OAuthClientId, + ocName :: OAuthApplicationName, + ocRedirectUrl :: RedirectUrl + } + deriving (Eq, Show, Generic) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema OAuthClient) + +instance ToSchema OAuthClient where + schema = + object "OAuthClient" $ + OAuthClient + <$> ocId .= field "clientId" schema + <*> ocName .= field "applicationName" schema + <*> ocRedirectUrl .= field "redirectUrl" schema + +data OAuthResponseType = OAuthResponseTypeCode + deriving (Eq, Show, Generic) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema OAuthResponseType) + +instance ToSchema OAuthResponseType where + schema :: ValueSchema NamedSwaggerDoc OAuthResponseType + schema = + enum @Text "OAuthResponseType" $ + mconcat + [ element "code" OAuthResponseTypeCode + ] + +data OAuthScope + = ConversationCreate + | ConversationCodeCreate + | SelfRead + deriving (Eq, Show, Generic, Ord) + +class IsOAuthScope scope where + toOAuthScope :: OAuthScope + +instance IsOAuthScope 'ConversationCreate where + toOAuthScope = ConversationCreate + +instance IsOAuthScope 'ConversationCodeCreate where + toOAuthScope = ConversationCodeCreate + +instance IsOAuthScope 'SelfRead where + toOAuthScope = SelfRead + +instance ToByteString OAuthScope where + builder = \case + ConversationCreate -> "conversation:create" + ConversationCodeCreate -> "conversation-code:create" + SelfRead -> "self:read" + +instance FromByteString OAuthScope where + parser = do + s <- parser + case s & T.toLower of + "conversation:create" -> pure ConversationCreate + "conversation-code:create" -> pure ConversationCodeCreate + "self:read" -> pure SelfRead + _ -> fail "invalid scope" + +newtype OAuthScopes = OAuthScopes {unOAuthScopes :: Set OAuthScope} + deriving (Eq, Show, Generic) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema OAuthScopes) + +instance ToSchema OAuthScopes where + schema = OAuthScopes <$> (oauthScopesToText . unOAuthScopes) .= withParser schema oauthScopeParser + +oauthScopesToText :: Set OAuthScope -> Text +oauthScopesToText = T.intercalate " " . fmap (cs . toByteString') . Set.toList + +oauthScopeParser :: Text -> A.Parser (Set OAuthScope) +oauthScopeParser "" = pure Set.empty +oauthScopeParser scope = + pure $ (not . T.null) `filter` T.splitOn " " scope & maybe Set.empty Set.fromList . mapM (fromByteString' . cs) + +data NewOAuthAuthCode = NewOAuthAuthCode + { noacClientId :: OAuthClientId, + noacScope :: OAuthScopes, + noacResponseType :: OAuthResponseType, + noacRedirectUri :: RedirectUrl, + noacState :: Text + } + deriving (Eq, Show, Generic) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema NewOAuthAuthCode) + +instance ToSchema NewOAuthAuthCode where + schema = + object "NewOAuthAuthCode" $ + NewOAuthAuthCode + <$> noacClientId .= field "clientId" schema + <*> noacScope .= field "scope" schema + <*> noacResponseType .= field "responseType" schema + <*> noacRedirectUri .= field "redirectUri" schema + <*> noacState .= field "state" schema + +newtype OAuthAuthCode = OAuthAuthCode {unOAuthAuthCode :: AsciiBase16} + deriving (Show, Eq, Generic) + +instance ToSchema OAuthAuthCode where + schema = (toText . unOAuthAuthCode) .= parsedText "OAuthAuthCode" (fmap OAuthAuthCode . validateBase16) + +instance ToByteString OAuthAuthCode where + builder = builder . unOAuthAuthCode + +instance FromByteString OAuthAuthCode where + parser = OAuthAuthCode <$> parser + +instance FromHttpApiData OAuthAuthCode where + parseQueryParam = bimap cs OAuthAuthCode . validateBase16 . cs + +instance ToHttpApiData OAuthAuthCode where + toQueryParam = toText . unOAuthAuthCode + +data OAuthGrantType = OAuthGrantTypeAuthorizationCode + deriving (Eq, Show, Generic) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema OAuthGrantType) + +instance ToSchema OAuthGrantType where + schema = + enum @Text "OAuthGrantType" $ + mconcat + [ element "authorization_code" OAuthGrantTypeAuthorizationCode + ] + +instance FromByteString OAuthGrantType where + parser = do + s <- parser + case s & T.toLower of + "authorization_code" -> pure OAuthGrantTypeAuthorizationCode + _ -> fail "invalid OAuthGrantType" + +instance ToByteString OAuthGrantType where + builder = \case + OAuthGrantTypeAuthorizationCode -> "authorization_code" + +instance FromHttpApiData OAuthGrantType where + parseQueryParam = maybe (Left "invalid OAuthGrantType") pure . fromByteString . cs + +instance ToHttpApiData OAuthGrantType where + toQueryParam = cs . toByteString + +data OAuthAccessTokenRequest = OAuthAccessTokenRequest + { oatGrantType :: OAuthGrantType, + oatClientId :: OAuthClientId, + oatClientSecret :: OAuthClientPlainTextSecret, + oatCode :: OAuthAuthCode, + oatRedirectUri :: RedirectUrl + } + deriving (Eq, Show, Generic) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema OAuthAccessTokenRequest) + +instance ToSchema OAuthAccessTokenRequest where + schema = + object "OAuthAccessTokenRequest" $ + OAuthAccessTokenRequest + <$> oatGrantType .= field "grantType" schema + <*> oatClientId .= field "clientId" schema + <*> oatClientSecret .= field "clientSecret" schema + <*> oatCode .= field "code" schema + <*> oatRedirectUri .= field "redirectUri" schema + +instance FromForm OAuthAccessTokenRequest where + fromForm f = + OAuthAccessTokenRequest + <$> parseUnique "grant_type" f + <*> parseUnique "client_id" f + <*> parseUnique "client_secret" f + <*> parseUnique "code" f + <*> parseUnique "redirect_uri" f + +instance ToForm OAuthAccessTokenRequest where + toForm req = + Form $ + mempty + & HM.insert "grant_type" [toQueryParam (oatGrantType req)] + & HM.insert "client_id" [toQueryParam (oatClientId req)] + & HM.insert "client_secret" [toQueryParam (oatClientSecret req)] + & HM.insert "code" [toQueryParam (oatCode req)] + & HM.insert "redirect_uri" [toQueryParam (oatRedirectUri req)] + +data OAuthAccessTokenType = OAuthAccessTokenTypeBearer + deriving (Eq, Show, Generic) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema OAuthAccessTokenType) + +instance ToSchema OAuthAccessTokenType where + schema = + enum @Text "OAuthAccessTokenType" $ + mconcat + [ element "Bearer" OAuthAccessTokenTypeBearer + ] + +newtype OAuthAccessToken = OAuthAccessToken {unOAuthAccessToken :: SignedJWT} + deriving (Show, Eq, Generic) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via Schema OAuthAccessToken + +instance ToByteString OAuthAccessToken where + builder = builder . encodeCompact . unOAuthAccessToken + +instance FromByteString OAuthAccessToken where + parser = do + t <- parser @Text + case decodeCompact (cs (TE.encodeUtf8 t)) of + Left (err :: JWTError) -> fail $ show err + Right jwt -> pure $ OAuthAccessToken jwt + +instance ToHttpApiData OAuthAccessToken where + toHeader = toByteString' + toUrlPiece = cs . toHeader + +instance FromHttpApiData OAuthAccessToken where + parseHeader = either (Left . cs) pure . runParser parser . cs + parseUrlPiece = parseHeader . cs + +instance ToSchema OAuthAccessToken where + schema = (TE.decodeUtf8 . toByteString') .= withParser schema (either fail pure . runParser parser . cs) + +data OAuthAccessTokenResponse = OAuthAccessTokenResponse + { oatAccessToken :: OAuthAccessToken, + oatTokenType :: OAuthAccessTokenType, + oatExpiresIn :: NominalDiffTime + } + deriving (Eq, Show, Generic) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema OAuthAccessTokenResponse) + +instance ToSchema OAuthAccessTokenResponse where + schema = + object "OAuthAccessTokenResponse" $ + OAuthAccessTokenResponse + <$> oatAccessToken .= field "accessToken" schema + <*> oatTokenType .= field "tokenType" schema + <*> oatExpiresIn .= field "expiresIn" (fromIntegral <$> roundDiffTime .= schema) + where + roundDiffTime :: NominalDiffTime -> Int32 + roundDiffTime = round + +data OAuthClaimSet = OAuthClaimSet {jwtClaims :: ClaimsSet, scope :: OAuthScopes} + deriving (Eq, Show, Generic) + +instance HasClaimsSet OAuthClaimSet where + claimsSet f s = fmap (\a' -> s {jwtClaims = a'}) (f (jwtClaims s)) + +instance A.FromJSON OAuthClaimSet where + parseJSON = A.withObject "OAuthClaimSet" $ \o -> + OAuthClaimSet + <$> A.parseJSON (A.Object o) + <*> o A..: "scope" + +instance A.ToJSON OAuthClaimSet where + toJSON s = + ins "scope" (scope s) (A.toJSON (jwtClaims s)) + where + ins k v (A.Object o) = A.Object $ M.insert k (A.toJSON v) o + ins _ _ a = a + +csUserId :: OAuthClaimSet -> Maybe UserId +csUserId = + view claimSub + >=> preview string + >=> either (const Nothing) pure . parseIdFromText + +hasScope :: OAuthScope -> OAuthClaimSet -> Bool +hasScope s claims = s `Set.member` unOAuthScopes (scope claims) + +verify :: JWK -> SignedJWT -> IO (Either JWTError OAuthClaimSet) +verify k jwt = runJOSE $ do + let audCheck = const True + verifyJWT (defaultJWTValidationSettings audCheck) k jwt + +-------------------------------------------------------------------------------- +-- Errors + +data OAuthError + = OAuthClientNotFound + | RedirectUrlMissMatch + | UnsupportedResponseType + | JwtError + | OAuthAuthCodeNotFound + | OAuthFeatureDisabled + | InvalidClientCredentials + +type instance MapError 'OAuthClientNotFound = 'StaticError 404 "not-found" "OAuth client not found" + +type instance MapError 'RedirectUrlMissMatch = 'StaticError 400 "redirect-url-miss-match" "Redirect URL miss match" + +type instance MapError 'UnsupportedResponseType = 'StaticError 400 "unsupported-response-type" "Unsupported response type" + +type instance MapError 'JwtError = 'StaticError 500 "jwt-error" "Internal error while handling JWT token" + +type instance MapError 'OAuthAuthCodeNotFound = 'StaticError 404 "not-found" "OAuth authorization code not found" + +type instance MapError 'OAuthFeatureDisabled = 'StaticError 403 "forbidden" "OAuth is disabled" + +type instance MapError 'InvalidClientCredentials = 'StaticError 403 "forbidden" "Invalid client credentials" + +-------------------------------------------------------------------------------- +-- CQL instances + +instance Cql OAuthApplicationName where + ctype = Tagged TextColumn + toCql = CqlText . fromRange . unOAuthApplicationName + fromCql (CqlText t) = checkedEither t <&> OAuthApplicationName + fromCql _ = Left "OAuthApplicationName: Text expected" + +instance Cql RedirectUrl where + ctype = Tagged BlobColumn + toCql = CqlBlob . toByteString + fromCql (CqlBlob t) = runParser parser (toStrict t) + fromCql _ = Left "RedirectUrl: Blob expected" + +instance Cql OAuthAuthCode where + ctype = Tagged AsciiColumn + toCql = CqlAscii . toText . unOAuthAuthCode + fromCql (CqlAscii t) = OAuthAuthCode <$> validateBase16 t + fromCql _ = Left "OAuthAuthCode: Ascii expected" + +instance Cql OAuthScope where + ctype = Tagged TextColumn + toCql = CqlText . cs . toByteString' + fromCql (CqlText t) = maybe (Left "invalid oauth scope") Right $ fromByteString' (cs t) + fromCql _ = Left "OAuthScope: Text expected" diff --git a/libs/wire-api/src/Wire/API/RawJson.hs b/libs/wire-api/src/Wire/API/RawJson.hs index 295202c1ed0..f2806c972f7 100644 --- a/libs/wire-api/src/Wire/API/RawJson.hs +++ b/libs/wire-api/src/Wire/API/RawJson.hs @@ -21,7 +21,7 @@ import Imports import Servant -- | Wrap json content as plain 'LByteString' --- This type is intented to be used to receive json content as 'LByteString'. +-- This type is intended to be used to receive json content as 'LByteString'. -- Warning: There is no validation of the json content. It may be any string. newtype RawJson = RawJson {rawJsonBytes :: LByteString} diff --git a/libs/wire-api/src/Wire/API/Routes/Bearer.hs b/libs/wire-api/src/Wire/API/Routes/Bearer.hs index ca88c1c5e44..06d6b2ad919 100644 --- a/libs/wire-api/src/Wire/API/Routes/Bearer.hs +++ b/libs/wire-api/src/Wire/API/Routes/Bearer.hs @@ -35,6 +35,10 @@ instance FromHttpApiData a => FromHttpApiData (Bearer a) where _ -> Left "Invalid authorization scheme" parseUrlPiece = parseHeader . T.encodeUtf8 +instance ToHttpApiData a => ToHttpApiData (Bearer a) where + toHeader = (<>) "Bearer " . toHeader . unBearer + toUrlPiece = T.decodeUtf8 . toHeader + type BearerHeader a = Header' '[Lenient] "Authorization" (Bearer a) type BearerQueryParam = diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/Brig/OAuth.hs b/libs/wire-api/src/Wire/API/Routes/Internal/Brig/OAuth.hs new file mode 100644 index 00000000000..5caf4045a23 --- /dev/null +++ b/libs/wire-api/src/Wire/API/Routes/Internal/Brig/OAuth.hs @@ -0,0 +1,40 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.API.Routes.Internal.Brig.OAuth where + +import Servant (JSON) +import Servant hiding (Handler, JSON, Tagged, addHeader, respond) +import Servant.Swagger.Internal.Orphans () +import Wire.API.Error +import Wire.API.OAuth +import Wire.API.Routes.Named (Named (..)) + +-------------------------------------------------------------------------------- +-- API Internal + +type IOAuthAPI = + Named + "create-oauth-client" + ( Summary "Register an OAuth client" + :> CanThrow 'OAuthFeatureDisabled + :> "i" + :> "oauth" + :> "clients" + :> ReqBody '[JSON] NewOAuthClient + :> Post '[JSON] OAuthClientCredentials + ) diff --git a/libs/wire-api/src/Wire/API/Routes/Public.hs b/libs/wire-api/src/Wire/API/Routes/Public.hs index deff0d727c5..8bec0f4df30 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public.hs @@ -30,6 +30,7 @@ module Wire.API.Routes.Public ZBot, ZConversation, ZProvider, + ZUserOrOAuth, -- * Swagger combinators OmitDocs, @@ -37,21 +38,31 @@ module Wire.API.Routes.Public where import Control.Lens ((<>~)) +import Control.Monad.Except +import Crypto.JWT hiding (Context, params, uri, verify) import Data.Domain +import Data.Either.Combinators import qualified Data.HashMap.Strict.InsOrd as InsOrdHashMap import Data.Id as Id import Data.Metrics.Servant import Data.Qualified +import Data.SOP +import Data.String.Conversions (cs) import Data.Swagger import GHC.Base (Symbol) import GHC.TypeLits (KnownSymbol) -import Imports hiding (All, head) +import Imports hiding (All, exp, head) +import Network.Wai import qualified Network.Wai as Wai -import Servant hiding (Handler, JSON, addHeader, respond) +import Servant hiding (Handler, JSON, Tagged, addHeader, respond) import Servant.API.Modifiers import Servant.Server.Internal.Delayed import Servant.Server.Internal.DelayedIO +import Servant.Server.Internal.Router import Servant.Swagger (HasSwagger (toSwagger)) +import Servant.Swagger.Internal.Orphans () +import Wire.API.OAuth +import Wire.API.Routes.Bearer mapRequestArgument :: forall mods a b. @@ -275,3 +286,74 @@ instance HasServer api ctx => HasServer (OmitDocs :> api) ctx where instance RoutesToPaths api => RoutesToPaths (OmitDocs :> api) where getRoutes = getRoutes @api + +-------------------------------------------------------------------------------- +-- Z-OAuth + +data ZUserOrOAuth (scope :: OAuthScope) + +instance HasSwagger api => HasSwagger (ZUserOrOAuth scope :> api) where + toSwagger _ = toSwagger (Proxy @(ZUserOrOAuth scope :> api)) + +checkZAuthOrOAuth :: OAuthScope -> Maybe JWK -> Request -> DelayedIO (Either ServerError UserId) +checkZAuthOrOAuth oauthScope mJwk req = maybe tryOAuth (pure . Right) tryZUserAuth + where + tryZUserAuth :: Maybe UserId + tryZUserAuth = lookup "Z-User" (requestHeaders req) >>= (either (const Nothing) pure . parseHeader) + + tryOAuth :: DelayedIO (Either ServerError UserId) + tryOAuth = do + let headerOrError = maybeToRight oauthTokenMissing $ lookup "Z-OAuth" (requestHeaders req) + let jwkOrError = maybeToRight jwtError mJwk + let tokenOrError = headerOrError >>= mapLeft invalidOAuthToken . parseHeader + either (pure . Left) verifyOAuthToken $ (,) <$> tokenOrError <*> jwkOrError + + verifyOAuthToken :: (Bearer OAuthAccessToken, JWK) -> DelayedIO (Either ServerError UserId) + verifyOAuthToken (token, key) = do + verifiedOrError <- mapLeft (invalidOAuthToken . cs . show) <$> liftIO (verify key (unOAuthAccessToken . unBearer $ token)) + pure $ + verifiedOrError >>= \claimSet -> + if hasScope oauthScope claimSet + then maybeToRight (invalidOAuthToken "Invalid token: Missing or invalid sub claim") (csUserId claimSet) + else Left insufficientScope + +instance (HasServer api context, HasContextEntry context (Maybe JWK), IsOAuthScope scope) => HasServer (ZUserOrOAuth scope :> api) context where + type ServerT (ZUserOrOAuth scope :> api) m = UserId -> ServerT api m + + route :: + (HasServer api context, HasContextEntry context (Maybe JWK)) => + Proxy (ZUserOrOAuth scope :> api) -> + Context context -> + Delayed env (Server (ZUserOrOAuth scope :> api)) -> + Router env + route _ ctx svr = route (Proxy @api) ctx (addAuthCheck svr (withRequest checkAuth)) + where + checkAuth :: Request -> DelayedIO UserId + checkAuth = checkZAuthOrOAuth (toOAuthScope @scope) (getContextEntry ctx) >=> either delayedFailFatal pure + + hoistServerWithContext :: + (HasServer api context, HasContextEntry context (Maybe JWK)) => + Proxy (ZUserOrOAuth scope :> api) -> + Proxy context -> + (forall x. m x -> n x) -> + ServerT (ZUserOrOAuth scope :> api) m -> + ServerT (ZUserOrOAuth scope :> api) n + hoistServerWithContext _ pc f s = hoistServerWithContext (Proxy :: Proxy api) pc f . s + +instance RoutesToPaths api => RoutesToPaths (ZUserOrOAuth scope :> api) where + getRoutes = getRoutes @api + +-------------------------------------------------------------------------------- +-- Util + +insufficientScope :: ServerError +insufficientScope = err403 {errReasonPhrase = "Access denied", errBody = "Insufficient scope"} + +jwtError :: ServerError +jwtError = err500 {errReasonPhrase = "jwt-error", errBody = "Internal error while handling JWT token"} + +invalidOAuthToken :: Text -> ServerError +invalidOAuthToken t = err403 {errReasonPhrase = "Access denied", errBody = "Invalid token: " <> cs t} + +oauthTokenMissing :: ServerError +oauthTokenMissing = err403 {errReasonPhrase = "Access denied"} diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs b/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs index d1f3eae1e6b..6e8ac94b75f 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs @@ -48,6 +48,7 @@ import Wire.API.Error.Brig import Wire.API.Error.Empty import Wire.API.MLS.KeyPackage import Wire.API.MLS.Servant +import Wire.API.OAuth import Wire.API.Properties import Wire.API.Routes.Bearer import Wire.API.Routes.Cookies @@ -250,7 +251,7 @@ type SelfAPI = Named "get-self" ( Summary "Get your own profile" - :> ZUser + :> ZUserOrOAuth 'SelfRead :> "self" :> Get '[JSON] SelfProfile ) diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Brig/OAuth.hs b/libs/wire-api/src/Wire/API/Routes/Public/Brig/OAuth.hs new file mode 100644 index 00000000000..c9720fc5955 --- /dev/null +++ b/libs/wire-api/src/Wire/API/Routes/Public/Brig/OAuth.hs @@ -0,0 +1,77 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.API.Routes.Public.Brig.OAuth where + +import Data.Id as Id +import Imports hiding (exp, head) +import Servant (JSON) +import Servant hiding (Handler, JSON, Tagged, addHeader, respond) +import Servant.Swagger.Internal.Orphans () +import Wire.API.Error +import Wire.API.OAuth +import Wire.API.Routes.MultiVerb +import Wire.API.Routes.Named (Named (..)) +import Wire.API.Routes.Public + +type OAuthAPI = + Named + "get-oauth-client" + ( Summary "Get OAuth client information" + :> CanThrow 'OAuthFeatureDisabled + :> ZUser + :> "oauth" + :> "clients" + :> Capture "ClientId" OAuthClientId + :> MultiVerb + 'GET + '[JSON] + '[ ErrorResponse 'OAuthClientNotFound, + Respond 200 "OAuth client found" OAuthClient + ] + (Maybe OAuthClient) + ) + :<|> Named + "create-oauth-auth-code" + ( Summary "" + :> CanThrow 'UnsupportedResponseType + :> CanThrow 'RedirectUrlMissMatch + :> CanThrow 'OAuthClientNotFound + :> CanThrow 'OAuthFeatureDisabled + :> ZUser + :> "oauth" + :> "authorization" + :> "codes" + :> ReqBody '[JSON] NewOAuthAuthCode + :> MultiVerb + 'POST + '[JSON] + '[WithHeaders '[Header "Location" RedirectUrl] RedirectUrl (RespondEmpty 302 "Found")] + RedirectUrl + ) + :<|> Named + "create-oauth-access-token" + ( Summary "Create an OAuth access token" + :> CanThrow 'JwtError + :> CanThrow 'OAuthAuthCodeNotFound + :> CanThrow 'OAuthClientNotFound + :> CanThrow 'OAuthFeatureDisabled + :> "oauth" + :> "token" + :> ReqBody '[FormUrlEncoded] OAuthAccessTokenRequest + :> Post '[JSON] OAuthAccessTokenResponse + ) diff --git a/libs/wire-api/wire-api.cabal b/libs/wire-api/wire-api.cabal index d0bdd13c0f7..1a483c53a76 100644 --- a/libs/wire-api/wire-api.cabal +++ b/libs/wire-api/wire-api.cabal @@ -60,6 +60,7 @@ library Wire.API.MLS.SubConversation Wire.API.MLS.Welcome Wire.API.Notification + Wire.API.OAuth Wire.API.Properties Wire.API.Provider Wire.API.Provider.Bot @@ -78,6 +79,7 @@ library Wire.API.Routes.Internal.Brig Wire.API.Routes.Internal.Brig.Connection Wire.API.Routes.Internal.Brig.EJPD + Wire.API.Routes.Internal.Brig.OAuth Wire.API.Routes.Internal.Cannon Wire.API.Routes.Internal.Cargohold Wire.API.Routes.Internal.Galley.TeamFeatureNoConfigMulti @@ -89,6 +91,7 @@ library Wire.API.Routes.Named Wire.API.Routes.Public Wire.API.Routes.Public.Brig + Wire.API.Routes.Public.Brig.OAuth Wire.API.Routes.Public.Cannon Wire.API.Routes.Public.Cargohold Wire.API.Routes.Public.Galley @@ -238,6 +241,7 @@ library , iproute >=1.5 , iso3166-country-codes >=0.2 , iso639 >=0.1 + , jose , lens >=4.12 , memory , metrics-wai diff --git a/services/brig/src/Brig/API/Internal.hs b/services/brig/src/Brig/API/Internal.hs index 9051b52fe20..3f575cddf8e 100644 --- a/services/brig/src/Brig/API/Internal.hs +++ b/services/brig/src/Brig/API/Internal.hs @@ -30,7 +30,7 @@ import qualified Brig.API.Connection as API import Brig.API.Error import Brig.API.Handler import Brig.API.MLS.KeyPackages.Validation -import Brig.API.OAuth (IOAuthAPI, internalOauthAPI) +import Brig.API.OAuth (internalOauthAPI) import Brig.API.Types import qualified Brig.API.User as API import qualified Brig.API.User as Api @@ -94,6 +94,7 @@ import Wire.API.MLS.Serialisation import Wire.API.Routes.Internal.Brig import qualified Wire.API.Routes.Internal.Brig as BrigIRoutes import Wire.API.Routes.Internal.Brig.Connection +import Wire.API.Routes.Internal.Brig.OAuth (IOAuthAPI) import Wire.API.Routes.Named import qualified Wire.API.Team.Feature as ApiFt import Wire.API.User diff --git a/services/brig/src/Brig/API/OAuth.hs b/services/brig/src/Brig/API/OAuth.hs index ca41d2c1a6c..9be87e6ec0e 100644 --- a/services/brig/src/Brig/API/OAuth.hs +++ b/services/brig/src/Brig/API/OAuth.hs @@ -29,352 +29,30 @@ import qualified Cassandra as C import Control.Lens (view, (.~), (?~), (^?)) import Control.Monad.Except import Crypto.JWT hiding (params, uri) -import qualified Data.Aeson as A -import qualified Data.Aeson.KeyMap as M -import qualified Data.Aeson.Types as A import Data.ByteString.Conversion -import Data.ByteString.Lazy (toStrict) import Data.Domain -import qualified Data.HashMap.Strict as HM import Data.Id (OAuthClientId, UserId, idToText, randomId) import Data.Misc (PlainTextPassword (PlainTextPassword)) -import Data.Range -import Data.Schema import qualified Data.Set as Set import Data.String.Conversions (cs) -import qualified Data.Swagger as S -import qualified Data.Text as T import Data.Text.Ascii -import qualified Data.Text.Encoding as TE -import Data.Text.Encoding.Error as TErr import Data.Time (NominalDiffTime, addUTCTime) import Imports hiding (exp) import OpenSSL.Random (randBytes) import Polysemy (Member) import Servant hiding (Handler, Tagged) import URI.ByteString -import Web.FormUrlEncoded (Form (..), FromForm (..), ToForm (..), parseUnique) import Wire.API.Error -import Wire.API.Routes.MultiVerb +import Wire.API.OAuth +import Wire.API.Routes.Internal.Brig.OAuth (IOAuthAPI) import Wire.API.Routes.Named (Named (..)) -import Wire.API.Routes.Public (ZUser) +import Wire.API.Routes.Public.Brig.OAuth (OAuthAPI) import Wire.Sem.Now (Now) import qualified Wire.Sem.Now as Now --------------------------------------------------------------------------------- --- Types - -newtype RedirectUrl = RedirectUrl {unRedirectUrl :: URIRef Absolute} - deriving (Eq, Show, Generic) - deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema RedirectUrl) - -instance ToByteString RedirectUrl where - builder = serializeURIRef . unRedirectUrl - -instance FromByteString RedirectUrl where - parser = RedirectUrl <$> uriParser strictURIParserOptions - -instance ToSchema RedirectUrl where - schema = - (TE.decodeUtf8 . serializeURIRef' . unRedirectUrl) - .= (RedirectUrl <$> parsedText "RedirectUrl" (runParser (uriParser strictURIParserOptions) . TE.encodeUtf8)) - -instance ToHttpApiData RedirectUrl where - toUrlPiece = TE.decodeUtf8With TErr.lenientDecode . toHeader - toHeader = serializeURIRef' . unRedirectUrl - -instance FromHttpApiData RedirectUrl where - parseUrlPiece = parseHeader . TE.encodeUtf8 - parseHeader = bimap (T.pack . show) RedirectUrl . parseURI strictURIParserOptions - -newtype OAuthApplicationName = OAuthApplicationName {unOAuthApplicationName :: Range 1 256 Text} - deriving (Eq, Show, Generic) - deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema OAuthApplicationName) - -instance ToSchema OAuthApplicationName where - schema = OAuthApplicationName <$> unOAuthApplicationName .= schema - -data NewOAuthClient = NewOAuthClient - { nocApplicationName :: OAuthApplicationName, - nocRedirectUrl :: RedirectUrl - } - deriving (Eq, Show, Generic) - deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema NewOAuthClient) - -instance ToSchema NewOAuthClient where - schema = - object "NewOAuthClient" $ - NewOAuthClient - <$> nocApplicationName .= field "applicationName" schema - <*> nocRedirectUrl .= field "redirectUrl" schema - -newtype OAuthClientPlainTextSecret = OAuthClientPlainTextSecret {unOAuthClientPlainTextSecret :: AsciiBase16} - deriving (Eq, Generic) - deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema OAuthClientPlainTextSecret) - -instance Show OAuthClientPlainTextSecret where - show _ = "" - -instance ToSchema OAuthClientPlainTextSecret where - schema = (toText . unOAuthClientPlainTextSecret) .= parsedText "OAuthClientPlainTextSecret" (fmap OAuthClientPlainTextSecret . validateBase16) - -instance FromHttpApiData OAuthClientPlainTextSecret where - parseQueryParam = bimap cs OAuthClientPlainTextSecret . validateBase16 . cs - -instance ToHttpApiData OAuthClientPlainTextSecret where - toQueryParam = toText . unOAuthClientPlainTextSecret - -data OAuthClientCredentials = OAuthClientCredentials - { occClientId :: OAuthClientId, - occClientSecret :: OAuthClientPlainTextSecret - } - deriving (Eq, Show, Generic) - deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema OAuthClientCredentials) - -instance ToSchema OAuthClientCredentials where - schema = - object "OAuthClientCredentials" $ - OAuthClientCredentials - <$> occClientId .= field "clientId" schema - <*> occClientSecret .= field "clientSecret" schema - -data OAuthClient = OAuthClient - { ocId :: OAuthClientId, - ocName :: OAuthApplicationName, - ocRedirectUrl :: RedirectUrl - } - deriving (Eq, Show, Generic) - deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema OAuthClient) - -instance ToSchema OAuthClient where - schema = - object "OAuthClient" $ - OAuthClient - <$> ocId .= field "clientId" schema - <*> ocName .= field "applicationName" schema - <*> ocRedirectUrl .= field "redirectUrl" schema - -data OAuthResponseType = OAuthResponseTypeCode - deriving (Eq, Show, Generic) - deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema OAuthResponseType) - -instance ToSchema OAuthResponseType where - schema :: ValueSchema NamedSwaggerDoc OAuthResponseType - schema = - enum @Text "OAuthResponseType" $ - mconcat - [ element "code" OAuthResponseTypeCode - ] - -data OAuthScope - = ConversationCreate - | ConversationCodeCreate - deriving (Eq, Show, Generic, Ord) - -instance ToByteString OAuthScope where - builder = \case - ConversationCreate -> "conversation:create" - ConversationCodeCreate -> "conversation-code:create" - -instance FromByteString OAuthScope where - parser = do - s <- parser - case s & T.toLower of - "conversation:create" -> pure ConversationCreate - "conversation-code:create" -> pure ConversationCodeCreate - _ -> fail "invalid scope" - -newtype OAuthScopes = OAuthScopes {unOAuthScopes :: Set OAuthScope} - deriving (Eq, Show, Generic) - deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema OAuthScopes) - -instance ToSchema OAuthScopes where - schema = OAuthScopes <$> (oauthScopesToText . unOAuthScopes) .= withParser schema oauthScopeParser - -oauthScopesToText :: Set OAuthScope -> Text -oauthScopesToText = T.intercalate " " . fmap (cs . toByteString') . Set.toList - -oauthScopeParser :: Text -> A.Parser (Set OAuthScope) -oauthScopeParser "" = pure Set.empty -oauthScopeParser scope = - pure $ (not . T.null) `filter` T.splitOn " " scope & maybe Set.empty Set.fromList . mapM (fromByteString' . cs) - -data NewOAuthAuthCode = NewOAuthAuthCode - { noacClientId :: OAuthClientId, - noacScope :: OAuthScopes, - noacResponseType :: OAuthResponseType, - noacRedirectUri :: RedirectUrl, - noacState :: Text - } - deriving (Eq, Show, Generic) - deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema NewOAuthAuthCode) - -instance ToSchema NewOAuthAuthCode where - schema = - object "NewOAuthAuthCode" $ - NewOAuthAuthCode - <$> noacClientId .= field "clientId" schema - <*> noacScope .= field "scope" schema - <*> noacResponseType .= field "responseType" schema - <*> noacRedirectUri .= field "redirectUri" schema - <*> noacState .= field "state" schema - -newtype OAuthAuthCode = OAuthAuthCode {unOAuthAuthCode :: AsciiBase16} - deriving (Show, Eq, Generic) - -instance ToSchema OAuthAuthCode where - schema = (toText . unOAuthAuthCode) .= parsedText "OAuthAuthCode" (fmap OAuthAuthCode . validateBase16) - -instance ToByteString OAuthAuthCode where - builder = builder . unOAuthAuthCode - -instance FromByteString OAuthAuthCode where - parser = OAuthAuthCode <$> parser - -instance FromHttpApiData OAuthAuthCode where - parseQueryParam = bimap cs OAuthAuthCode . validateBase16 . cs - -instance ToHttpApiData OAuthAuthCode where - toQueryParam = toText . unOAuthAuthCode - -data OAuthGrantType = OAuthGrantTypeAuthorizationCode - deriving (Eq, Show, Generic) - deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema OAuthGrantType) - -instance ToSchema OAuthGrantType where - schema = - enum @Text "OAuthGrantType" $ - mconcat - [ element "authorization_code" OAuthGrantTypeAuthorizationCode - ] - -instance FromByteString OAuthGrantType where - parser = do - s <- parser - case s & T.toLower of - "authorization_code" -> pure OAuthGrantTypeAuthorizationCode - _ -> fail "invalid OAuthGrantType" - -instance ToByteString OAuthGrantType where - builder = \case - OAuthGrantTypeAuthorizationCode -> "authorization_code" - -instance FromHttpApiData OAuthGrantType where - parseQueryParam = maybe (Left "invalid OAuthGrantType") pure . fromByteString . cs - -instance ToHttpApiData OAuthGrantType where - toQueryParam = cs . toByteString - -data OAuthAccessTokenRequest = OAuthAccessTokenRequest - { oatGrantType :: OAuthGrantType, - oatClientId :: OAuthClientId, - oatClientSecret :: OAuthClientPlainTextSecret, - oatCode :: OAuthAuthCode, - oatRedirectUri :: RedirectUrl - } - deriving (Eq, Show, Generic) - deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema OAuthAccessTokenRequest) - -instance ToSchema OAuthAccessTokenRequest where - schema = - object "OAuthAccessTokenRequest" $ - OAuthAccessTokenRequest - <$> oatGrantType .= field "grantType" schema - <*> oatClientId .= field "clientId" schema - <*> oatClientSecret .= field "clientSecret" schema - <*> oatCode .= field "code" schema - <*> oatRedirectUri .= field "redirectUri" schema - -instance FromForm OAuthAccessTokenRequest where - fromForm f = - OAuthAccessTokenRequest - <$> parseUnique "grant_type" f - <*> parseUnique "client_id" f - <*> parseUnique "client_secret" f - <*> parseUnique "code" f - <*> parseUnique "redirect_uri" f - -instance ToForm OAuthAccessTokenRequest where - toForm req = - Form $ - mempty - & HM.insert "grant_type" [toQueryParam (oatGrantType req)] - & HM.insert "client_id" [toQueryParam (oatClientId req)] - & HM.insert "client_secret" [toQueryParam (oatClientSecret req)] - & HM.insert "code" [toQueryParam (oatCode req)] - & HM.insert "redirect_uri" [toQueryParam (oatRedirectUri req)] - -data OAuthAccessTokenType = OAuthAccessTokenTypeBearer - deriving (Eq, Show, Generic) - deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema OAuthAccessTokenType) - -instance ToSchema OAuthAccessTokenType where - schema = - enum @Text "OAuthAccessTokenType" $ - mconcat - [ element "Bearer" OAuthAccessTokenTypeBearer - ] - -newtype OauthAccessToken = OauthAccessToken {unOauthAccessToken :: ByteString} - deriving (Show, Eq, Generic) - deriving (A.ToJSON, A.FromJSON, S.ToSchema) via Schema OauthAccessToken - -instance ToSchema OauthAccessToken where - schema = (TE.decodeUtf8 . unOauthAccessToken) .= fmap (OauthAccessToken . TE.encodeUtf8) schema - -data OAuthAccessTokenResponse = OAuthAccessTokenResponse - { oatAccessToken :: OauthAccessToken, - oatTokenType :: OAuthAccessTokenType, - oatExpiresIn :: NominalDiffTime - } - deriving (Eq, Show, Generic) - deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema OAuthAccessTokenResponse) - -instance ToSchema OAuthAccessTokenResponse where - schema = - object "OAuthAccessTokenResponse" $ - OAuthAccessTokenResponse - <$> oatAccessToken .= field "accessToken" schema - <*> oatTokenType .= field "tokenType" schema - <*> oatExpiresIn .= field "expiresIn" (fromIntegral <$> roundDiffTime .= schema) - where - roundDiffTime :: NominalDiffTime -> Int32 - roundDiffTime = round - -data OAuthClaimSet = OAuthClaimSet {jwtClaims :: ClaimsSet, scope :: OAuthScopes} - deriving (Eq, Show, Generic) - -instance HasClaimsSet OAuthClaimSet where - claimsSet f s = fmap (\a' -> s {jwtClaims = a'}) (f (jwtClaims s)) - -instance A.FromJSON OAuthClaimSet where - parseJSON = A.withObject "OAuthClaimSet" $ \o -> - OAuthClaimSet - <$> A.parseJSON (A.Object o) - <*> o A..: "scope" - -instance A.ToJSON OAuthClaimSet where - toJSON s = - ins "scope" (scope s) (A.toJSON (jwtClaims s)) - where - ins k v (A.Object o) = A.Object $ M.insert k (A.toJSON v) o - ins _ _ a = a - -------------------------------------------------------------------------------- -- API Internal -type IOAuthAPI = - Named - "create-oauth-client" - ( Summary "Register an OAuth client" - :> CanThrow 'OAuthFeatureDisabled - :> "i" - :> "oauth" - :> "clients" - :> ReqBody '[JSON] NewOAuthClient - :> Post '[JSON] OAuthClientCredentials - ) - internalOauthAPI :: ServerT IOAuthAPI (Handler r) internalOauthAPI = Named @"create-oauth-client" createNewOAuthClient @@ -382,83 +60,12 @@ internalOauthAPI = -------------------------------------------------------------------------------- -- API Public -type OAuthAPI = - Named - "get-oauth-client" - ( Summary "Get OAuth client information" - :> CanThrow 'OAuthFeatureDisabled - :> ZUser - :> "oauth" - :> "clients" - :> Capture "ClientId" OAuthClientId - :> MultiVerb - 'GET - '[JSON] - '[ ErrorResponse 'OAuthClientNotFound, - Respond 200 "OAuth client found" OAuthClient - ] - (Maybe OAuthClient) - ) - :<|> Named - "create-oauth-auth-code" - ( Summary "" - :> CanThrow 'UnsupportedResponseType - :> CanThrow 'RedirectUrlMissMatch - :> CanThrow 'OAuthClientNotFound - :> CanThrow 'OAuthFeatureDisabled - :> ZUser - :> "oauth" - :> "authorization" - :> "codes" - :> ReqBody '[JSON] NewOAuthAuthCode - :> MultiVerb - 'POST - '[JSON] - '[WithHeaders '[Header "Location" RedirectUrl] RedirectUrl (RespondEmpty 302 "Found")] - RedirectUrl - ) - :<|> Named - "create-oauth-access-token" - ( Summary "Create an OAuth access token" - :> CanThrow 'JwtError - :> CanThrow 'OAuthAuthCodeNotFound - :> CanThrow 'OAuthClientNotFound - :> CanThrow 'OAuthFeatureDisabled - :> "oauth" - :> "token" - :> ReqBody '[FormUrlEncoded] OAuthAccessTokenRequest - :> Post '[JSON] OAuthAccessTokenResponse - ) - oauthAPI :: (Member Now r, Member Jwk r) => ServerT OAuthAPI (Handler r) oauthAPI = Named @"get-oauth-client" getOAuthClient :<|> Named @"create-oauth-auth-code" createNewOAuthAuthCode :<|> Named @"create-oauth-access-token" createAccessToken --------------------------------------------------------------------------------- --- Errors - -data OAuthError - = OAuthClientNotFound - | RedirectUrlMissMatch - | UnsupportedResponseType - | JwtError - | OAuthAuthCodeNotFound - | OAuthFeatureDisabled - -type instance MapError 'OAuthClientNotFound = 'StaticError 404 "not-found" "OAuth client not found" - -type instance MapError 'RedirectUrlMissMatch = 'StaticError 400 "redirect-url-miss-match" "Redirect URL miss match" - -type instance MapError 'UnsupportedResponseType = 'StaticError 400 "unsupported-response-type" "Unsupported response type" - -type instance MapError 'JwtError = 'StaticError 500 "jwt-error" "Internal error while creating JWT" - -type instance MapError 'OAuthAuthCodeNotFound = 'StaticError 404 "not-found" "OAuth authorization code not found" - -type instance MapError 'OAuthFeatureDisabled = 'StaticError 403 "forbidden" "OAuth is disabled" - -------------------------------------------------------------------------------- -- Handlers @@ -502,17 +109,17 @@ createAccessToken req = do >>= maybe (throwStd $ errorToWai @'OAuthAuthCodeNotFound) pure oauthClient <- getOAuthClient authCodeUserId (oatClientId req) >>= maybe (throwStd $ errorToWai @'OAuthClientNotFound) pure - unlessM (verifyClientSecret (oatClientSecret req) (ocId oauthClient)) $ throwStd $ errorToWai @'OAuthClientNotFound - unless (ocRedirectUrl oauthClient == oatRedirectUri req) $ throwStd $ errorToWai @'OAuthAuthCodeNotFound - unless (authCodeCid == oatClientId req) $ throwStd $ errorToWai @'OAuthAuthCodeNotFound - unless (authCodeRedirectUrl == oatRedirectUri req) $ throwStd $ errorToWai @'OAuthAuthCodeNotFound + unlessM (verifyClientSecret (oatClientSecret req) (ocId oauthClient)) $ throwStd $ errorToWai @'InvalidClientCredentials + unless (authCodeCid == oatClientId req) $ throwStd $ errorToWai @'InvalidClientCredentials + unless (ocRedirectUrl oauthClient == oatRedirectUri req) $ throwStd $ errorToWai @'RedirectUrlMissMatch + unless (authCodeRedirectUrl == oatRedirectUri req) $ throwStd $ errorToWai @'RedirectUrlMissMatch domain <- Opt.setFederationDomain <$> view settings exp <- fromIntegral . Opt.setOAuthAccessTokenExpirationTimeSecs <$> view settings claims <- mkClaims authCodeUserId domain authCodeScopes exp fp <- view settings >>= maybe (throwStd $ errorToWai @'JwtError) pure . Opt.setOAuthJwkKeyPair key <- lift (liftSem $ Jwk.get fp) >>= maybe (throwStd $ errorToWai @'JwtError) pure - token <- OauthAccessToken . cs . encodeCompact <$> signJwtToken key claims + token <- OAuthAccessToken <$> signJwtToken key claims pure $ OAuthAccessTokenResponse token OAuthAccessTokenTypeBearer exp where mkClaims :: (Member Now r) => UserId -> Domain -> OAuthScopes -> NominalDiffTime -> (Handler r) OAuthClaimSet @@ -552,12 +159,6 @@ createAccessToken req = do rand32Bytes :: MonadIO m => m AsciiBase16 rand32Bytes = liftIO . fmap encodeBase16 $ randBytes 32 -verify :: JWK -> ByteString -> IO (Either JWTError OAuthClaimSet) -verify k s = runJOSE $ do - let audCheck = const True - jwt <- decodeCompact (cs s) - verifyJWT (defaultJWTValidationSettings audCheck) k jwt - -------------------------------------------------------------------------------- -- DB @@ -606,30 +207,3 @@ deleteOAuthAuthCode code = retry x5 . write q $ params LocalQuorum (Identity cod lookupAndDeleteOAuthAuthCode :: (MonadClient m, MonadReader Env m) => OAuthAuthCode -> m (Maybe (OAuthClientId, UserId, OAuthScopes, RedirectUrl)) lookupAndDeleteOAuthAuthCode code = lookupOAuthAuthCode code <* deleteOAuthAuthCode code - --------------------------------------------------------------------------------- --- CQL instances - -instance Cql OAuthApplicationName where - ctype = Tagged TextColumn - toCql = CqlText . fromRange . unOAuthApplicationName - fromCql (CqlText t) = checkedEither t <&> OAuthApplicationName - fromCql _ = Left "OAuthApplicationName: Text expected" - -instance Cql RedirectUrl where - ctype = Tagged BlobColumn - toCql = CqlBlob . toByteString - fromCql (CqlBlob t) = runParser parser (toStrict t) - fromCql _ = Left "RedirectUrl: Blob expected" - -instance Cql OAuthAuthCode where - ctype = Tagged AsciiColumn - toCql = CqlAscii . toText . unOAuthAuthCode - fromCql (CqlAscii t) = OAuthAuthCode <$> validateBase16 t - fromCql _ = Left "OAuthAuthCode: Ascii expected" - -instance Cql OAuthScope where - ctype = Tagged TextColumn - toCql = CqlText . cs . toByteString' - fromCql (CqlText t) = maybe (Left "invalid oauth scope") Right $ fromByteString' (cs t) - fromCql _ = Left "OAuthScope: Text expected" diff --git a/services/brig/src/Brig/API/Public.hs b/services/brig/src/Brig/API/Public.hs index 206efae1929..ee4f09a9aa2 100644 --- a/services/brig/src/Brig/API/Public.hs +++ b/services/brig/src/Brig/API/Public.hs @@ -33,7 +33,7 @@ import qualified Brig.API.Connection as API import Brig.API.Error import Brig.API.Handler import Brig.API.MLS.KeyPackages -import Brig.API.OAuth (OAuthAPI, oauthAPI) +import Brig.API.OAuth (oauthAPI) import qualified Brig.API.Properties as API import Brig.API.Public.Swagger import Brig.API.Types @@ -117,6 +117,7 @@ import qualified Wire.API.Properties as Public import qualified Wire.API.Routes.MultiTablePaging as Public import Wire.API.Routes.Named (Named (Named)) import Wire.API.Routes.Public.Brig +import Wire.API.Routes.Public.Brig.OAuth (OAuthAPI) import qualified Wire.API.Routes.Public.Cannon as CannonAPI import qualified Wire.API.Routes.Public.Cargohold as CargoholdAPI import qualified Wire.API.Routes.Public.Galley as GalleyAPI diff --git a/services/brig/src/Brig/Options.hs b/services/brig/src/Brig/Options.hs index de2e62ab449..0cceca668ec 100644 --- a/services/brig/src/Brig/Options.hs +++ b/services/brig/src/Brig/Options.hs @@ -893,7 +893,8 @@ Lens.makeLensesFor ("setRestrictUserCreation", "restrictUserCreation"), ("setEnableMLS", "enableMLS"), ("setOAuthEnabledInternal", "oauthEnabledInternal"), - ("setOAuthAuthCodeExpirationTimeSecsInternal", "oauthAuthCodeExpirationTimeSecsInternal") + ("setOAuthAuthCodeExpirationTimeSecsInternal", "oauthAuthCodeExpirationTimeSecsInternal"), + ("setOAuthAccessTokenExpirationTimeSecsInternal", "oauthAccessTokenExpirationTimeSecsInternal") ] ''Settings diff --git a/services/brig/src/Brig/Run.hs b/services/brig/src/Brig/Run.hs index 6656a228166..9eba2ffd62c 100644 --- a/services/brig/src/Brig/Run.hs +++ b/services/brig/src/Brig/Run.hs @@ -28,7 +28,6 @@ import Brig.API (sitemap) import Brig.API.Federation import Brig.API.Handler import qualified Brig.API.Internal as IAPI -import Brig.API.OAuth (IOAuthAPI, OAuthAPI) import Brig.API.Public (DocsAPI, docsAPI, servantSitemap) import qualified Brig.API.User as API import Brig.AWS (amazonkaEnv, sesQueue) @@ -37,6 +36,7 @@ import qualified Brig.AWS.SesNotification as SesNotification import Brig.App import qualified Brig.Calling as Calling import Brig.CanonicalInterpreter +import Brig.Effects.Jwk (readJwk) import Brig.Effects.UserPendingActivationStore (UserPendingActivation (UserPendingActivation), UserPendingActivationStore) import qualified Brig.Effects.UserPendingActivationStore as UsersPendingActivationStore import qualified Brig.InternalEvent.Process as Internal @@ -49,8 +49,10 @@ import Control.Exception.Safe (catchAny) import Control.Lens (view, (.~), (^.)) import Control.Monad.Catch (MonadCatch, finally) import Control.Monad.Random (randomRIO) +import Crypto.JWT import qualified Data.Aeson as Aeson import Data.Default (Default (def)) +import Data.Domain (Domain (..)) import Data.Id (RequestId (..)) import Data.Metrics.AWS (gaugeTokenRemaing) import qualified Data.Metrics.Servant as Metrics @@ -69,13 +71,15 @@ import Network.Wai.Utilities (lookupRequestId) import Network.Wai.Utilities.Server import qualified Network.Wai.Utilities.Server as Server import Polysemy (Members) -import Servant (Context ((:.)), (:<|>) (..)) +import Servant (Context ((:.)), HasServer (hoistServerWithContext), ServerT, (:<|>) (..)) import qualified Servant import System.Logger (msg, val, (.=), (~~)) import System.Logger.Class (MonadLogger, err) import Util.Options import Wire.API.Routes.API +import Wire.API.Routes.Internal.Brig.OAuth import Wire.API.Routes.Public.Brig +import Wire.API.Routes.Public.Brig.OAuth import Wire.API.Routes.Version import Wire.API.Routes.Version.Wai import qualified Wire.Sem.Paging as P @@ -118,7 +122,8 @@ run o = do mkApp :: Opts -> IO (Wai.Application, Env) mkApp o = do e <- newEnv o - pure (middleware e $ \reqId -> servantApp (e & requestId .~ reqId), e) + mJwk <- join <$> forM (setOAuthJwkKeyPair $ view settings e) readJwk + pure (middleware e $ \reqId -> servantApp mJwk (e & requestId .~ reqId), e) where rtree :: Tree (App (Handler BrigCanonicalEffects)) rtree = compile sitemap @@ -134,20 +139,28 @@ mkApp o = do app e r k = runHandler e r (Server.route rtree r k) k -- the servant API wraps the one defined using wai-routing - servantApp :: Env -> Wai.Application - servantApp e = + servantApp :: Maybe JWK -> Env -> Wai.Application + servantApp mJwk e = let localDomain = view (settings . federationDomain) e in Servant.serveWithContext (Proxy @ServantCombinedAPI) - (customFormatters :. localDomain :. Servant.EmptyContext) + (mJwk :. customFormatters :. localDomain :. Servant.EmptyContext) ( docsAPI - :<|> hoistServerWithDomain @(BrigAPI :<|> OAuthAPI) (toServantHandler e) servantSitemap + :<|> hoistServerWithContext' @(BrigAPI :<|> OAuthAPI) (toServantHandler e) servantSitemap :<|> hoistServerWithDomain @(IAPI.API :<|> IOAuthAPI) (toServantHandler e) IAPI.servantSitemap :<|> hoistServerWithDomain @FederationAPI (toServantHandler e) federationSitemap :<|> hoistServerWithDomain @VersionAPI (toServantHandler e) versionAPI :<|> Servant.Tagged (app e) ) +hoistServerWithContext' :: + forall api m n. + HasServer api '[Domain, Maybe JWK] => + (forall x. m x -> n x) -> + ServerT api m -> + ServerT api n +hoistServerWithContext' = hoistServerWithContext (Proxy @api) (Proxy @'[Domain, Maybe JWK]) + type ServantCombinedAPI = ( DocsAPI :<|> (BrigAPI :<|> OAuthAPI) diff --git a/services/brig/test/integration/API/OAuth.hs b/services/brig/test/integration/API/OAuth.hs index aa6f3f028a5..08f2a46f17e 100644 --- a/services/brig/test/integration/API/OAuth.hs +++ b/services/brig/test/integration/API/OAuth.hs @@ -19,14 +19,13 @@ module API.OAuth where import Bilge import Bilge.Assert -import Brig.API.OAuth import Brig.Effects.Jwk (readJwk) import Brig.Options import qualified Brig.Options as Opt import Control.Lens import Control.Monad.Catch (MonadCatch) -import Crypto.JOSE (JWK) -import Crypto.JWT (Audience (Audience), NumericDate (NumericDate), claimAud, claimExp, claimIat, claimIss, claimSub, stringOrUri) +import Crypto.JOSE (JWK, bestJWSAlg, newJWSHeader, runJOSE) +import Crypto.JWT (Audience (Audience), JWTError, NumericDate (NumericDate), SignedJWT, claimAud, claimExp, claimIat, claimIss, claimSub, signJWT, stringOrUri) import qualified Data.Aeson as A import Data.ByteString.Conversion (fromByteString, fromByteString', toByteString') import Data.Domain (domainText) @@ -37,15 +36,21 @@ import Data.String.Conversions (cs) import Data.Text.Ascii (encodeBase16) import Data.Time import Imports +import Network.HTTP.Types (HeaderName) import qualified Network.Wai.Utilities as Error +import Servant.API (ToHttpApiData (toHeader)) import Test.Tasty import Test.Tasty.HUnit import URI.ByteString import Util import Web.FormUrlEncoded +import Wire.API.OAuth +import Wire.API.Routes.Bearer (Bearer (Bearer)) +import Wire.API.User (SelfProfile, User (userId), userEmail) +import Wire.API.User.Auth (CookieType (PersistentCookie)) -tests :: Manager -> Brig -> Opts -> TestTree -tests m b o = do +tests :: Manager -> Brig -> Nginz -> Opts -> TestTree +tests m b n o = do testGroup "oauth" $ [ test m "register new oauth client" $ testRegisterNewOAuthClient b, testGroup "create oauth code" $ @@ -66,6 +71,15 @@ tests m b o = do test m "get client info" $ testGetOAuthClientInfoAccessDeniedWhenDisabled o b, test m "create code" $ testCreateCodeOAuthClientAccessDeniedWhenDisabled o b, test m "create token" $ testCreateAccessTokenAccessDeniedWhenDisabled o b + ], + testGroup "accessing a resource" $ + [ test m "success (internal," $ testAccessResourceSuccessInternal b, + test m "success (nginz)" $ testAccessResourceSuccessNginz b n, + test m "insufficient scope" $ testAccessResourceInsufficientScope b, + test m "expired token" $ testAccessResourceExpiredToken o b, + test m "nonsense token" $ testAccessResourceNonsenseToken b, + test m "no token" $ testAccessResourceNoToken b, + test m "invalid signature" $ testAccessResourceInvalidSignature o b ] ] @@ -125,9 +139,9 @@ testCreateOAuthCodeClientNotFound brig = do testCreateAccessTokenSuccess :: Opt.Opts -> Brig -> Http () testCreateAccessTokenSuccess opts brig = do now <- liftIO getCurrentTime - uid <- randomId + uid <- userId <$> createUser "alice" brig let redirectUrl = fromMaybe (error "invalid url") $ fromByteString' "https://example.com" - let scopes = OAuthScopes $ Set.fromList [ConversationCreate, ConversationCodeCreate] + let scopes = OAuthScopes $ Set.fromList [SelfRead] (cid, secret, code) <- generateOAuthClientAndAuthCode brig uid scopes redirectUrl let accessTokenRequest = OAuthAccessTokenRequest OAuthGrantTypeAuthorizationCode cid secret code redirectUrl accessToken <- createOAuthAccessToken brig accessTokenRequest @@ -135,9 +149,9 @@ testCreateAccessTokenSuccess opts brig = do createOAuthAccessToken' brig accessTokenRequest !!! do const 404 === statusCode const (Just "not-found") === fmap Error.label . responseJsonMaybe - k <- liftIO $ readJwk (fromMaybe "" (Opt.setOAuthJwkKeyPair $ Opt.optSettings opts)) <&> fromMaybe (error "invalid key") - verifiedOrError <- liftIO $ verify k (cs $ unOauthAccessToken $ oatAccessToken accessToken) - verifiedOrErrorWithWrongKey <- liftIO $ verify wrongKey (cs $ unOauthAccessToken $ oatAccessToken accessToken) + k <- liftIO $ readJwk (fromMaybe "path to jwk not set" (Opt.setOAuthJwkKeyPair $ Opt.optSettings opts)) <&> fromMaybe (error "invalid key") + verifiedOrError <- liftIO $ verify k (unOAuthAccessToken $ oatAccessToken accessToken) + verifiedOrErrorWithWrongKey <- liftIO $ verify wrongKey (unOAuthAccessToken $ oatAccessToken accessToken) let expectedDomain = domainText $ Opt.setFederationDomain $ Opt.optSettings opts liftIO $ do isRight verifiedOrError @?= True @@ -173,8 +187,8 @@ testCreateAccessTokenWrongClientSecret brig = do let secret = OAuthClientPlainTextSecret $ encodeBase16 "ee2316e304f5c318e4607d86748018eb9c66dc4f391c31bcccd9291d24b4c7e" let accessTokenRequest = OAuthAccessTokenRequest OAuthGrantTypeAuthorizationCode cid secret code redirectUrl createOAuthAccessToken' brig accessTokenRequest !!! do - const 404 === statusCode - const (Just "not-found") === fmap Error.label . responseJsonMaybe + const 403 === statusCode + const (Just "forbidden") === fmap Error.label . responseJsonMaybe testCreateAccessTokenWrongAuthCode :: Brig -> Http () testCreateAccessTokenWrongAuthCode brig = do @@ -197,8 +211,8 @@ testCreateAccessTokenWrongUrl brig = do let wrongUrl = fromMaybe (error "invalid url") $ fromByteString' "https://example.com" let accessTokenRequest = OAuthAccessTokenRequest OAuthGrantTypeAuthorizationCode cid secret code wrongUrl createOAuthAccessToken' brig accessTokenRequest !!! do - const 404 === statusCode - const (Just "not-found") === fmap Error.label . responseJsonMaybe + const 400 === statusCode + const (Just "redirect-url-miss-match") === fmap Error.label . responseJsonMaybe testCreateAccessTokenExpiredCode :: Opt.Opts -> Brig -> Http () testCreateAccessTokenExpiredCode opts brig = @@ -249,9 +263,105 @@ assertAccessDenied = do const 403 === statusCode const (Just "forbidden") === fmap Error.label . responseJsonMaybe +testAccessResourceSuccessInternal :: Brig -> Http () +testAccessResourceSuccessInternal brig = do + uid <- userId <$> createUser "alice" brig + let redirectUrl = fromMaybe (error "invalid url") $ fromByteString' "https://example.com" + let scopes = OAuthScopes $ Set.fromList [SelfRead] + (cid, secret, code) <- generateOAuthClientAndAuthCode brig uid scopes redirectUrl + let accessTokenRequest = OAuthAccessTokenRequest OAuthGrantTypeAuthorizationCode cid secret code redirectUrl + accessToken <- createOAuthAccessToken brig accessTokenRequest + -- should succeed with Z-User header + response :: SelfProfile <- responseJsonError =<< get (brig . paths ["self"] . zUser uid) Nginz -> Http () +testAccessResourceSuccessNginz brig nginz = do + -- with ZAuth header + user <- createUser "alice" brig + let email = fromMaybe (error "no email") $ userEmail user + zauthToken <- decodeToken <$> (login nginz (defEmailLogin email) PersistentCookie toByteString' zauthToken)) !!! const 200 === statusCode + + -- with Authorization header containing an OAuth bearer token + let redirectUrl = fromMaybe (error "invalid url") $ fromByteString' "https://example.com" + let scopes = OAuthScopes $ Set.fromList [SelfRead] + (cid, secret, code) <- generateOAuthClientAndAuthCode brig (userId user) scopes redirectUrl + let accessTokenRequest = OAuthAccessTokenRequest OAuthGrantTypeAuthorizationCode cid secret code redirectUrl + oauthToken <- oatAccessToken <$> createOAuthAccessToken brig accessTokenRequest + get (nginz . paths ["self"] . authHeader oauthToken) !!! const 200 === statusCode + +testAccessResourceInsufficientScope :: Brig -> Http () +testAccessResourceInsufficientScope brig = do + uid <- userId <$> createUser "alice" brig + let redirectUrl = fromMaybe (error "invalid url") $ fromByteString' "https://example.com" + let scopes = OAuthScopes $ Set.fromList [ConversationCreate] + (cid, secret, code) <- generateOAuthClientAndAuthCode brig uid scopes redirectUrl + let accessTokenRequest = OAuthAccessTokenRequest OAuthGrantTypeAuthorizationCode cid secret code redirectUrl + accessToken <- createOAuthAccessToken brig accessTokenRequest + get (brig . paths ["self"] . zOAuthHeader (oatAccessToken accessToken)) !!! do + const 403 === statusCode + const "Access denied" === statusMessage + const (Just "Insufficient scope") === responseBody + +testAccessResourceExpiredToken :: Opt.Opts -> Brig -> Http () +testAccessResourceExpiredToken opts brig = + withSettingsOverrides (opts & Opt.optionSettings . Opt.oauthAccessTokenExpirationTimeSecsInternal ?~ 1) $ do + uid <- userId <$> createUser "alice" brig + let redirectUrl = fromMaybe (error "invalid url") $ fromByteString' "https://example.com" + let scopes = OAuthScopes $ Set.fromList [SelfRead] + (cid, secret, code) <- generateOAuthClientAndAuthCode brig uid scopes redirectUrl + let accessTokenRequest = OAuthAccessTokenRequest OAuthGrantTypeAuthorizationCode cid secret code redirectUrl + accessToken <- createOAuthAccessToken brig accessTokenRequest + liftIO $ threadDelay (1 * 1200 * 1000) + get (brig . paths ["self"] . zOAuthHeader (oatAccessToken accessToken)) !!! do + const 403 === statusCode + const "Access denied" === statusMessage + const (Just "Invalid token: JWTExpired") === responseBody + +testAccessResourceNonsenseToken :: Brig -> Http () +testAccessResourceNonsenseToken brig = do + get (brig . paths ["self"] . zOAuthHeader @Text "foo") !!! do + const 403 === statusCode + const "Access denied" === statusMessage + const (Just "Invalid token: Failed reading: JWSError") =~= responseBody + +testAccessResourceNoToken :: Brig -> Http () +testAccessResourceNoToken brig = + get (brig . paths ["self"]) !!! do + const 403 === statusCode + const "Access denied" === statusMessage + +testAccessResourceInvalidSignature :: Opt.Opts -> Brig -> Http () +testAccessResourceInvalidSignature opts brig = do + uid <- userId <$> createUser "alice" brig + let redirectUrl = fromMaybe (error "invalid url") $ fromByteString' "https://example.com" + let scopes = OAuthScopes $ Set.fromList [SelfRead] + (cid, secret, code) <- generateOAuthClientAndAuthCode brig uid scopes redirectUrl + let accessTokenRequest = OAuthAccessTokenRequest OAuthGrantTypeAuthorizationCode cid secret code redirectUrl + accessToken <- createOAuthAccessToken brig accessTokenRequest + key <- liftIO $ readJwk (fromMaybe "path to jwk not set" (Opt.setOAuthJwkKeyPair $ Opt.optSettings opts)) <&> fromMaybe (error "invalid key") + claimSet <- fromRight (error "token invalid") <$> liftIO (verify key (unOAuthAccessToken $ oatAccessToken accessToken)) + tokenSignedWithWrongKey <- signJwtToken wrongKey claimSet + get (brig . paths ["self"] . zOAuthHeader (OAuthAccessToken tokenSignedWithWrongKey)) !!! do + const 403 === statusCode + const "Access denied" === statusMessage + const (Just "Invalid token: JWSError JWSInvalidSignature") === responseBody + ------------------------------------------------------------------------------- -- Util +authHeader :: ToHttpApiData a => a -> Request -> Request +authHeader = bearer "Authorization" + +zOAuthHeader :: ToHttpApiData a => a -> Request -> Request +zOAuthHeader = bearer "Z-OAuth" + +bearer :: ToHttpApiData a => HeaderName -> a -> Request -> Request +bearer name = header name . toHeader . Bearer + newOAuthClientRequestBody :: Text -> Text -> NewOAuthClient newOAuthClientRequestBody name url = let redirectUrl = fromMaybe (error "invalid url") $ fromByteString' (cs url) @@ -300,5 +410,15 @@ generateOAuthClientAndAuthCode brig uid scope url = do getQueryParamValue :: ByteString -> RedirectUrl -> Maybe ByteString getQueryParamValue key uri = snd <$> find ((== key) . fst) (getQueryParams uri) +signJwtToken :: JWK -> OAuthClaimSet -> Http SignedJWT +signJwtToken key claims = do + jwtOrError <- liftIO $ doSignClaims + either (const $ error "jwt error") pure jwtOrError + where + doSignClaims :: IO (Either JWTError SignedJWT) + doSignClaims = runJOSE $ do + algo <- bestJWSAlg key + signJWT key (newJWSHeader ((), algo)) claims + wrongKey :: JWK wrongKey = fromMaybe (error "invalid jwk") $ A.decode "{\"p\":\"-Ahl1aNMOqXLUtJHVO1OLGt92EOrjzcNlwB5AL9hp8-GykJIK6BIfDvCCJgDUX-8ZZ-1R485XFVtUiI5W72MKbJ-qicTB7Smzd7St_zO6PZUbkgQoJiosAOMjP_8DBs9CbMl9FqUfE1pNo4O0gYHslUoCKwS5IsAB9HjuHGEQ38\",\"kty\":\"RSA\",\"q\":\"qRih0wBK2xg2wyJcBN6dDpUHTBxNEt8jxmvy33oMU-_Vx0hFLVeAqDYK-awlHGtJQJKp1mXdURXocKXKPukVitnfEH8nvl6vQIr4-uXyENe3yLgADi8VRDZCbWuDVWYAlYlFgdNODZ_A_fIqCmGAw27bwXyZZ3IRusnipyFN6iM\",\"d\":\"L0uBKJrI4I-_X9KPQawrLDEnPT7msevOH5Rf264CPZgwe8B9M0mbGmhIzYFIThNSaEzGoEtyJdTf27zoawh3O3KQO0aJr2HKSCTMZUh7fpqIjYlu5jA_dT3k7yHHMIR4lRLQV0vb936Mu09kTkRqMZ0jSo46dJ5iw0wnuSF0dAiqVG0rSJK-gVBdIbzZYxhSBW4ZF3n4CqtFb6lc1stfZHcnzWHyF6Cofzup6pJumeFe7xXF9-aGU-3UcTSzTnMa21NVP-vT2CXkH8dSfwLI-PuJwlW6tcpBwT2PXrCGyAGqQ3h5cdAmwcgfbla8wqrzj1A08SlkKHvTDixVvnnzpQ\",\"e\":\"AQAB\",\"use\":\"sig\",\"kid\":\"0makAydOdX3vNv4YTToO45ccQUCOoLisvAFVyhiKA4c\",\"qi\":\"phNbA_tiDLQq1omVgM1dHtOe6Dd7J_ZoRdz1Rmc4uaSQyJe-yn88DxXlX10DJkM9uqyzcojOtD5awBUXgYSzmasZvcZ0e2XNi7iXmSwsggTux3lUVVqKWV8HreaSywJ-HqitxjitooWSWOyD9o8yq9RS4r2QdXyuCfthwnEZdpc\",\"dp\":\"q0IJJmjZYolFiYsdq5sq5erWerPGyl0l6gRuiECcqiTVmeQINu81_Wm5gPuNFwHO0JBkt-NBpOprUFHHLvwCwmu3n77ZGfH3VqCq-FT7fMlQ5NCngmvF1bqtmlHJ84X_MCpdY4oDioxcwEl4HDYDrHO17774UItVWxDmXl0rCPs\",\"alg\":\"PS512\",\"dq\":\"NznQQDVsPTofSIPEQeLisIyDoZvsoCk4ael_nPUjaZZ-32L_FNvrLQTZeMl8JVf0yJ4d0ePa8EyTaZb8AqflXT_i1mRw-n-6BP5earMG5_FMGMXfXsKJ04lVEJ94eT-jGTOH--qjJ1fxk_6vNEy73RgrtXmYMGzU1Yhx-duqsrk\",\"n\":\"o9VozUwUc1mQMrAH2fEna_ihmNa3CVRzK7MUgDHEbfY0T71wREpK4f4fOkDysKIqnmMdxRzJhsXTDpxX8_8AlKcimPgR8Qb2z7GwDsnDZOdgAYrZ7l7gj0nX02IX35MBk7a7tWr0nILFLV9SxEu6UFcZo0bL2Rhck81TRqLbomJpIzAq8VCS8uMQeg6hEMarl9tGvSKyFuMdTCV3JE9dSv_NErAWx7uBIgkai3Imjs4ufatvRsi9ZHaUV5V3NtrFbYDulg-GOH1eXZwnO6UrKgcAdB3nS1WKL-vcxqupceAHeFHRjARm6AV07hJyXVOVHxdffv6BFX5GihFPFvpQXQ\"}" diff --git a/services/brig/test/integration/Main.hs b/services/brig/test/integration/Main.hs index e066ca66cfb..5a2ee04bec4 100644 --- a/services/brig/test/integration/Main.hs +++ b/services/brig/test/integration/Main.hs @@ -159,7 +159,7 @@ runTests iConf brigOpts otherArgs = do versionApi = API.Version.tests mg brigOpts b mlsApi = MLS.tests mg b brigOpts - let oauthAPI = API.OAuth.tests mg b brigOpts + let oauthAPI = API.OAuth.tests mg b n brigOpts withArgs otherArgs . defaultMain $ testGroup diff --git a/services/nginz/integration-test/conf/nginz/common_response_with_zauth.conf b/services/nginz/integration-test/conf/nginz/common_response_with_zauth.conf index 97bfb043d8d..ebb5d1d467a 100644 --- a/services/nginz/integration-test/conf/nginz/common_response_with_zauth.conf +++ b/services/nginz/integration-test/conf/nginz/common_response_with_zauth.conf @@ -1,2 +1 @@ include common_response.conf; - proxy_set_header Authorization ""; diff --git a/services/nginz/integration-test/conf/nginz/common_response_with_zauth_oauth.conf b/services/nginz/integration-test/conf/nginz/common_response_with_zauth_oauth.conf new file mode 100644 index 00000000000..aea96ff11af --- /dev/null +++ b/services/nginz/integration-test/conf/nginz/common_response_with_zauth_oauth.conf @@ -0,0 +1,2 @@ + oauth on; + include common_response.conf; diff --git a/services/nginz/integration-test/conf/nginz/nginx.conf b/services/nginz/integration-test/conf/nginz/nginx.conf index 30214299a99..4f7e4728b80 100644 --- a/services/nginz/integration-test/conf/nginz/nginx.conf +++ b/services/nginz/integration-test/conf/nginz/nginx.conf @@ -227,7 +227,7 @@ http { ## brig authenticated endpoints location ~* ^(/v[0-9]+)?/self$ { - include common_response_with_zauth.conf; + include common_response_with_zauth_oauth.conf; proxy_pass http://brig; } diff --git a/services/nginz/third_party/nginx-zauth-module/zauth_module.c b/services/nginz/third_party/nginx-zauth-module/zauth_module.c index c9d2ce35405..562500368aa 100644 --- a/services/nginz/third_party/nginx-zauth-module/zauth_module.c +++ b/services/nginz/third_party/nginx-zauth-module/zauth_module.c @@ -6,6 +6,16 @@ #include #include +typedef struct { + ZauthKeystore * keystore; + ZauthAcl * acl; +} ZauthServerConf; + +typedef struct { + ngx_flag_t toggle; + ngx_flag_t oauth; +} ZauthLocationConf; + // Configuration setup static void * create_srv_conf (ngx_conf_t *); static void * create_loc_conf (ngx_conf_t *); @@ -34,14 +44,11 @@ static ngx_int_t zauth_token_typeinfo (ngx_http_request_t *, ngx_http_variable_v static ngx_int_t zauth_set_var (ngx_pool_t *, ngx_http_variable_value_t *, Range); static void zauth_empty_val (ngx_http_variable_value_t *); -typedef struct { - ZauthKeystore * keystore; - ZauthAcl * acl; -} ZauthServerConf; - -typedef struct { - ngx_flag_t toggle; -} ZauthLocationConf; +// Utility functions +static ngx_int_t zauth_handle_zauth_request (ngx_http_request_t *, const ZauthServerConf *); +static ngx_int_t empty_authorization_header_in_headers_in(ngx_http_request_t *); +static ngx_int_t set_custom_header_in_headers_in(ngx_http_request_t *, ngx_str_t *, ngx_str_t *); +static bool zauth_is_authorized_and_allowed(ngx_http_request_t *); static ngx_http_module_t zauth_module_ctx = { zauth_variables // pre-configuration @@ -63,6 +70,14 @@ static ngx_command_t zauth_commands [] = { , NULL } + , { ngx_string ("oauth") + , NGX_HTTP_LOC_CONF | NGX_CONF_TAKE1 + , ngx_conf_set_flag_slot + , NGX_HTTP_LOC_CONF_OFFSET + , offsetof (ZauthLocationConf, oauth) + , NULL + } + , { ngx_string ("zauth_keystore") , NGX_HTTP_SRV_CONF | NGX_CONF_TAKE1 , load_keystore @@ -167,6 +182,7 @@ static void * create_loc_conf (ngx_conf_t * conf) { } lc->toggle = NGX_CONF_UNSET; + lc->oauth = NGX_CONF_UNSET; return lc; } @@ -175,6 +191,7 @@ static char * merge_loc_conf (ngx_conf_t * _, void * pc, void * cc) { ZauthLocationConf * parent = pc; ZauthLocationConf * child = cc; ngx_conf_merge_off_value(child->toggle, parent->toggle, 1); + ngx_conf_merge_off_value(child->oauth, parent->oauth, 0); return NGX_CONF_OK; } @@ -254,10 +271,70 @@ static ngx_int_t zauth_handle_request (ngx_http_request_t * r) { ZauthLocationConf const * lc = ngx_http_get_module_loc_conf(r, zauth_module); + // if zauth is off (used for unauthenticated endpoints) we do not need to handle oauth if (lc == NULL || lc->toggle != 1) { return NGX_DECLINED; } + // let's try to handle zauth + ngx_int_t status = zauth_handle_zauth_request(r, sc); + + // if parsing the token fails, + // and oauth is enabled, + // we try to set the Z-OAuth header, + // and empty the Authorization header + if (status != NGX_OK && status != NGX_HTTP_FORBIDDEN && lc->oauth == 1) { + if (r->headers_in.authorization == NULL) { + return NGX_ERROR; + } + ngx_str_t hdr = r->headers_in.authorization->value; + if (strncmp((char const *) hdr.data, "Bearer ", 7) != 0) { + return NGX_ERROR; + } + ngx_str_t z_oauth_hdr_name = ngx_string("Z-OAuth"); + ngx_int_t res = set_custom_header_in_headers_in(r, &z_oauth_hdr_name, &hdr); + if (res != NGX_OK) { + return NGX_ERROR; + } + res = empty_authorization_header_in_headers_in(r); + if (res != NGX_OK) { + return NGX_ERROR; + } + return NGX_DECLINED; + } + // if zauth succeeds, we empty the Authorization header + else if (status == NGX_OK) { + return empty_authorization_header_in_headers_in(r); + } + // in all other cases (which should only be errors) we return the status + else { + return status; + } +} + +ngx_int_t empty_authorization_header_in_headers_in(ngx_http_request_t *r) { + ngx_table_elt_t * h = r->headers_in.authorization; + if (h == NULL) { + return NGX_OK; + } + ngx_str_t value = ngx_string(""); + h->value = value; + h->hash = 1; + return NGX_OK; +} + +ngx_int_t set_custom_header_in_headers_in(ngx_http_request_t *r, ngx_str_t *key, ngx_str_t *value) { + ngx_table_elt_t *h = ngx_list_push(&r->headers_in.headers); + if (h == NULL) { + return NGX_ERROR; + } + h->key = *key; + h->value = *value; + h->hash = 1; + return NGX_OK; +} + +static ngx_int_t zauth_handle_zauth_request (ngx_http_request_t * r, const ZauthServerConf * sc) { ZauthToken const * tkn = ngx_http_get_module_ctx(r, zauth_module); // internal redirects clear module contexts => try to parse again @@ -334,7 +411,17 @@ static ngx_int_t zauth_parse_request (ngx_http_request_t * r) { } if (res != ZAUTH_OK) { - ngx_log_error(NGX_LOG_NOTICE, r->connection->log, 0, "failed to parse token [%d]", res); + ZauthLocationConf const *lc = ngx_http_get_module_loc_conf(r, zauth_module); + + // if parsing the request failed (res != ZAUTH_OK) and... + if (lc == NULL || // no location config + lc->oauth == 0 || // or oauth disabled + r->headers_in.authorization == NULL || // or no authorization header + strncmp((char const *)r->headers_in.authorization->value.data, "Bearer ", 7) == 0) // or not a bearer token + { + // ... then we produce a log entry (otherwise the request will be handled by wire-server as an oauth request) + ngx_log_error(NGX_LOG_NOTICE, r->connection->log, 0, "failed to parse token [%d]", res); + } } return NGX_OK; @@ -455,11 +542,45 @@ static ngx_int_t zauth_token_typeinfo (ngx_http_request_t * r, ngx_http_variable } } +static bool zauth_is_authorized_and_allowed(ngx_http_request_t * r) { + ZauthToken const * t = ngx_http_get_module_ctx(r, zauth_module); + + if (t == NULL) { + return false; + } + + if (zauth_token_verification(t) != ZAUTH_TOKEN_VERIFICATION_SUCCESS) { + return false; + } + + ZauthServerConf const * sc = + ngx_http_get_module_srv_conf(r, zauth_module); + + if (sc == NULL || sc->acl == NULL) { + return false; + } + + uint8_t is_allowed = 0; + + ngx_int_t res = zauth_token_allowed(t, sc->acl, r->uri.data, r->uri.len, &is_allowed); + + if (res != NGX_OK) { + return false; + } + + return is_allowed == 1; +} + static ngx_int_t zauth_token_var (ngx_http_request_t * r, ngx_http_variable_value_t * v, uintptr_t data) { ZauthToken const * t = ngx_http_get_module_ctx(r, zauth_module); if (t == NULL) { return NGX_ERROR; } + if (!zauth_is_authorized_and_allowed(r)) { + zauth_empty_val(v); + return NGX_OK; + } + return zauth_set_var(r->pool, v, zauth_token_lookup(t, data)); }