From f1f648a76fcc0e8e33ef06cd9867600164d709d7 Mon Sep 17 00:00:00 2001 From: KtorZ Date: Sat, 15 Feb 2020 00:04:45 +0100 Subject: [PATCH] add support for RFC7636 - Proof Key for Code Exchange Auth 2.0 public clients utilizing the Authorization Code Grant are susceptible to the authorization code interception attack. This specification describes the attack as well as a technique to mitigate against the threat through the use of Proof Key for Code Exchange (PKCE, pronounced "pixy"). --- elm.json | 4 + src/Internal.elm | 6 +- src/OAuth/AuthorizationCode.elm | 12 +- src/OAuth/AuthorizationCode/PKCE.elm | 598 +++++++++++++++++++++++++++ src/OAuth/Implicit.elm | 12 +- 5 files changed, 627 insertions(+), 5 deletions(-) create mode 100644 src/OAuth/AuthorizationCode/PKCE.elm diff --git a/elm.json b/elm.json index 1289208..37c7159 100644 --- a/elm.json +++ b/elm.json @@ -7,6 +7,7 @@ "exposed-modules": [ "OAuth", "OAuth.AuthorizationCode", + "OAuth.AuthorizationCode.PKCE", "OAuth.Implicit", "OAuth.ClientCredentials", "OAuth.Password", @@ -15,11 +16,14 @@ "elm-version": "0.19.0 <= v < 0.20.0", "dependencies": { "elm/browser": "1.0.1 <= v < 2.0.0", + "elm/bytes": "1.0.8 <= v < 2.0.0", "elm/core": "1.0.2 <= v < 2.0.0", "elm/html": "1.0.0 <= v < 2.0.0", "elm/http": "2.0.0 <= v < 3.0.0", "elm/json": "1.1.2 <= v < 2.0.0", "elm/url": "1.0.0 <= v < 2.0.0", + "folkertdev/elm-sha2": "1.0.0 <= v < 2.0.0", + "ivadzy/bbase64": "1.1.1 <= v < 2.0.0", "truqu/elm-base64": "2.0.4 <= v < 3.0.0" }, "test-dependencies": {} diff --git a/src/Internal.elm b/src/Internal.elm index d187e7b..e9e7662 100644 --- a/src/Internal.elm +++ b/src/Internal.elm @@ -213,7 +213,7 @@ urlAddMaybe param ms qs = makeAuthorizationUrl : ResponseType -> Authorization -> Url -makeAuthorizationUrl responseType { clientId, url, redirectUri, scope, state } = +makeAuthorizationUrl responseType { clientId, url, redirectUri, scope, state, codeChallenge } = let query = [ Builder.string "client_id" clientId @@ -222,6 +222,9 @@ makeAuthorizationUrl responseType { clientId, url, redirectUri, scope, state } = ] |> urlAddList "scope" scope |> urlAddMaybe "state" state + |> urlAddMaybe "code_challenge" codeChallenge + |> urlAddMaybe "code_challenge_method" + (Maybe.map (always "S256") codeChallenge) |> Builder.toQuery |> String.dropLeft 1 in @@ -346,6 +349,7 @@ type alias Authorization = , redirectUri : Url , scope : List String , state : Maybe String + , codeChallenge : Maybe String } diff --git a/src/OAuth/AuthorizationCode.elm b/src/OAuth/AuthorizationCode.elm index 814dd60..bc3cb48 100644 --- a/src/OAuth/AuthorizationCode.elm +++ b/src/OAuth/AuthorizationCode.elm @@ -141,8 +141,16 @@ type AuthorizationResult authorization flow. -} makeAuthorizationUrl : Authorization -> Url -makeAuthorizationUrl = - Internal.makeAuthorizationUrl Internal.Code +makeAuthorizationUrl { clientId, url, redirectUri, scope, state } = + Internal.makeAuthorizationUrl + Internal.Code + { clientId = clientId + , url = url + , redirectUri = redirectUri + , scope = scope + , state = state + , codeChallenge = Nothing + } {-| Parse the location looking for a parameters set by the resource provider server after diff --git a/src/OAuth/AuthorizationCode/PKCE.elm b/src/OAuth/AuthorizationCode/PKCE.elm new file mode 100644 index 0000000..ee7878b --- /dev/null +++ b/src/OAuth/AuthorizationCode/PKCE.elm @@ -0,0 +1,598 @@ +module OAuth.AuthorizationCode.PKCE exposing + ( CodeVerifier, CodeChallenge, codeVerifierFromBytes, codeVerifierToString, mkCodeChallenge, codeChallengeToString + , makeAuthorizationUrl, parseCode, Authorization, AuthorizationCode, AuthorizationResult(..), AuthorizationSuccess, AuthorizationError + , makeTokenRequest, Authentication, Credentials, AuthenticationSuccess, AuthenticationError, RequestParts + , defaultAuthenticationSuccessDecoder, defaultAuthenticationErrorDecoder + , defaultExpiresInDecoder, defaultScopeDecoder, lenientScopeDecoder, defaultTokenDecoder, defaultRefreshTokenDecoder, defaultErrorDecoder, defaultErrorDescriptionDecoder, defaultErrorUriDecoder + , parseCodeWith, Parsers, defaultParsers, defaultCodeParser, defaultErrorParser, defaultAuthorizationSuccessParser, defaultAuthorizationErrorParser + ) + +{-| OAuth 2.0 public clients utilizing the Authorization Code Grant are +susceptible to the authorization code interception attack. A possible +mitigation against the threat is to use a technique called Proof Key for +Code Exchange (PKCE, pronounced "pixy") when supported by the target +authorization server. + + +-------------------+ + | Authz Server | + +--------+ | +---------------+ | + | |--(A)- Authorization Request ---->| | | + | | + t(code_verifier), t_m | | Authorization | | + | | | | Endpoint | | + | |<-(B)---- Authorization Code -----| | | + | Elm | | +---------------+ | + | App | | | + | | | +---------------+ | + | |--(C)-- Access Token Request ---->| | | + | | + code_verifier | | Token | | + | | | | Endpoint | | + | |<-(D)------ Access Token ---------| | | + +--------+ | +---------------+ | + +-------------------+ + + Abstract Protocol Flow + +See also the Authorization Code flow for details about the basic version +of this flow. + + +## Code Verifier / Challenge + +@docs CodeVerifier, CodeChallenge, codeVerifierFromBytes, codeVerifierToString, mkCodeChallenge, codeChallengeToString + + +## Authorize + +@docs makeAuthorizationUrl, parseCode, Authorization, AuthorizationCode, AuthorizationResult, AuthorizationSuccess, AuthorizationError + + +## Authenticate + +@docs makeTokenRequest, Authentication, Credentials, AuthenticationSuccess, AuthenticationError, RequestParts + + +## JSON Decoders + +@docs defaultAuthenticationSuccessDecoder, defaultAuthenticationErrorDecoder + + +## JSON Decoders (advanced) + +@docs defaultExpiresInDecoder, defaultScopeDecoder, lenientScopeDecoder, defaultTokenDecoder, defaultRefreshTokenDecoder, defaultErrorDecoder, defaultErrorDescriptionDecoder, defaultErrorUriDecoder + + +## Query Parsers (advanced) + +@docs parseCodeWith, Parsers, defaultParsers, defaultCodeParser, defaultErrorParser, defaultAuthorizationSuccessParser, defaultAuthorizationErrorParser + +-} + +import Base64.Encode as Base64 +import Bytes exposing (Bytes) +import Http +import Internal as Internal exposing (..) +import Json.Decode as Json +import OAuth exposing (ErrorCode, Token, errorCodeFromString) +import SHA256 as SHA256 +import Url exposing (Url) +import Url.Builder as Builder +import Url.Parser as Url exposing (()) +import Url.Parser.Query as Query + + + +-- +-- Code Challenge / Code Verifier +-- + + +type CodeChallenge + = CodeChallenge Base64.Encoder + + +type CodeVerifier + = CodeVerifier Base64.Encoder + + +{-| Construct a code verifier from a byte sequence generated from a **high quality randomness** source (i.e. cryptographic). + +Ideally, the byte sequence _should be_ 32 or 64 bytes, and it _must be_ at least 32 bytes and at most 90 bytes. + +-} +codeVerifierFromBytes : Bytes -> Maybe CodeVerifier +codeVerifierFromBytes bytes = + if Bytes.width bytes < 32 || Bytes.width bytes > 90 then + Nothing + + else + bytes |> Base64.bytes |> CodeVerifier |> Just + + +{-| Convert a code verifier to its string representation. +-} +codeVerifierToString : CodeVerifier -> String +codeVerifierToString (CodeVerifier str) = + base64UrlEncode str + + +{-| Construct a `CodeChallenge` to send to the authorization server. Upon receiving the authorization code, the client can then +the associated `CodeVerifier` to prove it is the rightful owner of the authorization code. +-} +mkCodeChallenge : CodeVerifier -> CodeChallenge +mkCodeChallenge = + codeVerifierToString >> SHA256.fromString >> SHA256.toBytes >> Base64.bytes >> CodeChallenge + + +{-| Convert a code challenge to its string representation. +-} +codeChallengeToString : CodeChallenge -> String +codeChallengeToString (CodeChallenge str) = + base64UrlEncode str + + +{-| Internal function implementing Base64-URL encoding (i.e. base64 without padding and some unsuitable characters replaced) +-} +base64UrlEncode : Base64.Encoder -> String +base64UrlEncode = + Base64.encode + >> String.replace "=" "" + >> String.replace "+" "-" + >> String.replace "/" "_" + + + +-- +-- Authorize +-- + + +{-| Request configuration for an authorization (Authorization Code & Implicit flows) + + - clientId (_REQUIRED_): + The client identifier issues by the authorization server via an off-band mechanism. + + - url (_REQUIRED_): + The authorization endpoint to contact the authorization server. + + - redirectUri (_OPTIONAL_): + After completing its interaction with the resource owner, the authorization + server directs the resource owner's user-agent back to the client via this + URL. May be already defined on the authorization server itself. + + - scope (_OPTIONAL_): + The scope of the access request. + + - state (_RECOMMENDED_): + An opaque value used by the client to maintain state between the request + and callback. The authorization server includes this value when redirecting + the user-agent back to the client. The parameter SHOULD be used for preventing + cross-site request forgery. + + - codeChallenge (_REQUIRED_): + A challenge derived from the code verifier that is sent in the + authorization request, to be verified against later. + +-} +type alias Authorization = + { clientId : String + , url : Url + , redirectUri : Url + , scope : List String + , state : Maybe String + , codeChallenge : CodeChallenge + } + + +{-| Describes an OAuth error as a result of an authorization request failure + + - error (_REQUIRED_): + A single ASCII error code. + + - errorDescription (_OPTIONAL_) + Human-readable ASCII text providing additional information, used to assist the client developer in + understanding the error that occurred. Values for the `errorDescription` parameter MUST NOT + include characters outside the set `%x20-21 / %x23-5B / %x5D-7E`. + + - errorUri (_OPTIONAL_): + A URI identifying a human-readable web page with information about the error, used to + provide the client developer with additional information about the error. Values for the + `errorUri` parameter MUST conform to the URI-reference syntax and thus MUST NOT include + characters outside the set `%x21 / %x23-5B / %x5D-7E`. + + - state (_REQUIRED if `state` was present in the authorization request_): + The exact value received from the client + +-} +type alias AuthorizationError = + { error : ErrorCode + , errorDescription : Maybe String + , errorUri : Maybe String + , state : Maybe String + } + + +{-| The response obtained as a result of an authorization + + - code (_REQUIRED_): + The authorization code generated by the authorization server. The authorization code MUST expire + shortly after it is issued to mitigate the risk of leaks. A maximum authorization code lifetime of + 10 minutes is RECOMMENDED. The client MUST NOT use the authorization code more than once. If an + authorization code is used more than once, the authorization server MUST deny the request and + SHOULD revoke (when possible) all tokens previously issued based on that authorization code. The + authorization code is bound to the client identifier and redirection URI. + + - state (_REQUIRED if `state` was present in the authorization request_): + The exact value received from the client + +-} +type alias AuthorizationSuccess = + { code : String + , state : Maybe String + } + + +{-| Describes errors coming from attempting to parse a url after an OAuth redirection + + - Empty: means there were nothing (related to OAuth 2.0) to parse + - Error: a successfully parsed OAuth 2.0 error + - Success: a successfully parsed token and response + +-} +type AuthorizationResult + = Empty + | Error AuthorizationError + | Success AuthorizationSuccess + + +{-| Redirects the resource owner (user) to the resource provider server using the specified +authorization flow. +-} +makeAuthorizationUrl : Authorization -> Url +makeAuthorizationUrl { clientId, url, redirectUri, scope, state, codeChallenge } = + Internal.makeAuthorizationUrl + Internal.Code + { clientId = clientId + , url = url + , redirectUri = redirectUri + , scope = scope + , state = state + , codeChallenge = Just <| codeChallengeToString codeChallenge + } + + +{-| Parse the location looking for a parameters set by the resource provider server after +redirecting the resource owner (user). + +Returns `AuthorizationResult Empty` when there's nothing. + +-} +parseCode : Url -> AuthorizationResult +parseCode = + parseCodeWith defaultParsers + + + +-- +-- Query Parsers (advanced) +-- + + +{-| See `parseCode`, but gives you the ability to provide your own custom parsers. +-} +parseCodeWith : Parsers -> Url -> AuthorizationResult +parseCodeWith { codeParser, errorParser, authorizationSuccessParser, authorizationErrorParser } url_ = + let + url = + { url_ | path = "/" } + in + case Url.parse (Url.top Query.map2 Tuple.pair codeParser errorParser) url of + Just ( Just code, _ ) -> + parseUrlQuery url Empty (Query.map Success <| authorizationSuccessParser code) + + Just ( _, Just error ) -> + parseUrlQuery url Empty (Query.map Error <| authorizationErrorParser error) + + _ -> + Empty + + +{-| Parsers used in the 'parseCode' function. + + - codeParser: looks for a 'code' string + - errorParser: looks for an 'error' to build a corresponding `ErrorCode` + - authorizationSuccessParser: selected when the `tokenParser` succeeded to parse the remaining parts + - authorizationErrorParser: selected when the `errorParser` succeeded to parse the remaining parts + +-} +type alias Parsers = + { codeParser : Query.Parser (Maybe String) + , errorParser : Query.Parser (Maybe ErrorCode) + , authorizationSuccessParser : String -> Query.Parser AuthorizationSuccess + , authorizationErrorParser : ErrorCode -> Query.Parser AuthorizationError + } + + +{-| Default parsers according to RFC-6749 +-} +defaultParsers : Parsers +defaultParsers = + { codeParser = defaultCodeParser + , errorParser = defaultErrorParser + , authorizationSuccessParser = defaultAuthorizationSuccessParser + , authorizationErrorParser = defaultAuthorizationErrorParser + } + + +{-| Default 'code' parser according to RFC-6749 +-} +defaultCodeParser : Query.Parser (Maybe String) +defaultCodeParser = + Query.string "code" + + +{-| Default 'error' parser according to RFC-6749 +-} +defaultErrorParser : Query.Parser (Maybe ErrorCode) +defaultErrorParser = + errorParser errorCodeFromString + + +{-| Default response success parser according to RFC-6749 +-} +defaultAuthorizationSuccessParser : String -> Query.Parser AuthorizationSuccess +defaultAuthorizationSuccessParser code = + Query.map (AuthorizationSuccess code) + stateParser + + +{-| Default response error parser according to RFC-6749 +-} +defaultAuthorizationErrorParser : ErrorCode -> Query.Parser AuthorizationError +defaultAuthorizationErrorParser = + authorizationErrorParser + + + +-- +-- Authenticate +-- + + +{-| Request configuration for an AuthorizationCode authentication + + - credentials: + Only the clientId is required. Specify a secret if a Basic OAuth + is required by the resource provider. + + - code: + Authorization code from the authorization result + + - codeVerifier: + The code verifier proving you are the rightful recipient of the + access token. + + - url: + Token endpoint of the resource provider + + - redirectUri: + Redirect Uri to your webserver. + +-} +type alias Authentication = + { credentials : Credentials + , code : String + , codeVerifier : CodeVerifier + , redirectUri : Url + , url : Url + } + + +{-| The response obtained as a result of an authentication (implicit or not) + + - token (_REQUIRED_): + The access token issued by the authorization server. + + - refreshToken (_OPTIONAL_): + The refresh token, which can be used to obtain new access tokens using the same authorization + grant as described in [Section 6](https://tools.ietf.org/html/rfc6749#section-6). + + - expiresIn (_RECOMMENDED_): + The lifetime in seconds of the access token. For example, the value "3600" denotes that the + access token will expire in one hour from the time the response was generated. If omitted, the + authorization server SHOULD provide the expiration time via other means or document the default + value. + + - scope (_OPTIONAL, if identical to the scope requested; otherwise, REQUIRED_): + The scope of the access token as described by [Section 3.3](https://tools.ietf.org/html/rfc6749#section-3.3). + +-} +type alias AuthenticationSuccess = + { token : Token + , refreshToken : Maybe Token + , expiresIn : Maybe Int + , scope : List String + } + + +{-| Describes an OAuth error as a result of a request failure + + - error (_REQUIRED_): + A single ASCII error code. + + - errorDescription (_OPTIONAL_) + Human-readable ASCII text providing additional information, used to assist the client developer in + understanding the error that occurred. Values for the `errorDescription` parameter MUST NOT + include characters outside the set `%x20-21 / %x23-5B / %x5D-7E`. + + - errorUri (_OPTIONAL_): + A URI identifying a human-readable web page with information about the error, used to + provide the client developer with additional information about the error. Values for the + `errorUri` parameter MUST conform to the URI-reference syntax and thus MUST NOT include + characters outside the set `%x21 / %x23-5B / %x5D-7E`. + +-} +type alias AuthenticationError = + { error : ErrorCode + , errorDescription : Maybe String + , errorUri : Maybe String + } + + +{-| Parts required to build a request. This record is given to `Http.request` in order +to create a new request and may be adjusted at will. +-} +type alias RequestParts a = + { method : String + , headers : List Http.Header + , url : String + , body : Http.Body + , expect : Http.Expect a + , timeout : Maybe Float + , tracker : Maybe String + } + + +{-| Describes at least a `clientId` and if define, a complete set of credentials +with the `secret`. The secret is so-to-speak optional and depends on whether the +authorization server you interact with requires a 'Basic' authentication on top of +the authentication request. Provides it if you need to do so. + + { clientId = "" + , secret = Just "" + } + +-} +type alias Credentials = + { clientId : String + , secret : Maybe String + } + + +{-| Builds a the request components required to get a token from an authorization code + + let req : Http.Request AuthenticationSuccess + req = makeTokenRequest toMsg authentication |> Http.request + +-} +makeTokenRequest : (Result Http.Error AuthenticationSuccess -> msg) -> Authentication -> RequestParts msg +makeTokenRequest toMsg { credentials, code, codeVerifier, url, redirectUri } = + let + body = + [ Builder.string "grant_type" "authorization_code" + , Builder.string "client_id" credentials.clientId + , Builder.string "redirect_uri" (makeRedirectUri redirectUri) + , Builder.string "code" code + , Builder.string "code_verifier" (codeVerifierToString codeVerifier) + ] + |> Builder.toQuery + |> String.dropLeft 1 + + headers = + makeHeaders <| + case credentials.secret of + Nothing -> + Nothing + + Just secret -> + Just { clientId = credentials.clientId, secret = secret } + in + makeRequest toMsg url headers body + + + +-- +-- Json Decoders +-- + + +{-| Json decoder for a positive response. You may provide a custom response decoder using other decoders +from this module, or some of your own craft. + + defaultAuthenticationSuccessDecoder : Decoder AuthenticationSuccess + defaultAuthenticationSuccessDecoder = + D.map4 AuthenticationSuccess + tokenDecoder + refreshTokenDecoder + expiresInDecoder + scopeDecoder + +-} +defaultAuthenticationSuccessDecoder : Json.Decoder AuthenticationSuccess +defaultAuthenticationSuccessDecoder = + Internal.authenticationSuccessDecoder + + +{-| Json decoder for an errored response. + + case res of + Err (Http.BadStatus { body }) -> + case Json.decodeString OAuth.AuthorizationCode.defaultAuthenticationErrorDecoder body of + Ok { error, errorDescription } -> + doSomething + + _ -> + parserFailed + + _ -> + someOtherError + +-} +defaultAuthenticationErrorDecoder : Json.Decoder AuthenticationError +defaultAuthenticationErrorDecoder = + Internal.authenticationErrorDecoder defaultErrorDecoder + + +{-| Json decoder for an 'expire' timestamp +-} +defaultExpiresInDecoder : Json.Decoder (Maybe Int) +defaultExpiresInDecoder = + Internal.expiresInDecoder + + +{-| Json decoder for a 'scope' +-} +defaultScopeDecoder : Json.Decoder (List String) +defaultScopeDecoder = + Internal.scopeDecoder + + +{-| Json decoder for a 'scope', allowing comma- or space-separated scopes +-} +lenientScopeDecoder : Json.Decoder (List String) +lenientScopeDecoder = + Internal.lenientScopeDecoder + + +{-| Json decoder for an 'access\_token' +-} +defaultTokenDecoder : Json.Decoder Token +defaultTokenDecoder = + Internal.tokenDecoder + + +{-| Json decoder for a 'refresh\_token' +-} +defaultRefreshTokenDecoder : Json.Decoder (Maybe Token) +defaultRefreshTokenDecoder = + Internal.refreshTokenDecoder + + +{-| Json decoder for 'error' field +-} +defaultErrorDecoder : Json.Decoder ErrorCode +defaultErrorDecoder = + Internal.errorDecoder errorCodeFromString + + +{-| Json decoder for 'error\_description' field +-} +defaultErrorDescriptionDecoder : Json.Decoder (Maybe String) +defaultErrorDescriptionDecoder = + Internal.errorDescriptionDecoder + + +{-| Json decoder for 'error\_uri' field +-} +defaultErrorUriDecoder : Json.Decoder (Maybe String) +defaultErrorUriDecoder = + Internal.errorUriDecoder diff --git a/src/OAuth/Implicit.elm b/src/OAuth/Implicit.elm index 364d2eb..83ffde5 100644 --- a/src/OAuth/Implicit.elm +++ b/src/OAuth/Implicit.elm @@ -128,8 +128,16 @@ type AuthorizationResult authorization flow. -} makeAuthorizationUrl : Authorization -> Url -makeAuthorizationUrl = - Internal.makeAuthorizationUrl Internal.Token +makeAuthorizationUrl { clientId, url, redirectUri, scope, state } = + Internal.makeAuthorizationUrl + Internal.Token + { clientId = clientId + , url = url + , redirectUri = redirectUri + , scope = scope + , state = state + , codeChallenge = Nothing + } {-| Parses the location looking for parameters in the 'fragment' set by the