diff --git a/elm-package.json b/elm-package.json deleted file mode 100644 index 89aeba0..0000000 --- a/elm-package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "version": "2.2.1", - "summary": "OAuth 2.0 client-side utils", - "repository": "https://github.com/truqu/elm-oauth2.git", - "license": "MIT", - "source-directories": [ - "src", - "examples/implicit", - "examples/authorization_code" - ], - "exposed-modules": [ - "OAuth", - "OAuth.AuthorizationCode", - "OAuth.Implicit", - "OAuth.ClientCredentials", - "OAuth.Password", - "OAuth.Decode" - ], - "dependencies": { - "Bogdanp/elm-querystring": "1.0.0 <= v < 2.0.0", - "elm-lang/core": "5.1.1 <= v < 6.0.0", - "elm-lang/html": "2.0.0 <= v < 3.0.0", - "elm-lang/http": "1.0.0 <= v < 2.0.0", - "elm-lang/navigation": "2.1.0 <= v < 3.0.0", - "truqu/elm-base64": "2.0.0 <= v < 3.0.0" - }, - "elm-version": "0.18.0 <= v < 0.19.0" -} diff --git a/elm.json b/elm.json new file mode 100644 index 0000000..1dd177a --- /dev/null +++ b/elm.json @@ -0,0 +1,26 @@ +{ + "type": "package", + "name": "truqu/elm-oauth2", + "version": "1.0.0", + "license": "MIT", + "summary": "OAuth 2.0 client-side utils", + "exposed-modules": [ + "OAuth", + "OAuth.AuthorizationCode", + "OAuth.Implicit", + "OAuth.ClientCredentials", + "OAuth.Password", + "OAuth.Decode" + ], + "dependencies": { + "elm/url": "1.0.0 <= v < 2.0.0", + "elm/core": "1.0.0 <= v < 2.0.0", + "elm/html": "1.0.0 <= v < 2.0.0", + "elm/http": "1.0.0 <= v < 2.0.0", + "elm/browser": "1.0.0 <= v < 2.0.0", + "elm/json": "1.0.0 <= v < 2.0.0", + "truqu/elm-base64": "2.0.0 <= v < 3.0.0" + }, + "test-dependencies": {}, + "elm-version": "0.19.0 <= v < 0.20.0" +} diff --git a/src/Internal.elm b/src/Internal.elm index fe2829d..b071b99 100644 --- a/src/Internal.elm +++ b/src/Internal.elm @@ -1,26 +1,38 @@ -module Internal exposing (..) +module Internal exposing + ( authHeader + , authenticate + , authorize + , makeRequest + , parseAuthorizationCode + , parseError + , parseToken + , qsSpaceSeparatedList + , urlAddList + , urlAddMaybe + ) +import Base64 +import Browser.Navigation as Navigation +import Http as Http import OAuth exposing (..) import OAuth.Decode exposing (..) -import Http as Http -import QueryString as QS -import Navigation as Navigation -import Base64 +import Url.Builder as Url exposing (QueryParameter) +import Url.Parser.Query as Query authorize : Authorization -> Cmd msg authorize { clientId, url, redirectUri, responseType, scope, state } = let qs = - QS.empty - |> QS.add "client_id" clientId - |> QS.add "redirect_uri" redirectUri - |> QS.add "response_type" (showResponseType responseType) - |> qsAddList "scope" scope - |> qsAddMaybe "state" state - |> QS.render + [ Url.string "client_id" clientId + , Url.string "redirect_uri" redirectUri + , Url.string "response_type" (showResponseType responseType) + ] + |> urlAddList "scope" scope + |> urlAddMaybe "state" state + |> Url.toQuery in - Navigation.load (url ++ qs) + Navigation.load (url ++ qs) authenticate : AdjustRequest ResponseToken -> Authentication -> Http.Request ResponseToken @@ -29,56 +41,56 @@ authenticate adjust authentication = AuthorizationCode { credentials, code, redirectUri, scope, state, url } -> let body = - QS.empty - |> QS.add "grant_type" "authorization_code" - |> QS.add "client_id" credentials.clientId - |> QS.add "redirect_uri" redirectUri - |> QS.add "code" code - |> qsAddList "scope" scope - |> qsAddMaybe "state" state - |> QS.render + [ Url.string "grant_type" "authorization_code" + , Url.string "client_id" credentials.clientId + , Url.string "redirect_uri" redirectUri + , Url.string "code" code + ] + |> urlAddList "scope" scope + |> urlAddMaybe "state" state + |> Url.toQuery |> String.dropLeft 1 headers = authHeader <| if String.isEmpty credentials.secret then Nothing + else Just credentials in - makeRequest adjust url headers body + makeRequest adjust url headers body ClientCredentials { credentials, scope, state, url } -> let body = - QS.empty - |> QS.add "grant_type" "client_credentials" - |> qsAddList "scope" scope - |> qsAddMaybe "state" state - |> QS.render + [ Url.string "grant_type" "client_credentials" ] + |> urlAddList "scope" scope + |> urlAddMaybe "state" state + |> Url.toQuery |> String.dropLeft 1 headers = authHeader (Just { clientId = credentials.clientId, secret = credentials.secret }) in - makeRequest adjust url headers body + makeRequest adjust url headers body Password { credentials, password, scope, state, url, username } -> let body = - QS.empty - |> QS.add "grant_type" "password" - |> QS.add "username" username - |> QS.add "password" password - |> qsAddList "scope" scope - |> qsAddMaybe "state" state - |> QS.render + [ Url.string "grant_type" "password" + , Url.string "username" username + , Url.string "password" password + ] + |> urlAddList "scope" scope + |> urlAddMaybe "state" state + |> Url.toQuery |> String.dropLeft 1 headers = authHeader credentials in - makeRequest adjust url headers body + makeRequest adjust url headers body Refresh { credentials, scope, token, url } -> let @@ -88,17 +100,17 @@ authenticate adjust authentication = t body = - QS.empty - |> QS.add "grant_type" "refresh_token" - |> QS.add "refresh_token" refreshToken - |> qsAddList "scope" scope - |> QS.render + [ Url.string "grant_type" "refresh_token" + , Url.string "refresh_token" refreshToken + ] + |> urlAddList "scope" scope + |> Url.toQuery |> String.dropLeft 1 headers = authHeader credentials in - makeRequest adjust url headers body + makeRequest adjust url headers body makeRequest : AdjustRequest ResponseToken -> String -> List Http.Header -> String -> Http.Request ResponseToken @@ -114,9 +126,9 @@ makeRequest adjust url headers body = , withCredentials = False } in - requestParts - |> adjust - |> Http.request + requestParts + |> adjust + |> Http.request authHeader : Maybe Credentials -> List Http.Header @@ -140,8 +152,8 @@ parseError error errorDescription errorUri state = parseToken : String -> Maybe String -> Maybe Int -> List String -> Maybe String -> Result ParseErr ResponseToken parseToken accessToken mTokenType mExpiresIn scope state = - case ( Maybe.map String.toLower mTokenType, mExpiresIn ) of - ( Just "bearer", mExpiresIn ) -> + case Maybe.map String.toLower mTokenType of + Just "bearer" -> Ok <| { expiresIn = mExpiresIn , refreshToken = Nothing @@ -150,10 +162,10 @@ parseToken accessToken mTokenType mExpiresIn scope state = , token = Bearer accessToken } - ( Just _, _ ) -> + Just _ -> Result.Err <| Invalid [ "token_type" ] - ( Nothing, _ ) -> + Nothing -> Result.Err <| Missing [ "token_type" ] @@ -165,21 +177,30 @@ parseAuthorizationCode code state = } -qsAddList : String -> List String -> QS.QueryString -> QS.QueryString -qsAddList param xs qs = - case xs of - [] -> - qs +urlAddList : String -> List String -> List QueryParameter -> List QueryParameter +urlAddList param xs qs = + qs + ++ (case xs of + [] -> + [] - _ -> - QS.add param (String.join " " xs) qs + _ -> + [ Url.string param (String.join " " xs) ] + ) -qsAddMaybe : String -> Maybe String -> QS.QueryString -> QS.QueryString -qsAddMaybe param ms qs = - case ms of - Nothing -> - qs +urlAddMaybe : String -> Maybe String -> List QueryParameter -> List QueryParameter +urlAddMaybe param ms qs = + qs + ++ (case ms of + Nothing -> + [] + + Just s -> + [ Url.string param s ] + ) + - Just s -> - QS.add param s qs +qsSpaceSeparatedList : String -> Query.Parser (List String) +qsSpaceSeparatedList param = + Query.map (\s -> Maybe.withDefault "" s |> String.split " ") (Query.string param) diff --git a/src/OAuth.elm b/src/OAuth.elm index 0a3fcea..8466d3e 100644 --- a/src/OAuth.elm +++ b/src/OAuth.elm @@ -1,22 +1,8 @@ -module OAuth - exposing - ( Authorization - , Authentication(..) - , Credentials - , Err - , ErrCode(..) - , ParseErr(..) - , ResponseType(..) - , ResponseToken - , ResponseCode - , Token(..) - , errCodeFromString - , errDecoder - , showErrCode - , showResponseType - , showToken - , use - ) +module OAuth exposing + ( use + , Authorization, Authentication(..), Credentials, ResponseType(..), showResponseType + , ResponseToken, ResponseCode, Token(..), Err, ParseErr(..), ErrCode(..), showToken, showErrCode, errCodeFromString, errDecoder + ) {-| Utility library to manage client-side OAuth 2.0 authentications @@ -41,7 +27,7 @@ you'll only need tu use one of the additional modules: with the authorization server (the method of which is beyond the scope of this specification) [4.4](https://tools.ietf.org/html/rfc6749#section-4.3). -In practice, you most probably want to use the *OAuth.Implicit* module which is the most commonly +In practice, you most probably want to use the _OAuth.Implicit_ module which is the most commonly used. @@ -196,23 +182,23 @@ type ResponseType {-| The response obtained as a result of an authentication (implicit or not) - - expiresIn (*RECOMMENDED*): + - 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. - - refreshToken (*OPTIONAL*): + - 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). - - scope (*OPTIONAL, if identical to the scope requested; otherwise, REQUIRED*): + - 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). - - state (*REQUIRED if `state` was present in the authentication request*): + - state (_REQUIRED if `state` was present in the authentication request_): The exact value received from the client - - token (*REQUIRED*): + - token (_REQUIRED_): The access token issued by the authorization server. -} @@ -227,7 +213,7 @@ type alias ResponseToken = {-| The response obtained as a result of an authorization - - code (*REQUIRED*): + - 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 @@ -235,7 +221,7 @@ type alias ResponseToken = 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*): + - state (_REQUIRED if `state` was present in the authorization request_): The exact value received from the client -} @@ -251,6 +237,7 @@ type alias ResponseCode = - OAuthErr: a successfully parsed OAuth 2.0 error - Missing: means the OAuth provider didn't with all the required parameters for the given grant type. - Invalid: means the OAuth provider did reply with an invalid parameter for the given grant type. + - FailedToParse: means that the given URL is badly constructed -} type ParseErr @@ -258,25 +245,26 @@ type ParseErr | OAuthErr Err | Missing (List String) | Invalid (List String) + | FailedToParse {-| Describes an OAuth error as a result of a request failure - - error (*REQUIRED*): + - error (_REQUIRED_): A single ASCII error code. - - errorDescription (*OPTIONAL*) + - 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*): + - 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*): + - state (_REQUIRED if `state` was present in the authorization request_): The exact value received from the client -} diff --git a/src/OAuth/AuthorizationCode.elm b/src/OAuth/AuthorizationCode.elm index 4d72670..a74e0cf 100644 --- a/src/OAuth/AuthorizationCode.elm +++ b/src/OAuth/AuthorizationCode.elm @@ -1,10 +1,7 @@ -module OAuth.AuthorizationCode - exposing - ( authorize - , authenticate - , authenticateWithOpts - , parse - ) +module OAuth.AuthorizationCode exposing + ( authorize, parse + , authenticate, authenticateWithOpts + ) {-| The authorization code grant type is used to obtain both access tokens and refresh tokens and is optimized for confidential clients. @@ -34,12 +31,14 @@ request. -} +import Browser.Navigation as Navigation +import Http as Http +import Internal as Internal import OAuth exposing (..) import OAuth.Decode exposing (..) -import Navigation as Navigation -import Internal as Internal -import QueryString as QS -import Http as Http +import Url exposing (Url) +import Url.Parser as Url exposing (()) +import Url.Parser.Query as Query {-| Redirects the resource owner (user) to the resource provider server using the specified @@ -80,27 +79,33 @@ redirecting the resource owner (user). Fails with a `ParseErr Empty` when there's nothing -} -parse : Navigation.Location -> Result ParseErr ResponseCode -parse { search } = +parse : Url -> Result ParseErr ResponseCode +parse url_ = let - qs = - QS.parse search - - gets = - flip (QS.one QS.string) qs + url = + { url_ | path = "/" } + + tokenTypeParser = + Url.top + Query.map2 Tuple.pair (Query.string "code") (Query.string "error") + + authorizationCodeParser code = + Url.query <| + Query.map (Internal.parseAuthorizationCode code) (Query.string "state") + + errorParser error = + Url.query <| + Query.map3 (Internal.parseError error) + (Query.string "error_description") + (Query.string "error_url") + (Query.string "state") in - case ( gets "code", gets "error" ) of - ( Just code, _ ) -> - Internal.parseAuthorizationCode - code - (gets "state") - - ( _, Just error ) -> - Internal.parseError - error - (gets "error_description") - (gets "error_uri") - (gets "state") - - _ -> - Result.Err Empty + case Url.parse tokenTypeParser url of + Just ( Just code, _ ) -> + Maybe.withDefault (Result.Err FailedToParse) <| Url.parse (authorizationCodeParser code) url + + Just ( _, Just error ) -> + Maybe.withDefault (Result.Err FailedToParse) <| Url.parse (errorParser error) url + + _ -> + Result.Err Empty diff --git a/src/OAuth/Decode.elm b/src/OAuth/Decode.elm index b1705ea..6f0276e 100644 --- a/src/OAuth/Decode.elm +++ b/src/OAuth/Decode.elm @@ -1,4 +1,8 @@ -module OAuth.Decode exposing (..) +module OAuth.Decode exposing + ( RequestParts, AdjustRequest + , responseDecoder, expiresInDecoder, scopeDecoder, lenientScopeDecoder, stateDecoder, accessTokenDecoder, refreshTokenDecoder + , makeToken, makeResponseToken + ) {-| This module exposes decoders and helpers to fine tune some requests when necessary. @@ -23,10 +27,9 @@ requests made to the Authorization Server and cope with implementation quirks. -} -import OAuth exposing (..) -import Json.Decode as Json import Http as Http -import Time exposing (Time) +import Json.Decode as Json +import OAuth exposing (..) {-| Parts required to build a request. This record is given to `Http.request` in order @@ -38,7 +41,7 @@ type alias RequestParts a = , url : String , body : Http.Body , expect : Http.Expect a - , timeout : Maybe Time + , timeout : Maybe Float , withCredentials : Bool } @@ -50,7 +53,7 @@ For instance, adjustRequest : AdjustRequest ResponseToken adjustRequest req = - { req | headers = [ Http.header "Accept" ("application/json") ] :: req.headers } + { req | headers = [ Http.header "Accept" "application/json" ] :: req.headers } -} type alias AdjustRequest a = @@ -134,7 +137,7 @@ accessTokenDecoder = failUnless = Maybe.map Json.succeed >> Maybe.withDefault (Json.fail "can't decode token") in - Json.andThen failUnless mtoken + Json.andThen failUnless mtoken {-| Json decoder for a refresh token diff --git a/src/OAuth/Implicit.elm b/src/OAuth/Implicit.elm index 25f02fb..d9843dd 100644 --- a/src/OAuth/Implicit.elm +++ b/src/OAuth/Implicit.elm @@ -1,8 +1,4 @@ -module OAuth.Implicit - exposing - ( authorize - , parse - ) +module OAuth.Implicit exposing (authorize, parse) {-| The implicit grant type is used to obtain access tokens (it does not support the issuance of refresh tokens) and is optimized for public clients known to operate a @@ -24,10 +20,13 @@ request. -} -import OAuth exposing (..) -import Navigation as Navigation +import Browser.Navigation as Navigation import Internal as Internal -import QueryString as QS +import OAuth exposing (..) +import Url exposing (Protocol(..), Url) +import Url.Builder as Url +import Url.Parser as Url exposing (()) +import Url.Parser.Query as Query {-| Redirects the resource owner (user) to the resource provider server using the specified @@ -47,33 +46,37 @@ redirecting the resource owner (user). Fails with `ParseErr Empty` when there's nothing -} -parse : Navigation.Location -> Result ParseErr ResponseToken -parse { hash } = +parse : Url -> Result ParseErr ResponseToken +parse url_ = let - qs = - QS.parse ("?" ++ String.dropLeft 1 hash) + url = + { url_ | path = "/", query = url_.fragment, fragment = Nothing } + + tokenTypeParser = + Url.top + Query.map2 Tuple.pair (Query.string "access_token") (Query.string "error") + + tokenParser accessToken = + Url.query <| + Query.map4 (Internal.parseToken accessToken) + (Query.string "token_type") + (Query.int "expires_in") + (Internal.qsSpaceSeparatedList "scope") + (Query.string "state") + + errorParser error = + Url.query <| + Query.map3 (Internal.parseError error) + (Query.string "error_description") + (Query.string "error_url") + (Query.string "state") + in + case Url.parse tokenTypeParser url of + Just ( Just accessToken, _ ) -> + Maybe.withDefault (Result.Err FailedToParse) <| Url.parse (tokenParser accessToken) url - gets = - flip (QS.one QS.string) qs + Just ( _, Just error ) -> + Maybe.withDefault (Result.Err FailedToParse) <| Url.parse (errorParser error) url - geti = - flip (QS.one QS.int) qs - in - case ( gets "access_token", gets "error" ) of - ( Just accessToken, _ ) -> - Internal.parseToken - accessToken - (gets "token_type") - (geti "expires_in") - (QS.all "scope" qs) - (gets "state") - - ( _, Just error ) -> - Internal.parseError - error - (gets "error_description") - (gets "error_uri") - (gets "state") - - _ -> - Result.Err Empty + _ -> + Result.Err Empty