├── .editorconfig ├── .github └── demo.png ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── TROUBLESHOOTING.md ├── deploy.sh ├── deploy_examples.sh ├── elm.json ├── examples ├── Makefile ├── README.md ├── assets │ └── css │ │ ├── branding.css │ │ └── style.css ├── dist │ └── .gitkeep ├── index.html └── providers │ ├── auth0 │ ├── README.md │ ├── authorization-code │ │ ├── Main.elm │ │ ├── README.md │ │ ├── elm.json │ │ └── src │ ├── implicit │ │ ├── Main.elm │ │ ├── README.md │ │ ├── elm.json │ │ └── src │ └── pkce │ │ ├── Main.elm │ │ ├── README.md │ │ ├── elm.json │ │ └── src │ ├── facebook │ ├── README.md │ └── implicit │ │ ├── Main.elm │ │ ├── elm.json │ │ └── src │ ├── google │ ├── README.md │ └── implicit │ │ ├── Main.elm │ │ ├── README.md │ │ ├── elm.json │ │ └── src │ └── spotify │ ├── README.md │ └── implicit │ ├── Main.elm │ ├── README.md │ ├── elm.json │ └── src ├── src ├── Extra │ └── Maybe.elm ├── Internal.elm ├── OAuth.elm └── OAuth │ ├── AuthorizationCode.elm │ ├── AuthorizationCode │ └── PKCE.elm │ ├── ClientCredentials.elm │ ├── Implicit.elm │ ├── Password.elm │ └── Refresh.elm └── tests └── Test └── Parsers.elm /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | 9 | [Makefile] 10 | indent_style = tab 11 | indent_size = 8 12 | 13 | [*.elm] 14 | indent_size = 4 15 | indent_style = space 16 | max_line_length = 100 17 | 18 | [*.js] 19 | indent_size = 4 20 | indent_style = space 21 | max_line_length = 100 22 | -------------------------------------------------------------------------------- /.github/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/truqu/elm-oauth2/ef6a7bf29b361a2564b99b0daa79eb3b7ed74f45/.github/demo.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### elm ### 2 | elm-stuff 3 | repl-temp-* 4 | 5 | 6 | ### tmux ### 7 | *.log 8 | 9 | 10 | ### others / builds ### 11 | *.min.js 12 | *.html 13 | !examples/**/index.html 14 | examples/index.html 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v8.0.1 (2021-08-08) 2 | 3 | - Softly deprecate Implicit and put AuthorizationCode and AuthorizationCode w/ PKCE more in the spotlights, as per security recommendations. 4 | - Documentation tweaks and improvements (better cross-link references, better code highlights, links to examples). 5 | 6 | ## v8.0.0 (2021-06-30) 7 | 8 | - Allow more advanced control for tweaking parsers, decoders and url builders. This is particularly useful for applications integrating with systems which are either not strictly following the OAuth2.0 specifications, or, systems who introduce custom fields of some importance for the underlying application. (see #29, #23, #21) 9 | 10 | - Update dependencies for base64 encoding 11 | 12 | ### Diff 13 | 14 | #### `OAuth` - MINOR 15 | 16 | - Added: 17 | 18 | ```elm 19 | type GrantType 20 | = AuthorizationCode 21 | | Password 22 | | ClientCredentials 23 | | RefreshToken 24 | | CustomGrant String 25 | 26 | grantTypeToString : GrantType -> String 27 | ``` 28 | 29 | ```elm 30 | type ResponseType 31 | = Code 32 | | Token 33 | | CustomResponse String 34 | 35 | responseTypeToString : ResponseType -> String 36 | ``` 37 | 38 | #### `OAuth.Implicit` - MAJOR 39 | 40 | - Added: 41 | 42 | ```elm 43 | makeAuthorizationUrlWith : 44 | ResponseType 45 | -> Dict String String 46 | -> Authorization 47 | -> Url 48 | ``` 49 | 50 | - Changed: 51 | 52 | ```elm 53 | -- type alias Parsers = 54 | -- { tokenParser : 55 | -- Query.Parser (Maybe Token) 56 | -- , errorParser : 57 | -- Query.Parser (Maybe ErrorCode) 58 | -- , authorizationSuccessParser : 59 | -- String -> Query.Parser AuthorizationSuccess 60 | -- , authorizationErrorParser : 61 | -- ErrorCode -> Query.Parser AuthorizationError 62 | -- } 63 | 64 | type alias Parsers error success = 65 | { tokenParser : 66 | Query.Parser (Maybe Token) 67 | , errorParser : 68 | Query.Parser (Maybe ErrorCode) 69 | , authorizationSuccessParser : 70 | String -> Query.Parser success 71 | , authorizationErrorParser : 72 | ErrorCode -> Query.Parser error 73 | } 74 | ``` 75 | 76 | ```elm 77 | -- defaultParsers : Parsers 78 | defaultParsers : Parsers AuthorizationError AuthorizationSuccess 79 | ``` 80 | 81 | ```elm 82 | -- parseTokenWith : Parsers -> Url -> AuthorizationResult 83 | parseTokenWith : Parsers error success -> Url -> AuthorizationResultWith error success 84 | ``` 85 | 86 | #### `OAuth.AuthorizationCode` - MAJOR 87 | 88 | - Added: 89 | 90 | ```elm 91 | makeAuthorizationUrlWith : 92 | ResponseType 93 | -> Dict String String 94 | -> Authorization 95 | -> Url 96 | ``` 97 | 98 | ```elm 99 | makeTokenRequestWith : 100 | OAuth.GrantType 101 | -> Json.Decoder success 102 | -> Dict String String 103 | -> (Result Http.Error success -> msg) 104 | -> Authentication 105 | -> RequestParts msg 106 | ``` 107 | 108 | - Changed: 109 | 110 | ```elm 111 | -- type AuthorizationResult 112 | -- = Empty 113 | -- | Error AuthorizationError 114 | -- | Success AuthorizationSuccess 115 | 116 | type alias AuthorizationResult = 117 | AuthorizationResultWith AuthorizationError AuthorizationSuccess 118 | 119 | type AuthorizationResultWith error success 120 | = Empty 121 | | Error error 122 | | Success success 123 | ``` 124 | 125 | ```elm 126 | -- type alias Parsers = 127 | -- { codeParser : 128 | -- Query.Parser (Maybe String) 129 | -- , errorParser : 130 | -- Query.Parser (Maybe ErrorCode) 131 | -- , authorizationSuccessParser : 132 | -- String -> Query.Parser AuthorizationSuccess 133 | -- , authorizationErrorParser : 134 | -- ErrorCode -> Query.Parser AuthorizationError 135 | -- } 136 | 137 | type alias Parsers error success = 138 | { codeParser : 139 | Query.Parser (Maybe String) 140 | , errorParser : 141 | Query.Parser (Maybe ErrorCode) 142 | , authorizationSuccessParser : 143 | String -> Query.Parser success 144 | , authorizationErrorParser : 145 | ErrorCode -> Query.Parser error 146 | } 147 | ``` 148 | 149 | ```elm 150 | -- defaultParsers : Parsers 151 | defaultParsers : Parsers AuthorizationError AuthorizationSuccess 152 | ``` 153 | 154 | ```elm 155 | -- parseCodeWith : Parsers -> Url -> AuthorizationResult 156 | parseCodeWith : Parsers error success -> Url -> AuthorizationResultWith error success 157 | ``` 158 | 159 | #### `OAuth.AuthorizationCode.PKCE` - MAJOR 160 | 161 | - Added: 162 | 163 | ```elm 164 | makeAuthorizationUrlWith : 165 | ResponseType 166 | -> Dict String String 167 | -> Authorization 168 | -> Url 169 | ``` 170 | 171 | ```elm 172 | makeTokenRequestWith : 173 | OAuth.GrantType 174 | -> Json.Decoder success 175 | -> Dict String String 176 | -> (Result Http.Error success -> msg) 177 | -> Authentication 178 | -> RequestParts msg 179 | ``` 180 | 181 | - Changed: 182 | 183 | ```elm 184 | -- type AuthorizationResult 185 | -- = Empty 186 | -- | Error AuthorizationError 187 | -- | Success AuthorizationSuccess 188 | 189 | type alias AuthorizationResult = 190 | AuthorizationResultWith AuthorizationError AuthorizationSuccess 191 | 192 | type AuthorizationResultWith error success 193 | = Empty 194 | | Error error 195 | | Success success 196 | ``` 197 | 198 | ```elm 199 | -- type alias Parsers = 200 | -- { codeParser : 201 | -- Query.Parser (Maybe String) 202 | -- , errorParser : 203 | -- Query.Parser (Maybe ErrorCode) 204 | -- , authorizationSuccessParser : 205 | -- String -> Query.Parser AuthorizationSuccess 206 | -- , authorizationErrorParser : 207 | -- ErrorCode -> Query.Parser AuthorizationError 208 | -- } 209 | 210 | type alias Parsers error success = 211 | { codeParser : 212 | Query.Parser (Maybe String) 213 | , errorParser : 214 | Query.Parser (Maybe ErrorCode) 215 | , authorizationSuccessParser : 216 | String -> Query.Parser success 217 | , authorizationErrorParser : 218 | ErrorCode -> Query.Parser error 219 | } 220 | ``` 221 | 222 | ```elm 223 | -- defaultParsers : Parsers 224 | defaultParsers : Parsers AuthorizationError AuthorizationSuccess 225 | ``` 226 | 227 | ```elm 228 | -- parseCodeWith : Parsers -> Url -> AuthorizationResult 229 | parseCodeWith : Parsers error success -> Url -> AuthorizationResultWith error success 230 | ``` 231 | 232 | 233 | #### `OAuth.ClientCredentials` - MINOR 234 | 235 | - Added: 236 | 237 | ```elm 238 | makeTokenRequestWith : 239 | GrantType 240 | -> Json.Decoder success 241 | -> Dict String String 242 | -> (Result Http.Error success -> msg) 243 | -> Authentication 244 | -> RequestParts msg 245 | ``` 246 | 247 | #### `OAuth.Password` - MINOR 248 | 249 | - Added: 250 | 251 | ```elm 252 | makeTokenRequestWith : 253 | GrantType 254 | -> Json.Decoder success 255 | -> Dict String String 256 | -> (Result Http.Error success -> msg) 257 | -> Authentication 258 | -> RequestParts msg 259 | ``` 260 | 261 | 262 | #### `OAuth.Refresh` - MINOR 263 | 264 | - Added: 265 | 266 | ```elm 267 | makeTokenRequestWith : 268 | GrantType 269 | -> Json.Decoder success 270 | -> Dict String String 271 | -> (Result Http.Error success -> msg) 272 | -> Authentication 273 | -> RequestParts msg 274 | ``` 275 | 276 | ## v7.0.1 (2020-12-05) 277 | 278 | - Updated dependency `ivadzy/bbase64@1.1.1` renamed as `chelovek0v/bbase64@1.0.1` 279 | 280 | ## v7.0.0 (2020-02-17) 281 | 282 | #### Diff 283 | 284 | ```elm 285 | ---- ADDED MODULES - MINOR ---- 286 | 287 | OAuth.AuthorizationCode.PKCE 288 | 289 | 290 | ---- OAuth.AuthorizationCode - MAJOR ---- 291 | 292 | Added: 293 | type alias AuthorizationCode = String.String 294 | 295 | Changed: 296 | - type alias AuthorizationSuccess = 297 | { code : String, state : Maybe String } 298 | + type alias AuthorizationSuccess = 299 | { code : OAuth.AuthorizationCode.AuthorizationCode 300 | , state : Maybe.Maybe String.String 301 | } 302 | ``` 303 | 304 | #### Commits 305 | 306 | - f1f648a76fcc0e8e33ef06cd9867600164d709d7 add support for RFC7636 - Proof Key for Code Exchange 307 | 308 | Auth 2.0 public clients utilizing the Authorization Code Grant are 309 | susceptible to the authorization code interception attack. This 310 | specification describes the attack as well as a technique to mitigate against 311 | the threat through the use of Proof Key for Code Exchange (PKCE, pronounced 312 | "pixy"). 313 | 314 | - 3dc3c9d6a0aa6d20b84d8ffc79e55aec06beb683 remove double dependency on base64 and favor only one 315 | 316 | - 6199c78126d59fe0da5ed491f04835087285188a several doc revision on all grants (diagrams, type description etc ...) 317 | 318 | - 0d969a08dd90079933f747c24cea8c13b9954a07 put PKCE as recommended in README and start reviewing demos / guides 319 | 320 | - b712fcdec341bb3b07a95fbcf5e77c6794f7da01 rework examples 321 | - Add auth0 example with authorization code and PKCE support 322 | - Add facebook example 323 | - Make them more readable and avoid unrelated code in examples 324 | - Add README to summarize information 325 | 326 | - 68383cfa0d22c29733a219a2849db3cfc2731e63 revise deployment scripts, in particular examples 327 | 328 | - f86ffe9469f50b9c011505459df380fe071b604c bump version (major) to 7.0.0 & update CHANGELOG 329 | 330 | 331 | ## v6.0.0 (2019-09-03) 332 | 333 | - (267ca48) Internal small refactor 334 | - (43e536a) General documentation improvements 335 | - (e34e16f) Rename 'makeAuthUrl' to 'makeAuthorizationUrl' 336 | - (12ce2ba) Split-up README, extract troubleshooting and guides 337 | 338 | ## v5.0.0 (2019-01-23) 339 | 340 | - (d74016e, 333d6ea, 849d985, 78caba7) Upgrade `elm/http` to new major version `2.0.0` 341 | 342 | 343 | ## v4.0.1 (2018-10-06) 344 | 345 | - (15e4e82) Bug Fix: make token\_type parsing case-insensitive. 346 | 347 | 348 | ## v4.0.0 (2018-09-07) 349 | 350 | - (72f251a, 1327646) Documentation improvements 351 | 352 | - (0105ca3, 9a3b307, 5e3c841, 4801593) Review examples to be more complete, self-explanatory and clearer 353 | 354 | - (0ac7d90) Completely review internal implementation & exposed API 355 | 356 | 357 | ## v3.0.0 (2018-09-03) 358 | 359 | - (3a60354) Upgrade `src/` to `elm@0.19` 360 | - (ef85924) Upgrade `examples/implicit` to `elm@0.19` 361 | - (88f27a7) Remove `examples/authorization_code` 362 | - (7ce7c82) Change `String` to `Url` for 363 | - `Authorization.url` 364 | - `Authorization.redirectUri` 365 | - `Authentication#AuthorizationCode.redirectUri` 366 | - `Authentication#AuthorizationCode.url` 367 | - `Authentication#ClientCredentials.url` 368 | - `Authentication#Password.url` 369 | - `Authentication#Refresh.url` 370 | - (912197c) Expose `lenientResponseDecoder` from `OAuth.Decode` 371 | 372 | 373 | ## v2.2.1 (2018-08-16) 374 | 375 | - Bump `elm-base64` version upper-bound 376 | 377 | 378 | ## v2.2.0 (2017-12-22) 379 | 380 | - (oversight) Actually expose 'authenticateWithOpts' functions from modules 381 | 382 | 383 | ## v2.1.0 (2017-12-22) 384 | 385 | - Expose internal Json decoders 386 | - Enable users to adjust requests made to the Authorization Server to cope with possible 387 | implementation quirks (like GitHub API v3) 388 | 389 | 390 | ## v2.0.3 (2017-06-04) 391 | 392 | - Update LICENSE's information 393 | - Fix broken links and examples in README 394 | 395 | 396 | ## v2.0.2 (2017-06-02) 397 | 398 | - Fix bug about empty scope parameter being sent when `Nothing` is provided as a scope 399 | 400 | 401 | ## v2.0.1 (2017-06-02) 402 | 403 | - Enhance documentation about response parameters 404 | 405 | ## v2.0.0 (2017-06-02) 406 | 407 | - Review type `Response` to provide a clearer API 408 | - Fix typos and references in examples 409 | 410 | 411 | ## v1.0.0 (2017-06-01) 412 | 413 | - Initial release, support for all 4 grant types. 414 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017-2019 TruQu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a 6 | copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included 14 | in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 17 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Elm OAuth 2 [![](https://img.shields.io/badge/package.elm--lang.org-8.0.0-60b5cc.svg?style=flat-square)](http://package.elm-lang.org/packages/truqu/elm-oauth2/latest) 2 | ===== 3 | 4 | This package offers some utilities to implement a client-side [OAuth 2](https://tools.ietf.org/html/rfc6749) authorization in Elm. It covers all four basic grant types as well as the [PKCE](https://tools.ietf.org/html/rfc7636) extension: 5 | 6 | - **(RECOMMENDED)** [Authorization Code w/ PKCE](http://package.elm-lang.org/packages/truqu/elm-oauth2/latest/OAuth-AuthorizationCode-PKCE): 7 | An extension of the original OAuth 2.0 specification to mitigate authorization code interception attacks through the use of Proof Key for Code Exchange (PKCE). **FOR PUBLIC & CONFIDENTIAL CLIENTS** such as the device operating system or a highly privileged application that has been issued credentials for authenticating with the authorization server (e.g. a client id). 8 | 9 | - [Authorization Code](http://package.elm-lang.org/packages/truqu/elm-oauth2/latest/OAuth-AuthorizationCode): 10 | The token is obtained as a result of an authentication, from a code obtained as a result of a user redirection to an OAuth provider. The authorization code grant type is used to obtain both access tokens and refresh tokens and is optimized **FOR PUBLIC & CONFIDENTIAL CLIENTS** such as the device operating system or a highly privileged application that has been issued credentials for authenticating with the authorization server (e.g. a client id). 11 | 12 | - [Client Credentials](http://package.elm-lang.org/packages/truqu/elm-oauth2/latest/OAuth-ClientCredentials): 13 | The token is obtained directly by exchanging application credentials with an OAuth provider. The client credentials grant type must only be **USED BY CONFIDENTIAL CLIENTS**. 14 | 15 | - [Resource Owner Password Credentials](http://package.elm-lang.org/packages/truqu/elm-oauth2/latest/OAuth-Password): 16 | The token is obtained directly by exchanging the user credentials with an OAuth provider. The resource owner password credentials grant type is suitable in cases **WHERE THE RESOURCE OWNER HAS A TRUST RELATIONSHIP WITH THE CLIENT**. 17 | 18 | - **(DEPRECATED)\*** [Implicit](http://package.elm-lang.org/packages/truqu/elm-oauth2/latest/OAuth-Implicit): 19 | The token is obtained directly as a result of a user redirection to an OAuth provider. 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**. 20 | 21 | (\*) https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics-16#section-2.1.2 22 | 23 | 24 | 25 | ## Getting Started 26 | 27 | ### Installation 28 | 29 | ``` 30 | elm install truqu/elm-oauth2 31 | ``` 32 | 33 | ### Examples / Demos 34 | 35 | - [Auth0](https://github.com/truqu/elm-oauth2/tree/master/examples/providers/auth0) 36 | - [Facebook](https://github.com/truqu/elm-oauth2/tree/master/examples/providers/facebook) 37 | - [Google](https://github.com/truqu/elm-oauth2/tree/master/examples/providers/google) 38 | - [Spotify](https://github.com/truqu/elm-oauth2/tree/master/examples/providers/spotify) 39 | 40 | ### Troubleshooting 41 | 42 | [TROUBLESHOOTING.md](https://github.com/truqu/elm-oauth2/tree/master/TROUBLESHOOTING.md) 43 | 44 | ## Changelog 45 | 46 | [CHANGELOG.md](https://github.com/truqu/elm-oauth2/tree/master/CHANGELOG.md) 47 | -------------------------------------------------------------------------------- /TROUBLESHOOTING.md: -------------------------------------------------------------------------------- 1 |
2 | Understanding OAuth roles 3 | 4 | Throughout the library, you'll find terms referring to OAuth well-defined roles: 5 | 6 | - **`resource owner`** 7 | _An entity capable of granting access to a protected resource. 8 | When the resource owner is a person, it is referred to as an 9 | end-user._ 10 | 11 | - **`client`** 12 | _An application making protected resource requests on behalf of the 13 | resource owner and with its authorization. The term "client" does 14 | not imply any particular implementation characteristics (e.g., 15 | whether the application executes on a server, a desktop, or other 16 | devices)._ 17 | 18 | - **`authorization server`** 19 | _The server issuing access tokens to the client after successfully 20 | authenticating the resource owner and obtaining authorization._ 21 | 22 | - **`resource server`** 23 | _The server hosting the protected resources, capable of accepting 24 | and responding to protected resource requests using access tokens._ 25 | 26 | > NOTE: Usually, the _authorization server_ and the _resource server_ are 27 | > a same entity, or comes from the same entity. So, a simplified vision of 28 | > this roles can be: 29 | > 30 | > - **`resource owner`** 31 | > The end-user 32 | > 33 | > - **`client`** 34 | > Your Elm app 35 | > 36 | > - **`authorization server`** / **`resource server`** 37 | > Google, Facebook, Twitter or whatever OAuth provider you're talking to 38 |
39 | 40 |
41 | Authentication requests in the Authorization Flow don't go through 42 | 43 | Most authorization servers don't enable CORS on the authentication endpoints. For this reason, 44 | it's likely that the preflight _OPTIONS_ requests sent by the browser return an invalid 45 | answer, preventing the browser from making the request at all. 46 | 47 | Why is it so? The authorization request _usually_requires one's secret; thus making them 48 | rather impractical to perform from a client-side application without exposing those secrets. 49 | As a security measure, most authorization servers choose to enforce that those requests are 50 | made server-side instead. 51 | 52 | Generally, this is also what you want, unless you're dealing with a custom authorization server 53 | in some sort of isolated environment. OAuth 2.0 is designed to cover all sort of delegation of 54 | permissions, the case of user-facing client-side applications is only one of them; some 55 | authorization flows are therefore not necessarily adapted to these cases. Usually, a client-side 56 | application will prefer the _Implicit Flow_ over the others. 57 |
58 | 59 | --- 60 | 61 | > Still having an issue? 62 | > 63 | > Please [open a ticket](https://github.com/truqu/elm-oauth2/issues/new) and let us know :heart:! 64 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function untag () { 4 | git tag -d $1 5 | git push origin --delete $1 6 | } 7 | 8 | ## Verify nothing is unstaged or untracked 9 | status=$(git status -s) 10 | if [ -n "$status" ]; then 11 | echo "branch isn't clean: commit staged files and / or discard untracked files!" 12 | echo $status 13 | exit 1 14 | fi 15 | 16 | ## Verify code compiles 17 | echo "compiling library" && elm make || exit 1 18 | for d in $(ls -d examples/providers/**/** | grep -v README) ; do 19 | cd $d 20 | mkdir -p dist 21 | echo "compiling $d" && elm make --optimize "Main.elm" --output="dist/app.min.js" || exit 1 22 | cd - 23 | done 24 | rm -f index.html 25 | 26 | ## Get version number 27 | version=$(cat elm.json | grep '"version"' | sed 's/\([^0-9]*\)\([0-9]\.[0-9]\.[0-9]\)\(.*\)/\2/') 28 | if [ -z "$version" ]; then 29 | echo "unable to capture package version" 30 | exit 1 31 | else 32 | echo "VERSION: $version" 33 | fi 34 | 35 | ## Create tag and publish 36 | trap 'untag $version' 1 37 | git tag -d $version 1>/dev/null 2>&1 38 | git tag -a $version -m "release version $version" && git push origin $version 39 | elm publish || exit 1 40 | 41 | 42 | ## Deploy examples 43 | source ./deploy_examples.sh 44 | deploy_examples $version 45 | -------------------------------------------------------------------------------- /deploy_examples.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function deploy_examples () { 4 | version=$1 5 | 6 | git branch -D gh-pages-$version 7 | git checkout --orphan gh-pages-$version 8 | git reset 9 | 10 | cd examples/providers 11 | for d in $(ls -d **/** | grep -v README); do 12 | mkdir -p ../../$d 13 | cp -r ../index.html ../assets $d/dist ../../$d 14 | git add -f ../../$d/assets 15 | git add -f ../../$d/dist/app.min.js 16 | git add -f ../../$d/index.html 17 | done 18 | cd - 19 | git commit -m "$version" 20 | git branch -M gh-pages && git push origin -f HEAD 21 | git checkout -f master 22 | } 23 | -------------------------------------------------------------------------------- /elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "package", 3 | "name": "truqu/elm-oauth2", 4 | "summary": "OAuth 2.0 client-side utils", 5 | "license": "MIT", 6 | "version": "8.0.1", 7 | "exposed-modules": [ 8 | "OAuth", 9 | "OAuth.AuthorizationCode", 10 | "OAuth.AuthorizationCode.PKCE", 11 | "OAuth.Implicit", 12 | "OAuth.ClientCredentials", 13 | "OAuth.Password", 14 | "OAuth.Refresh" 15 | ], 16 | "elm-version": "0.19.0 <= v < 0.20.0", 17 | "dependencies": { 18 | "chelovek0v/bbase64": "1.0.1 <= v < 2.0.0", 19 | "elm/bytes": "1.0.8 <= v < 2.0.0", 20 | "elm/core": "1.0.2 <= v < 2.0.0", 21 | "elm/http": "2.0.0 <= v < 3.0.0", 22 | "elm/json": "1.1.2 <= v < 2.0.0", 23 | "elm/url": "1.0.0 <= v < 2.0.0", 24 | "folkertdev/elm-sha2": "1.0.0 <= v < 2.0.0" 25 | }, 26 | "test-dependencies": { 27 | "elm-explorations/test": "1.2.2 <= v < 2.0.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /examples/Makefile: -------------------------------------------------------------------------------- 1 | ELM=elm make --optimize 2 | DIST=dist 3 | SRC=providers 4 | OUTPUT=../../../$(DIST)/app.min.js 5 | 6 | .PHONY: help start 7 | 8 | help: 9 | @echo -n "Usage: make /" 10 | @echo -n "\n\nAvailable targets:\n" 11 | $(eval DIRS := $(shell find providers -maxdepth 2 -mindepth 2 -type d)) 12 | @echo -n $(foreach EL,$(DIRS),$(shell echo "$(EL)" | cut -c 11-)) | tr " " "\n" | sed "s/^/ /" 13 | @echo -n "\n\nExamples:\n make facebook/implicit\n make auth0/pkce" 14 | 15 | start: 16 | python -m SimpleHTTPServer 17 | 18 | %/implicit: $(DIST) 19 | cd $(SRC)/$@ && $(ELM) --output=$(OUTPUT) *.elm 20 | 21 | %/authorization-code: $(DIST) 22 | cd $(SRC)/$@ && $(ELM) --output=$(OUTPUT) *.elm 23 | 24 | %/pkce: $(DIST) 25 | cd $(SRC)/$@ && $(ELM) --output=$(OUTPUT) *.elm 26 | 27 | $(DIST): 28 | mkdir -p $@ 29 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | ## Pre-Requisite 4 | 5 | :snake: Python 2+ | :hammer: Make 3+ | :curly_loop: Elm 0.19 6 | 7 | ## Building 8 | 9 | General command: 10 | 11 | ```console 12 | $ make {provider}/{flow} 13 | ``` 14 | 15 | Concrete examples: 16 | 17 | ```console 18 | $ make google/implicit 19 | cd providers/google/implicit && elm make --optimize --output=../../../dist/app.min.js *.elm 20 | Success! 21 | 22 | Main ───> ../../../dist/app.min.js 23 | 24 | $ make auth0/authorization-code 25 | cd providers/auth0/authorization-code && elm make --optimize --output=../../../dist/app.min.js *.elm 26 | Success! 27 | 28 | Main ───> ../../../dist/app.min.js 29 | ``` 30 | 31 | ## Running 32 | 33 | ```console 34 | $ make start 35 | python -m SimpleHTTPServer 36 | Serving HTTP on 0.0.0.0 port 8000 ... 37 | ``` 38 | 39 | Then, visit http://localhost:8000/ 40 | -------------------------------------------------------------------------------- /examples/assets/css/style.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | margin: 0; 3 | padding: 0; 4 | width: 100%; 5 | height: 100%; 6 | } 7 | 8 | body { 9 | font-family: Roboto, Arial, sans-serif; 10 | width: 100%; 11 | } 12 | 13 | button { 14 | border: none; 15 | box-shadow: rgba(0,0,0,0.25) 0px 2px 4px 0px; 16 | color: #757575; 17 | cursor: pointer; 18 | font-size: 24px; 19 | height: 3em; 20 | margin: 0.5em 0; 21 | outline: none; 22 | padding: 0 1em 0 4em; 23 | text-align: right; 24 | width: 10em; 25 | } 26 | 27 | button::-moz-focus-inner { 28 | border: 0; 29 | } 30 | 31 | img.avatar { 32 | height: 15em; 33 | width: 15em; 34 | border-radius: 50%; 35 | box-shadow: rgba(0,0,0,0.25) 0 0 4px 2px; 36 | } 37 | 38 | img.avatar + p { 39 | margin: 2em; 40 | font: 24px Roboto, Arial; 41 | color: #757575; 42 | } 43 | 44 | .flex { 45 | align-items: center; 46 | display: flex; 47 | justify-content: center; 48 | width: 100%; 49 | } 50 | 51 | .flex-column { 52 | flex-direction: column; 53 | justify-content: center; 54 | } 55 | 56 | .flex-space-around { 57 | height: 100%; 58 | justify-content: space-around; 59 | } 60 | 61 | 62 | .step { 63 | width: 2em; 64 | height: 2em; 65 | border: 1px solid; 66 | border-radius: 50%; 67 | border-color: #95a5a6; 68 | } 69 | 70 | .step > * { 71 | position: relative; 72 | display: block; 73 | top: 2.5em; 74 | color: #95a5a6; 75 | white-space: nowrap; 76 | font-variant: small-caps; 77 | } 78 | 79 | .step-separator { 80 | width: 7.5em; 81 | height: 0.1em; 82 | background-color: #95a5a6; 83 | } 84 | 85 | .step-active { 86 | border-color: #2ecc71; 87 | background-color: #2ecc71; 88 | transition: all 250ms ease-in; 89 | } 90 | 91 | .step-active > * { 92 | font-weight: bold; 93 | color: #2ecc71; 94 | } 95 | 96 | .step-errored { 97 | border-color: #e74c3c; 98 | background-color: #e74c3c; 99 | transition: all 250ms ease-in; 100 | } 101 | 102 | .step-errored > * { 103 | font-weight: bold; 104 | color: #e74c3c;; 105 | } 106 | 107 | .step-separator.step-active { 108 | height: 0.2em; 109 | transition: all 250ms ease-in; 110 | } 111 | -------------------------------------------------------------------------------- /examples/dist/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/truqu/elm-oauth2/ef6a7bf29b361a2564b99b0daa79eb3b7ed74f45/examples/dist/.gitkeep -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /examples/providers/auth0/README.md: -------------------------------------------------------------------------------- 1 | # Auth0 2 | 3 | ## Authorization Flows 4 | 5 | Flow | Support | Remark | Example 6 | --- | --- | --- | --- 7 | Implicit | :heavy_check_mark: | \- | [live demo][implicit-demo] \| [source code][implicit-source] 8 | Authorization Code | :heavy_check_mark: | \- | [live demo][authorization-demo] \| [source code][authorization-source] 9 | Authorization Code w/ PKCE | :heavy_check_mark: | \- | [live demo][pkce-demo] \| [source code][pkce-source] 10 | Password | :heavy_check_mark: | \- | N/A 11 | Client Credentials | :heavy_check_mark: | Requires secret key (server-side) | N/A 12 | 13 | ## OAuth Configuration 14 | 15 | \- | \- 16 | --- | --- 17 | Authorization Endpoint | my-app.domain.auth0.com/authorize 18 | Token Endpoint | my-app.domain.auth0.com/oauth/token 19 | User Info Endpoint | my-app.domain.auth0.com/userinfo 20 | 21 | --- 22 | 23 | :book: https://auth0.com/docs/getting-started/overview 24 | 25 | 26 | [implicit-demo]: https://truqu.github.io/elm-oauth2/auth0/implicit/ 27 | [implicit-source]: https://github.com/truqu/elm-oauth2/blob/master/examples/providers/auth0/implicit/Main.elm 28 | 29 | [authorization-demo]: https://truqu.github.io/elm-oauth2/auth0/authorization-code/ 30 | [authorization-source]: https://github.com/truqu/elm-oauth2/blob/master/examples/providers/auth0/authorization-code/Main.elm 31 | 32 | [pkce-demo]: https://truqu.github.io/elm-oauth2/auth0/pkce/ 33 | [pkce-source]: https://github.com/truqu/elm-oauth2/blob/master/examples/providers/auth0/pkce/Main.elm 34 | -------------------------------------------------------------------------------- /examples/providers/auth0/authorization-code/Main.elm: -------------------------------------------------------------------------------- 1 | port module Main exposing (main) 2 | 3 | import Base64.Encode as Base64 4 | import Browser exposing (Document, application) 5 | import Browser.Navigation as Navigation exposing (Key) 6 | import Bytes exposing (Bytes) 7 | import Bytes.Encode as Bytes 8 | import Delay exposing (TimeUnit(..), after) 9 | import Html exposing (..) 10 | import Html.Attributes exposing (..) 11 | import Html.Events exposing (..) 12 | import Http 13 | import Json.Decode as Json 14 | import OAuth 15 | import OAuth.AuthorizationCode as OAuth 16 | import Url exposing (Protocol(..), Url) 17 | 18 | 19 | main : Program (Maybe (List Int)) Model Msg 20 | main = 21 | application 22 | { init = 23 | Maybe.map convertBytes >> init 24 | , update = 25 | update 26 | , subscriptions = 27 | always <| randomBytes GotRandomBytes 28 | , onUrlRequest = 29 | always NoOp 30 | , onUrlChange = 31 | always NoOp 32 | , view = 33 | view 34 | { title = "Auth0 - Flow: Authorization Code" 35 | , btnClass = class "btn-auth0" 36 | } 37 | } 38 | 39 | 40 | {-| OAuth configuration. 41 | 42 | Note that this demo also fetches basic user information with the obtained access token, 43 | hence the user info endpoint and JSON decoder 44 | 45 | -} 46 | configuration : Configuration 47 | configuration = 48 | { authorizationEndpoint = 49 | { defaultHttpsUrl | host = "elm-oauth2.eu.auth0.com", path = "/authorize" } 50 | , tokenEndpoint = 51 | { defaultHttpsUrl | host = "elm-oauth2.eu.auth0.com", path = "/oauth/token" } 52 | , userInfoEndpoint = 53 | { defaultHttpsUrl | host = "elm-oauth2.eu.auth0.com", path = "/userinfo" } 54 | , userInfoDecoder = 55 | Json.map2 UserInfo 56 | (Json.field "name" Json.string) 57 | (Json.field "picture" Json.string) 58 | , clientId = 59 | "AbRrXEIRBPgkDrqR4RgdXuHoAd1mDetT" 60 | , scope = 61 | [ "openid", "profile" ] 62 | } 63 | 64 | 65 | 66 | -- 67 | -- Model 68 | -- 69 | 70 | 71 | type alias Model = 72 | { redirectUri : Url 73 | , flow : Flow 74 | } 75 | 76 | 77 | {-| This demo evolves around the following state-machine\* 78 | 79 | +--------+ 80 | | Idle | 81 | +--------+ 82 | | 83 | | Redirect user for authorization 84 | | 85 | v 86 | +--------------+ 87 | | Authorized | 88 | +--------------+ 89 | | 90 | | Exchange authorization code for an access token 91 | | 92 | v 93 | +-----------------+ 94 | | Authenticated | 95 | +-----------------+ 96 | | 97 | | Fetch user info using the access token 98 | v 99 | +--------+ 100 | | Done | 101 | +--------+ 102 | 103 | (\*) The 'Errored' state hasn't been represented here for simplicity. 104 | 105 | -} 106 | type Flow 107 | = Idle 108 | | Authorized OAuth.AuthorizationCode 109 | | Authenticated OAuth.Token 110 | | Done UserInfo 111 | | Errored Error 112 | 113 | 114 | type Error 115 | = ErrStateMismatch 116 | | ErrAuthorization OAuth.AuthorizationError 117 | | ErrAuthentication OAuth.AuthenticationError 118 | | ErrHTTPGetAccessToken 119 | | ErrHTTPGetUserInfo 120 | 121 | 122 | type alias UserInfo = 123 | { name : String 124 | , picture : String 125 | } 126 | 127 | 128 | type alias Configuration = 129 | { authorizationEndpoint : Url 130 | , tokenEndpoint : Url 131 | , userInfoEndpoint : Url 132 | , userInfoDecoder : Json.Decoder UserInfo 133 | , clientId : String 134 | , scope : List String 135 | } 136 | 137 | 138 | {-| During the authentication flow, we'll run twice into the `init` function: 139 | 140 | - The first time, for the application very first run. And we proceed with the `Idle` state, 141 | waiting for the user (a.k.a you) to request a sign in. 142 | 143 | - The second time, after a sign in has been requested, the user is redirected to the 144 | authorization server and redirects the user back to our application, with a code 145 | and other fields as query parameters. 146 | 147 | When query params are present (and valid), we consider the user `Authorized`. 148 | 149 | -} 150 | init : Maybe { state : String } -> Url -> Key -> ( Model, Cmd Msg ) 151 | init mflags origin navigationKey = 152 | let 153 | redirectUri = 154 | { origin | query = Nothing, fragment = Nothing } 155 | 156 | clearUrl = 157 | Navigation.replaceUrl navigationKey (Url.toString redirectUri) 158 | in 159 | case OAuth.parseCode origin of 160 | OAuth.Empty -> 161 | ( { flow = Idle, redirectUri = redirectUri } 162 | , Cmd.none 163 | ) 164 | 165 | -- It is important to set a `state` when making the authorization request 166 | -- and to verify it after the redirection. The state can be anything but its primary 167 | -- usage is to prevent cross-site request forgery; at minima, it should be a short, 168 | -- non-guessable string, generated on the fly. 169 | -- 170 | -- We remember any previously generated state state using the browser's local storage 171 | -- and give it back (if present) to the elm application upon start 172 | OAuth.Success { code, state } -> 173 | case mflags of 174 | Nothing -> 175 | ( { flow = Errored ErrStateMismatch, redirectUri = redirectUri } 176 | , clearUrl 177 | ) 178 | 179 | Just flags -> 180 | if state /= Just flags.state then 181 | ( { flow = Errored ErrStateMismatch, redirectUri = redirectUri } 182 | , clearUrl 183 | ) 184 | 185 | else 186 | ( { flow = Authorized code, redirectUri = redirectUri } 187 | , Cmd.batch 188 | -- Artificial delay to make the live demo easier to follow. 189 | -- In practice, the access token could be requested right here. 190 | [ after 750 Millisecond AccessTokenRequested 191 | , clearUrl 192 | ] 193 | ) 194 | 195 | OAuth.Error error -> 196 | ( { flow = Errored <| ErrAuthorization error, redirectUri = redirectUri } 197 | , clearUrl 198 | ) 199 | 200 | 201 | 202 | -- 203 | -- Msg 204 | -- 205 | 206 | 207 | type Msg 208 | = NoOp 209 | | SignInRequested 210 | | GotRandomBytes (List Int) 211 | | AccessTokenRequested 212 | | GotAccessToken (Result Http.Error OAuth.AuthenticationSuccess) 213 | | UserInfoRequested 214 | | GotUserInfo (Result Http.Error UserInfo) 215 | | SignOutRequested 216 | 217 | 218 | getAccessToken : Configuration -> Url -> OAuth.AuthorizationCode -> Cmd Msg 219 | getAccessToken { clientId, tokenEndpoint } redirectUri code = 220 | Http.request <| 221 | OAuth.makeTokenRequest GotAccessToken 222 | { credentials = 223 | { clientId = clientId 224 | , secret = Nothing 225 | } 226 | , code = code 227 | , url = tokenEndpoint 228 | , redirectUri = redirectUri 229 | } 230 | 231 | 232 | getUserInfo : Configuration -> OAuth.Token -> Cmd Msg 233 | getUserInfo { userInfoDecoder, userInfoEndpoint } token = 234 | Http.request 235 | { method = "GET" 236 | , body = Http.emptyBody 237 | , headers = OAuth.useToken token [] 238 | , url = Url.toString userInfoEndpoint 239 | , expect = Http.expectJson GotUserInfo userInfoDecoder 240 | , timeout = Nothing 241 | , tracker = Nothing 242 | } 243 | 244 | 245 | 246 | {- On the JavaScript's side, we have: 247 | 248 | app.ports.genRandomBytes.subscribe(n => { 249 | const buffer = new Uint8Array(n); 250 | crypto.getRandomValues(buffer); 251 | const bytes = Array.from(buffer); 252 | localStorage.setItem("bytes", bytes); 253 | app.ports.randomBytes.send(bytes); 254 | }); 255 | -} 256 | 257 | 258 | port genRandomBytes : Int -> Cmd msg 259 | 260 | 261 | port randomBytes : (List Int -> msg) -> Sub msg 262 | 263 | 264 | 265 | -- 266 | -- Update 267 | -- 268 | 269 | 270 | update : Msg -> Model -> ( Model, Cmd Msg ) 271 | update msg model = 272 | case ( model.flow, msg ) of 273 | ( Idle, SignInRequested ) -> 274 | signInRequested model 275 | 276 | ( Idle, GotRandomBytes bytes ) -> 277 | gotRandomBytes model bytes 278 | 279 | ( Authorized code, AccessTokenRequested ) -> 280 | accessTokenRequested model code 281 | 282 | ( Authorized _, GotAccessToken authenticationResponse ) -> 283 | gotAccessToken model authenticationResponse 284 | 285 | ( Authenticated token, UserInfoRequested ) -> 286 | userInfoRequested model token 287 | 288 | ( Authenticated _, GotUserInfo userInfoResponse ) -> 289 | gotUserInfo model userInfoResponse 290 | 291 | ( Done _, SignOutRequested ) -> 292 | signOutRequested model 293 | 294 | _ -> 295 | noOp model 296 | 297 | 298 | noOp : Model -> ( Model, Cmd Msg ) 299 | noOp model = 300 | ( model, Cmd.none ) 301 | 302 | 303 | signInRequested : Model -> ( Model, Cmd Msg ) 304 | signInRequested model = 305 | ( { model | flow = Idle } 306 | , genRandomBytes 16 307 | ) 308 | 309 | 310 | gotRandomBytes : Model -> List Int -> ( Model, Cmd Msg ) 311 | gotRandomBytes model bytes = 312 | let 313 | { state } = 314 | convertBytes bytes 315 | 316 | authorization = 317 | { clientId = configuration.clientId 318 | , redirectUri = model.redirectUri 319 | , scope = configuration.scope 320 | , state = Just state 321 | , url = configuration.authorizationEndpoint 322 | } 323 | in 324 | ( { model | flow = Idle } 325 | , authorization 326 | |> OAuth.makeAuthorizationUrl 327 | |> Url.toString 328 | |> Navigation.load 329 | ) 330 | 331 | 332 | accessTokenRequested : Model -> OAuth.AuthorizationCode -> ( Model, Cmd Msg ) 333 | accessTokenRequested model code = 334 | ( { model | flow = Authorized code } 335 | , getAccessToken configuration model.redirectUri code 336 | ) 337 | 338 | 339 | gotAccessToken : Model -> Result Http.Error OAuth.AuthenticationSuccess -> ( Model, Cmd Msg ) 340 | gotAccessToken model authenticationResponse = 341 | case authenticationResponse of 342 | Err (Http.BadBody body) -> 343 | case Json.decodeString OAuth.defaultAuthenticationErrorDecoder body of 344 | Ok error -> 345 | ( { model | flow = Errored <| ErrAuthentication error } 346 | , Cmd.none 347 | ) 348 | 349 | _ -> 350 | ( { model | flow = Errored ErrHTTPGetAccessToken } 351 | , Cmd.none 352 | ) 353 | 354 | Err _ -> 355 | ( { model | flow = Errored ErrHTTPGetAccessToken } 356 | , Cmd.none 357 | ) 358 | 359 | Ok { token } -> 360 | ( { model | flow = Authenticated token } 361 | , after 750 Millisecond UserInfoRequested 362 | ) 363 | 364 | 365 | userInfoRequested : Model -> OAuth.Token -> ( Model, Cmd Msg ) 366 | userInfoRequested model token = 367 | ( { model | flow = Authenticated token } 368 | , getUserInfo configuration token 369 | ) 370 | 371 | 372 | gotUserInfo : Model -> Result Http.Error UserInfo -> ( Model, Cmd Msg ) 373 | gotUserInfo model userInfoResponse = 374 | case userInfoResponse of 375 | Err _ -> 376 | ( { model | flow = Errored ErrHTTPGetUserInfo } 377 | , Cmd.none 378 | ) 379 | 380 | Ok userInfo -> 381 | ( { model | flow = Done userInfo } 382 | , Cmd.none 383 | ) 384 | 385 | 386 | signOutRequested : Model -> ( Model, Cmd Msg ) 387 | signOutRequested model = 388 | ( { model | flow = Idle } 389 | , Navigation.load (Url.toString model.redirectUri) 390 | ) 391 | 392 | 393 | 394 | -- 395 | -- View 396 | -- 397 | 398 | 399 | type alias ViewConfiguration msg = 400 | { title : String 401 | , btnClass : Attribute msg 402 | } 403 | 404 | 405 | view : ViewConfiguration Msg -> Model -> Document Msg 406 | view ({ title } as config) model = 407 | { title = title 408 | , body = viewBody config model 409 | } 410 | 411 | 412 | viewBody : ViewConfiguration Msg -> Model -> List (Html Msg) 413 | viewBody config model = 414 | [ div [ class "flex", class "flex-column", class "flex-space-around" ] <| 415 | case model.flow of 416 | Idle -> 417 | div [ class "flex" ] 418 | [ viewAuthorizationStep False 419 | , viewStepSeparator False 420 | , viewAuthenticationStep False 421 | , viewStepSeparator False 422 | , viewGetUserInfoStep False 423 | ] 424 | :: viewIdle config 425 | 426 | Authorized _ -> 427 | div [ class "flex" ] 428 | [ viewAuthorizationStep True 429 | , viewStepSeparator True 430 | , viewAuthenticationStep False 431 | , viewStepSeparator False 432 | , viewGetUserInfoStep False 433 | ] 434 | :: viewAuthorized 435 | 436 | Authenticated _ -> 437 | div [ class "flex" ] 438 | [ viewAuthorizationStep True 439 | , viewStepSeparator True 440 | , viewAuthenticationStep True 441 | , viewStepSeparator True 442 | , viewGetUserInfoStep False 443 | ] 444 | :: viewAuthenticated 445 | 446 | Done userInfo -> 447 | div [ class "flex" ] 448 | [ viewAuthorizationStep True 449 | , viewStepSeparator True 450 | , viewAuthenticationStep True 451 | , viewStepSeparator True 452 | , viewGetUserInfoStep True 453 | ] 454 | :: viewUserInfo config userInfo 455 | 456 | Errored err -> 457 | div [ class "flex" ] 458 | [ viewErroredStep 459 | ] 460 | :: viewErrored err 461 | ] 462 | 463 | 464 | viewIdle : ViewConfiguration Msg -> List (Html Msg) 465 | viewIdle { btnClass } = 466 | [ button 467 | [ onClick SignInRequested, btnClass ] 468 | [ text "Sign in" ] 469 | ] 470 | 471 | 472 | viewAuthorized : List (Html Msg) 473 | viewAuthorized = 474 | [ span [] [ text "Authenticating..." ] 475 | ] 476 | 477 | 478 | viewAuthenticated : List (Html Msg) 479 | viewAuthenticated = 480 | [ span [] [ text "Getting user info..." ] 481 | ] 482 | 483 | 484 | viewUserInfo : ViewConfiguration Msg -> UserInfo -> List (Html Msg) 485 | viewUserInfo { btnClass } { name, picture } = 486 | [ div [ class "flex", class "flex-column" ] 487 | [ img [ class "avatar", src picture ] [] 488 | , p [] [ text name ] 489 | , div [] 490 | [ button 491 | [ onClick SignOutRequested, btnClass ] 492 | [ text "Sign out" ] 493 | ] 494 | ] 495 | ] 496 | 497 | 498 | viewErrored : Error -> List (Html Msg) 499 | viewErrored error = 500 | [ span [ class "span-error" ] [ viewError error ] ] 501 | 502 | 503 | viewError : Error -> Html Msg 504 | viewError e = 505 | text <| 506 | case e of 507 | ErrStateMismatch -> 508 | "'state' doesn't match, the request has likely been forged by an adversary!" 509 | 510 | ErrAuthorization error -> 511 | oauthErrorToString { error = error.error, errorDescription = error.errorDescription } 512 | 513 | ErrAuthentication error -> 514 | oauthErrorToString { error = error.error, errorDescription = error.errorDescription } 515 | 516 | ErrHTTPGetAccessToken -> 517 | "Unable to retrieve token: HTTP request failed. CORS is likely disabled on the authorization server." 518 | 519 | ErrHTTPGetUserInfo -> 520 | "Unable to retrieve user info: HTTP request failed." 521 | 522 | 523 | viewAuthorizationStep : Bool -> Html Msg 524 | viewAuthorizationStep isActive = 525 | viewStep isActive ( "Authorization", style "left" "-110%" ) 526 | 527 | 528 | viewAuthenticationStep : Bool -> Html Msg 529 | viewAuthenticationStep isActive = 530 | viewStep isActive ( "Authentication", style "left" "-125%" ) 531 | 532 | 533 | viewGetUserInfoStep : Bool -> Html Msg 534 | viewGetUserInfoStep isActive = 535 | viewStep isActive ( "Get User Info", style "left" "-135%" ) 536 | 537 | 538 | viewErroredStep : Html Msg 539 | viewErroredStep = 540 | div 541 | [ class "step", class "step-errored" ] 542 | [ span [ style "left" "-50%" ] [ text "Errored" ] ] 543 | 544 | 545 | viewStep : Bool -> ( String, Attribute Msg ) -> Html Msg 546 | viewStep isActive ( step, position ) = 547 | let 548 | stepClass = 549 | class "step" 550 | :: (if isActive then 551 | [ class "step-active" ] 552 | 553 | else 554 | [] 555 | ) 556 | in 557 | div stepClass [ span [ position ] [ text step ] ] 558 | 559 | 560 | viewStepSeparator : Bool -> Html Msg 561 | viewStepSeparator isActive = 562 | let 563 | stepClass = 564 | class "step-separator" 565 | :: (if isActive then 566 | [ class "step-active" ] 567 | 568 | else 569 | [] 570 | ) 571 | in 572 | span stepClass [] 573 | 574 | 575 | 576 | -- 577 | -- Helpers 578 | -- 579 | 580 | 581 | toBytes : List Int -> Bytes 582 | toBytes = 583 | List.map Bytes.unsignedInt8 >> Bytes.sequence >> Bytes.encode 584 | 585 | 586 | base64 : Bytes -> String 587 | base64 = 588 | Base64.bytes >> Base64.encode 589 | 590 | 591 | convertBytes : List Int -> { state : String } 592 | convertBytes = 593 | toBytes >> base64 >> (\state -> { state = state }) 594 | 595 | 596 | oauthErrorToString : { error : OAuth.ErrorCode, errorDescription : Maybe String } -> String 597 | oauthErrorToString { error, errorDescription } = 598 | let 599 | desc = 600 | errorDescription |> Maybe.withDefault "" |> String.replace "+" " " 601 | in 602 | OAuth.errorCodeToString error ++ ": " ++ desc 603 | 604 | 605 | defaultHttpsUrl : Url 606 | defaultHttpsUrl = 607 | { protocol = Https 608 | , host = "" 609 | , path = "" 610 | , port_ = Nothing 611 | , query = Nothing 612 | , fragment = Nothing 613 | } 614 | -------------------------------------------------------------------------------- /examples/providers/auth0/authorization-code/README.md: -------------------------------------------------------------------------------- 1 | # Auth0 2 | 3 |

4 | 5 |

6 | -------------------------------------------------------------------------------- /examples/providers/auth0/authorization-code/elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src", 5 | "." 6 | ], 7 | "elm-version": "0.19.1", 8 | "dependencies": { 9 | "direct": { 10 | "andrewMacmurray/elm-delay": "3.0.0", 11 | "elm/browser": "1.0.2", 12 | "elm/bytes": "1.0.8", 13 | "elm/core": "1.0.4", 14 | "elm/html": "1.0.0", 15 | "elm/http": "2.0.0", 16 | "elm/json": "1.1.3", 17 | "elm/url": "1.0.0", 18 | "folkertdev/elm-sha2": "1.0.0", 19 | "chelovek0v/bbase64": "1.0.1" 20 | }, 21 | "indirect": { 22 | "danfishgold/base64-bytes": "1.0.3", 23 | "elm/file": "1.0.5", 24 | "elm/regex": "1.0.0", 25 | "elm/time": "1.0.0", 26 | "elm/virtual-dom": "1.0.2", 27 | "rtfeldman/elm-hex": "1.0.0" 28 | } 29 | }, 30 | "test-dependencies": { 31 | "direct": {}, 32 | "indirect": {} 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /examples/providers/auth0/authorization-code/src: -------------------------------------------------------------------------------- 1 | ../../../../src/ -------------------------------------------------------------------------------- /examples/providers/auth0/implicit/Main.elm: -------------------------------------------------------------------------------- 1 | port module Main exposing (main) 2 | 3 | import Base64.Encode as Base64 4 | import Browser exposing (Document, application) 5 | import Browser.Navigation as Navigation exposing (Key) 6 | import Bytes exposing (Bytes) 7 | import Bytes.Encode as Bytes 8 | import Delay exposing (TimeUnit(..), after) 9 | import Html exposing (..) 10 | import Html.Attributes exposing (..) 11 | import Html.Events exposing (..) 12 | import Http 13 | import Json.Decode as Json 14 | import OAuth 15 | import OAuth.Implicit as OAuth 16 | import Url exposing (Protocol(..), Url) 17 | 18 | 19 | main : Program (Maybe (List Int)) Model Msg 20 | main = 21 | application 22 | { init = 23 | Maybe.map convertBytes >> init 24 | , update = 25 | update 26 | , subscriptions = 27 | always <| randomBytes GotRandomBytes 28 | , onUrlRequest = 29 | always NoOp 30 | , onUrlChange = 31 | always NoOp 32 | , view = 33 | view 34 | { title = "Auth0 - Flow: Implicit" 35 | , btnClass = class "btn-auth0" 36 | } 37 | } 38 | 39 | 40 | {-| OAuth configuration. 41 | 42 | Note that this demo also fetches basic user information with the obtained access token, 43 | hence the user info endpoint and JSON decoder 44 | 45 | -} 46 | configuration : Configuration 47 | configuration = 48 | { authorizationEndpoint = 49 | { defaultHttpsUrl | host = "elm-oauth2.eu.auth0.com", path = "/authorize" } 50 | , userInfoEndpoint = 51 | { defaultHttpsUrl | host = "elm-oauth2.eu.auth0.com", path = "/userinfo" } 52 | , userInfoDecoder = 53 | Json.map2 UserInfo 54 | (Json.field "name" Json.string) 55 | (Json.field "picture" Json.string) 56 | , clientId = 57 | "AbRrXEIRBPgkDrqR4RgdXuHoAd1mDetT" 58 | , scope = 59 | [ "openid", "profile" ] 60 | } 61 | 62 | 63 | 64 | -- 65 | -- Model 66 | -- 67 | 68 | 69 | type alias Model = 70 | { redirectUri : Url 71 | , flow : Flow 72 | } 73 | 74 | 75 | {-| This demo evolves around the following state-machine\* 76 | 77 | +--------+ 78 | | Idle | 79 | +--------+ 80 | | 81 | | Redirect user for authorization 82 | | 83 | v 84 | +--------------+ 85 | | Authorized | w/ Access Token 86 | +--------------+ 87 | | 88 | | Fetch user info using the access token 89 | v 90 | +--------+ 91 | | Done | 92 | +--------+ 93 | 94 | (\*) The 'Errored' state hasn't been represented here for simplicity. 95 | 96 | -} 97 | type Flow 98 | = Idle 99 | | Authorized OAuth.Token 100 | | Done UserInfo 101 | | Errored Error 102 | 103 | 104 | type Error 105 | = ErrStateMismatch 106 | | ErrAuthorization OAuth.AuthorizationError 107 | | ErrHTTPGetUserInfo 108 | 109 | 110 | type alias UserInfo = 111 | { name : String 112 | , picture : String 113 | } 114 | 115 | 116 | type alias Configuration = 117 | { authorizationEndpoint : Url 118 | , userInfoEndpoint : Url 119 | , userInfoDecoder : Json.Decoder UserInfo 120 | , clientId : String 121 | , scope : List String 122 | } 123 | 124 | 125 | {-| During the authentication flow, we'll run twice into the `init` function: 126 | 127 | - The first time, for the application very first run. And we proceed with the `Idle` state, 128 | waiting for the user (a.k.a you) to request a sign in. 129 | 130 | - The second time, after a sign in has been requested, the user is redirected to the 131 | authorization server and redirects the user back to our application, with an access 132 | token and other fields as query parameters. 133 | 134 | When query params are present (and valid), we consider the user `Authorized`. 135 | 136 | -} 137 | init : Maybe { state : String } -> Url -> Key -> ( Model, Cmd Msg ) 138 | init mflags origin navigationKey = 139 | let 140 | redirectUri = 141 | { origin | query = Nothing, fragment = Nothing } 142 | 143 | clearUrl = 144 | Navigation.replaceUrl navigationKey (Url.toString redirectUri) 145 | in 146 | case OAuth.parseToken origin of 147 | OAuth.Empty -> 148 | ( { flow = Idle, redirectUri = redirectUri } 149 | , Cmd.none 150 | ) 151 | 152 | -- It is important to set a `state` when making the authorization request 153 | -- and to verify it after the redirection. The state can be anything but its primary 154 | -- usage is to prevent cross-site request forgery; at minima, it should be a short, 155 | -- non-guessable string, generated on the fly. 156 | -- 157 | -- We remember any previously generated state state using the browser's local storage 158 | -- and give it back (if present) to the elm application upon start 159 | OAuth.Success { token, state } -> 160 | case mflags of 161 | Nothing -> 162 | ( { flow = Errored ErrStateMismatch, redirectUri = redirectUri } 163 | , clearUrl 164 | ) 165 | 166 | Just flags -> 167 | if state /= Just flags.state then 168 | ( { flow = Errored ErrStateMismatch, redirectUri = redirectUri } 169 | , clearUrl 170 | ) 171 | 172 | else 173 | ( { flow = Authorized token, redirectUri = redirectUri } 174 | , Cmd.batch 175 | -- Artificial delay to make the live demo easier to follow. 176 | -- In practice, the access token could be requested right here. 177 | [ after 750 Millisecond UserInfoRequested 178 | , clearUrl 179 | ] 180 | ) 181 | 182 | OAuth.Error error -> 183 | ( { flow = Errored <| ErrAuthorization error, redirectUri = redirectUri } 184 | , clearUrl 185 | ) 186 | 187 | 188 | 189 | -- 190 | -- Msg 191 | -- 192 | 193 | 194 | type Msg 195 | = NoOp 196 | | SignInRequested 197 | | GotRandomBytes (List Int) 198 | | GotAccessToken (Result Http.Error OAuth.AuthorizationSuccess) 199 | | UserInfoRequested 200 | | GotUserInfo (Result Http.Error UserInfo) 201 | | SignOutRequested 202 | 203 | 204 | getUserInfo : Configuration -> OAuth.Token -> Cmd Msg 205 | getUserInfo { userInfoDecoder, userInfoEndpoint } token = 206 | Http.request 207 | { method = "GET" 208 | , body = Http.emptyBody 209 | , headers = OAuth.useToken token [] 210 | , url = Url.toString userInfoEndpoint 211 | , expect = Http.expectJson GotUserInfo userInfoDecoder 212 | , timeout = Nothing 213 | , tracker = Nothing 214 | } 215 | 216 | 217 | 218 | {- On the JavaScript's side, we have: 219 | 220 | app.ports.genRandomBytes.subscribe(n => { 221 | const buffer = new Uint8Array(n); 222 | crypto.getRandomValues(buffer); 223 | const bytes = Array.from(buffer); 224 | localStorage.setItem("bytes", bytes); 225 | app.ports.randomBytes.send(bytes); 226 | }); 227 | -} 228 | 229 | 230 | port genRandomBytes : Int -> Cmd msg 231 | 232 | 233 | port randomBytes : (List Int -> msg) -> Sub msg 234 | 235 | 236 | 237 | -- 238 | -- Update 239 | -- 240 | 241 | 242 | update : Msg -> Model -> ( Model, Cmd Msg ) 243 | update msg model = 244 | case ( model.flow, msg ) of 245 | ( Idle, SignInRequested ) -> 246 | signInRequested model 247 | 248 | ( Idle, GotRandomBytes bytes ) -> 249 | gotRandomBytes model bytes 250 | 251 | ( Authorized token, UserInfoRequested ) -> 252 | userInfoRequested model token 253 | 254 | ( Authorized _, GotUserInfo userInfoResponse ) -> 255 | gotUserInfo model userInfoResponse 256 | 257 | ( Done _, SignOutRequested ) -> 258 | signOutRequested model 259 | 260 | _ -> 261 | noOp model 262 | 263 | 264 | noOp : Model -> ( Model, Cmd Msg ) 265 | noOp model = 266 | ( model, Cmd.none ) 267 | 268 | 269 | signInRequested : Model -> ( Model, Cmd Msg ) 270 | signInRequested model = 271 | ( { model | flow = Idle } 272 | , genRandomBytes 16 273 | ) 274 | 275 | 276 | gotRandomBytes : Model -> List Int -> ( Model, Cmd Msg ) 277 | gotRandomBytes model bytes = 278 | let 279 | { state } = 280 | convertBytes bytes 281 | 282 | authorization = 283 | { clientId = configuration.clientId 284 | , redirectUri = model.redirectUri 285 | , scope = configuration.scope 286 | , state = Just state 287 | , url = configuration.authorizationEndpoint 288 | } 289 | in 290 | ( { model | flow = Idle } 291 | , authorization 292 | |> OAuth.makeAuthorizationUrl 293 | |> Url.toString 294 | |> Navigation.load 295 | ) 296 | 297 | 298 | userInfoRequested : Model -> OAuth.Token -> ( Model, Cmd Msg ) 299 | userInfoRequested model token = 300 | ( { model | flow = Authorized token } 301 | , getUserInfo configuration token 302 | ) 303 | 304 | 305 | gotUserInfo : Model -> Result Http.Error UserInfo -> ( Model, Cmd Msg ) 306 | gotUserInfo model userInfoResponse = 307 | case userInfoResponse of 308 | Err _ -> 309 | ( { model | flow = Errored ErrHTTPGetUserInfo } 310 | , Cmd.none 311 | ) 312 | 313 | Ok userInfo -> 314 | ( { model | flow = Done userInfo } 315 | , Cmd.none 316 | ) 317 | 318 | 319 | signOutRequested : Model -> ( Model, Cmd Msg ) 320 | signOutRequested model = 321 | ( { model | flow = Idle } 322 | , Navigation.load (Url.toString model.redirectUri) 323 | ) 324 | 325 | 326 | 327 | -- 328 | -- View 329 | -- 330 | 331 | 332 | type alias ViewConfiguration msg = 333 | { title : String 334 | , btnClass : Attribute msg 335 | } 336 | 337 | 338 | view : ViewConfiguration Msg -> Model -> Document Msg 339 | view ({ title } as config) model = 340 | { title = title 341 | , body = viewBody config model 342 | } 343 | 344 | 345 | viewBody : ViewConfiguration Msg -> Model -> List (Html Msg) 346 | viewBody config model = 347 | [ div [ class "flex", class "flex-column", class "flex-space-around" ] <| 348 | case model.flow of 349 | Idle -> 350 | div [ class "flex" ] 351 | [ viewAuthorizationStep False 352 | , viewStepSeparator False 353 | , viewGetUserInfoStep False 354 | ] 355 | :: viewIdle config 356 | 357 | Authorized _ -> 358 | div [ class "flex" ] 359 | [ viewAuthorizationStep True 360 | , viewStepSeparator True 361 | , viewGetUserInfoStep False 362 | ] 363 | :: viewAuthorized 364 | 365 | Done userInfo -> 366 | div [ class "flex" ] 367 | [ viewAuthorizationStep True 368 | , viewStepSeparator True 369 | , viewGetUserInfoStep True 370 | ] 371 | :: viewUserInfo config userInfo 372 | 373 | Errored err -> 374 | div [ class "flex" ] 375 | [ viewErroredStep 376 | ] 377 | :: viewErrored err 378 | ] 379 | 380 | 381 | viewIdle : ViewConfiguration Msg -> List (Html Msg) 382 | viewIdle { btnClass } = 383 | [ button 384 | [ onClick SignInRequested, btnClass ] 385 | [ text "Sign in" ] 386 | ] 387 | 388 | 389 | viewAuthorized : List (Html Msg) 390 | viewAuthorized = 391 | [ span [] [ text "Getting user info..." ] 392 | ] 393 | 394 | 395 | viewUserInfo : ViewConfiguration Msg -> UserInfo -> List (Html Msg) 396 | viewUserInfo { btnClass } { name, picture } = 397 | [ div [ class "flex", class "flex-column" ] 398 | [ img [ class "avatar", src picture ] [] 399 | , p [] [ text name ] 400 | , div [] 401 | [ button 402 | [ onClick SignOutRequested, btnClass ] 403 | [ text "Sign out" ] 404 | ] 405 | ] 406 | ] 407 | 408 | 409 | viewErrored : Error -> List (Html Msg) 410 | viewErrored error = 411 | [ span [ class "span-error" ] [ viewError error ] ] 412 | 413 | 414 | viewError : Error -> Html Msg 415 | viewError e = 416 | text <| 417 | case e of 418 | ErrStateMismatch -> 419 | "'state' doesn't match, the request has likely been forged by an adversary!" 420 | 421 | ErrAuthorization error -> 422 | oauthErrorToString { error = error.error, errorDescription = error.errorDescription } 423 | 424 | ErrHTTPGetUserInfo -> 425 | "Unable to retrieve user info: HTTP request failed." 426 | 427 | 428 | viewAuthorizationStep : Bool -> Html Msg 429 | viewAuthorizationStep isActive = 430 | viewStep isActive ( "Authorization", style "left" "-110%" ) 431 | 432 | 433 | viewGetUserInfoStep : Bool -> Html Msg 434 | viewGetUserInfoStep isActive = 435 | viewStep isActive ( "Get User Info", style "left" "-135%" ) 436 | 437 | 438 | viewErroredStep : Html Msg 439 | viewErroredStep = 440 | div 441 | [ class "step", class "step-errored" ] 442 | [ span [ style "left" "-50%" ] [ text "Errored" ] ] 443 | 444 | 445 | viewStep : Bool -> ( String, Attribute Msg ) -> Html Msg 446 | viewStep isActive ( step, position ) = 447 | let 448 | stepClass = 449 | class "step" 450 | :: (if isActive then 451 | [ class "step-active" ] 452 | 453 | else 454 | [] 455 | ) 456 | in 457 | div stepClass [ span [ position ] [ text step ] ] 458 | 459 | 460 | viewStepSeparator : Bool -> Html Msg 461 | viewStepSeparator isActive = 462 | let 463 | stepClass = 464 | class "step-separator" 465 | :: (if isActive then 466 | [ class "step-active" ] 467 | 468 | else 469 | [] 470 | ) 471 | in 472 | span stepClass [] 473 | 474 | 475 | 476 | -- 477 | -- Helpers 478 | -- 479 | 480 | 481 | toBytes : List Int -> Bytes 482 | toBytes = 483 | List.map Bytes.unsignedInt8 >> Bytes.sequence >> Bytes.encode 484 | 485 | 486 | base64 : Bytes -> String 487 | base64 = 488 | Base64.bytes >> Base64.encode 489 | 490 | 491 | convertBytes : List Int -> { state : String } 492 | convertBytes = 493 | toBytes >> base64 >> (\state -> { state = state }) 494 | 495 | 496 | oauthErrorToString : { error : OAuth.ErrorCode, errorDescription : Maybe String } -> String 497 | oauthErrorToString { error, errorDescription } = 498 | let 499 | desc = 500 | errorDescription |> Maybe.withDefault "" |> String.replace "+" " " 501 | in 502 | OAuth.errorCodeToString error ++ ": " ++ desc 503 | 504 | 505 | defaultHttpsUrl : Url 506 | defaultHttpsUrl = 507 | { protocol = Https 508 | , host = "" 509 | , path = "" 510 | , port_ = Nothing 511 | , query = Nothing 512 | , fragment = Nothing 513 | } 514 | -------------------------------------------------------------------------------- /examples/providers/auth0/implicit/README.md: -------------------------------------------------------------------------------- 1 | # Auth0 2 | 3 |

4 | 5 |

6 | -------------------------------------------------------------------------------- /examples/providers/auth0/implicit/elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src", 5 | "." 6 | ], 7 | "elm-version": "0.19.1", 8 | "dependencies": { 9 | "direct": { 10 | "andrewMacmurray/elm-delay": "3.0.0", 11 | "elm/browser": "1.0.2", 12 | "elm/bytes": "1.0.8", 13 | "elm/core": "1.0.4", 14 | "elm/html": "1.0.0", 15 | "elm/http": "2.0.0", 16 | "elm/json": "1.1.3", 17 | "elm/url": "1.0.0", 18 | "folkertdev/elm-sha2": "1.0.0", 19 | "chelovek0v/bbase64": "1.0.1" 20 | }, 21 | "indirect": { 22 | "danfishgold/base64-bytes": "1.0.3", 23 | "elm/file": "1.0.5", 24 | "elm/regex": "1.0.0", 25 | "elm/time": "1.0.0", 26 | "elm/virtual-dom": "1.0.2", 27 | "rtfeldman/elm-hex": "1.0.0" 28 | } 29 | }, 30 | "test-dependencies": { 31 | "direct": {}, 32 | "indirect": {} 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /examples/providers/auth0/implicit/src: -------------------------------------------------------------------------------- 1 | ../../../../src/ -------------------------------------------------------------------------------- /examples/providers/auth0/pkce/README.md: -------------------------------------------------------------------------------- 1 | # Auth0 2 | 3 |

4 | 5 |

6 | -------------------------------------------------------------------------------- /examples/providers/auth0/pkce/elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src", 5 | "." 6 | ], 7 | "elm-version": "0.19.1", 8 | "dependencies": { 9 | "direct": { 10 | "andrewMacmurray/elm-delay": "3.0.0", 11 | "elm/browser": "1.0.2", 12 | "elm/bytes": "1.0.8", 13 | "elm/core": "1.0.4", 14 | "elm/html": "1.0.0", 15 | "elm/http": "2.0.0", 16 | "elm/json": "1.1.3", 17 | "elm/url": "1.0.0", 18 | "folkertdev/elm-sha2": "1.0.0", 19 | "chelovek0v/bbase64": "1.0.1" 20 | }, 21 | "indirect": { 22 | "danfishgold/base64-bytes": "1.0.3", 23 | "elm/file": "1.0.5", 24 | "elm/regex": "1.0.0", 25 | "elm/time": "1.0.0", 26 | "elm/virtual-dom": "1.0.2", 27 | "rtfeldman/elm-hex": "1.0.0" 28 | } 29 | }, 30 | "test-dependencies": { 31 | "direct": {}, 32 | "indirect": {} 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /examples/providers/auth0/pkce/src: -------------------------------------------------------------------------------- 1 | ../../../../src/ -------------------------------------------------------------------------------- /examples/providers/facebook/README.md: -------------------------------------------------------------------------------- 1 | # Facebook 2 | 3 | ## Authorization Flows 4 | 5 | Flow | Support | Remark | Example 6 | --- | --- | --- | --- 7 | Implicit | :heavy_check_mark: | Non-standard parsers required | [live demo][implicit-demo] \| [source code][implicit-source] 8 | Authorization Code | :heavy_check_mark: | Non-standard parsers required
Requires secret key (server-side) | N/A 9 | Authorization Code w/ PKCE | :x: | \- | \- 10 | Password | :heavy_check_mark: | \- | \- 11 | Client Credentials | :heavy_check_mark: | Requires secret key (server-side) | N/A 12 | 13 | ## OAuth Configuration 14 | 15 | \- | \- 16 | --- | --- 17 | Authorization Endpoint | facebook.com/v6.0/dialog/oauth 18 | Token Endpoint | graph.facebook.com/v6.0/oauth/access_token 19 | User Info Endpoint | graph.facebook.com/v6.0/me 20 | 21 | --- 22 | 23 | :book: https://developers.facebook.com/docs/facebook-login/manually-build-a-login-flow 24 | 25 | 26 | [implicit-demo]: https://truqu.github.io/elm-oauth2/facebook/implicit/ 27 | [implicit-source]: https://github.com/truqu/elm-oauth2/blob/master/examples/providers/facebook/implicit/Main.elm 28 | -------------------------------------------------------------------------------- /examples/providers/facebook/implicit/Main.elm: -------------------------------------------------------------------------------- 1 | port module Main exposing (main) 2 | 3 | import Base64.Encode as Base64 4 | import Browser exposing (Document, application) 5 | import Browser.Navigation as Navigation exposing (Key) 6 | import Bytes exposing (Bytes) 7 | import Bytes.Encode as Bytes 8 | import Delay exposing (TimeUnit(..), after) 9 | import Html exposing (..) 10 | import Html.Attributes exposing (..) 11 | import Html.Events exposing (..) 12 | import Http 13 | import Json.Decode as Json 14 | import OAuth 15 | import OAuth.Implicit as OAuth 16 | import Url exposing (Protocol(..), Url) 17 | import Url.Parser.Query as Query 18 | 19 | 20 | main : Program (Maybe (List Int)) Model Msg 21 | main = 22 | application 23 | { init = 24 | Maybe.map convertBytes >> init 25 | , update = 26 | update 27 | , subscriptions = 28 | always <| randomBytes GotRandomBytes 29 | , onUrlRequest = 30 | always NoOp 31 | , onUrlChange = 32 | always NoOp 33 | , view = 34 | view 35 | { title = "Facebook - Flow: Implicit" 36 | , btnClass = class "btn-facebook" 37 | } 38 | } 39 | 40 | 41 | {-| OAuth configuration. 42 | 43 | Note that this demo also fetches basic user information with the obtained access token, 44 | hence the user info endpoint and JSON decoder 45 | 46 | -} 47 | configuration : Configuration 48 | configuration = 49 | { authorizationEndpoint = 50 | { defaultHttpsUrl | host = "facebook.com", path = "/v6.0/dialog/oauth" } 51 | , userInfoEndpoint = 52 | { defaultHttpsUrl | host = "graph.facebook.com", path = "/v6.0/me", query = Just "fields=name,picture.type(large)" } 53 | , userInfoDecoder = 54 | Json.map2 UserInfo 55 | (Json.field "name" Json.string) 56 | (Json.field "picture" <| Json.field "data" <| Json.field "url" Json.string) 57 | , clientId = 58 | "179456896198275" 59 | , scope = 60 | [ "public_profile" ] 61 | } 62 | 63 | 64 | 65 | -- 66 | -- Model 67 | -- 68 | 69 | 70 | type alias Model = 71 | { redirectUri : Url 72 | , flow : Flow 73 | } 74 | 75 | 76 | {-| This demo evolves around the following state-machine\* 77 | 78 | +--------+ 79 | | Idle | 80 | +--------+ 81 | | 82 | | Redirect user for authorization 83 | | 84 | v 85 | +--------------+ 86 | | Authorized | w/ Access Token 87 | +--------------+ 88 | | 89 | | Fetch user info using the access token 90 | v 91 | +--------+ 92 | | Done | 93 | +--------+ 94 | 95 | (\*) The 'Errored' state hasn't been represented here for simplicity. 96 | 97 | -} 98 | type Flow 99 | = Idle 100 | | Authorized OAuth.Token 101 | | Done UserInfo 102 | | Errored Error 103 | 104 | 105 | type Error 106 | = ErrStateMismatch 107 | | ErrAuthorization OAuth.AuthorizationError 108 | | ErrHTTPGetUserInfo 109 | 110 | 111 | type alias UserInfo = 112 | { name : String 113 | , picture : String 114 | } 115 | 116 | 117 | type alias Configuration = 118 | { authorizationEndpoint : Url 119 | , userInfoEndpoint : Url 120 | , userInfoDecoder : Json.Decoder UserInfo 121 | , clientId : String 122 | , scope : List String 123 | } 124 | 125 | 126 | {-| During the authentication flow, we'll run twice into the `init` function: 127 | 128 | - The first time, for the application very first run. And we proceed with the `Idle` state, 129 | waiting for the user (a.k.a you) to request a sign in. 130 | 131 | - The second time, after a sign in has been requested, the user is redirected to the 132 | authorization server and redirects the user back to our application, with an access 133 | token and other fields as query parameters. 134 | 135 | When query params are present (and valid), we consider the user `Authorized`. 136 | 137 | -} 138 | init : Maybe { state : String } -> Url -> Key -> ( Model, Cmd Msg ) 139 | init mflags origin navigationKey = 140 | let 141 | redirectUri = 142 | { origin | query = Nothing, fragment = Nothing } 143 | 144 | clearUrl = 145 | Navigation.replaceUrl navigationKey (Url.toString redirectUri) 146 | in 147 | case OAuth.parseTokenWith parsers (patchUrl origin) of 148 | OAuth.Empty -> 149 | ( { flow = Idle, redirectUri = redirectUri } 150 | , Cmd.none 151 | ) 152 | 153 | -- It is important to set a `state` when making the authorization request 154 | -- and to verify it after the redirection. The state can be anything but its primary 155 | -- usage is to prevent cross-site request forgery; at minima, it should be a short, 156 | -- non-guessable string, generated on the fly. 157 | -- 158 | -- We remember any previously generated state state using the browser's local storage 159 | -- and give it back (if present) to the elm application upon start 160 | OAuth.Success { token, state } -> 161 | case mflags of 162 | Nothing -> 163 | ( { flow = Errored ErrStateMismatch, redirectUri = redirectUri } 164 | , clearUrl 165 | ) 166 | 167 | Just flags -> 168 | if state /= Just flags.state then 169 | ( { flow = Errored ErrStateMismatch, redirectUri = redirectUri } 170 | , clearUrl 171 | ) 172 | 173 | else 174 | ( { flow = Authorized token, redirectUri = redirectUri } 175 | , Cmd.batch 176 | -- Artificial delay to make the live demo easier to follow. 177 | -- In practice, the access token could be requested right here. 178 | [ after 750 Millisecond UserInfoRequested 179 | , clearUrl 180 | ] 181 | ) 182 | 183 | OAuth.Error error -> 184 | ( { flow = Errored <| ErrAuthorization error, redirectUri = redirectUri } 185 | , clearUrl 186 | ) 187 | 188 | 189 | 190 | -- 191 | -- Msg 192 | -- 193 | 194 | 195 | type Msg 196 | = NoOp 197 | | SignInRequested 198 | | GotRandomBytes (List Int) 199 | | GotAccessToken (Result Http.Error OAuth.AuthorizationSuccess) 200 | | UserInfoRequested 201 | | GotUserInfo (Result Http.Error UserInfo) 202 | | SignOutRequested 203 | 204 | 205 | getUserInfo : Configuration -> OAuth.Token -> Cmd Msg 206 | getUserInfo { userInfoDecoder, userInfoEndpoint } token = 207 | Http.request 208 | { method = "GET" 209 | , body = Http.emptyBody 210 | , headers = OAuth.useToken token [] 211 | , url = Url.toString userInfoEndpoint 212 | , expect = Http.expectJson GotUserInfo userInfoDecoder 213 | , timeout = Nothing 214 | , tracker = Nothing 215 | } 216 | 217 | 218 | 219 | {- On the JavaScript's side, we have: 220 | 221 | app.ports.genRandomBytes.subscribe(n => { 222 | const buffer = new Uint8Array(n); 223 | crypto.getRandomValues(buffer); 224 | const bytes = Array.from(buffer); 225 | localStorage.setItem("bytes", bytes); 226 | app.ports.randomBytes.send(bytes); 227 | }); 228 | -} 229 | 230 | 231 | port genRandomBytes : Int -> Cmd msg 232 | 233 | 234 | port randomBytes : (List Int -> msg) -> Sub msg 235 | 236 | 237 | 238 | -- 239 | -- Update 240 | -- 241 | 242 | 243 | update : Msg -> Model -> ( Model, Cmd Msg ) 244 | update msg model = 245 | case ( model.flow, msg ) of 246 | ( Idle, SignInRequested ) -> 247 | signInRequested model 248 | 249 | ( Idle, GotRandomBytes bytes ) -> 250 | gotRandomBytes model bytes 251 | 252 | ( Authorized token, UserInfoRequested ) -> 253 | userInfoRequested model token 254 | 255 | ( Authorized _, GotUserInfo userInfoResponse ) -> 256 | gotUserInfo model userInfoResponse 257 | 258 | ( Done _, SignOutRequested ) -> 259 | signOutRequested model 260 | 261 | _ -> 262 | noOp model 263 | 264 | 265 | noOp : Model -> ( Model, Cmd Msg ) 266 | noOp model = 267 | ( model, Cmd.none ) 268 | 269 | 270 | signInRequested : Model -> ( Model, Cmd Msg ) 271 | signInRequested model = 272 | ( { model | flow = Idle } 273 | , genRandomBytes 16 274 | ) 275 | 276 | 277 | gotRandomBytes : Model -> List Int -> ( Model, Cmd Msg ) 278 | gotRandomBytes model bytes = 279 | let 280 | { state } = 281 | convertBytes bytes 282 | 283 | authorization = 284 | { clientId = configuration.clientId 285 | , redirectUri = model.redirectUri 286 | , scope = configuration.scope 287 | , state = Just state 288 | , url = configuration.authorizationEndpoint 289 | } 290 | in 291 | ( { model | flow = Idle } 292 | , authorization 293 | |> OAuth.makeAuthorizationUrl 294 | |> Url.toString 295 | |> Navigation.load 296 | ) 297 | 298 | 299 | userInfoRequested : Model -> OAuth.Token -> ( Model, Cmd Msg ) 300 | userInfoRequested model token = 301 | ( { model | flow = Authorized token } 302 | , getUserInfo configuration token 303 | ) 304 | 305 | 306 | gotUserInfo : Model -> Result Http.Error UserInfo -> ( Model, Cmd Msg ) 307 | gotUserInfo model userInfoResponse = 308 | case userInfoResponse of 309 | Err _ -> 310 | ( { model | flow = Errored ErrHTTPGetUserInfo } 311 | , Cmd.none 312 | ) 313 | 314 | Ok userInfo -> 315 | ( { model | flow = Done userInfo } 316 | , Cmd.none 317 | ) 318 | 319 | 320 | signOutRequested : Model -> ( Model, Cmd Msg ) 321 | signOutRequested model = 322 | ( { model | flow = Idle } 323 | , Navigation.load (Url.toString model.redirectUri) 324 | ) 325 | 326 | 327 | 328 | -- 329 | -- View 330 | -- 331 | 332 | 333 | type alias ViewConfiguration msg = 334 | { title : String 335 | , btnClass : Attribute msg 336 | } 337 | 338 | 339 | view : ViewConfiguration Msg -> Model -> Document Msg 340 | view ({ title } as config) model = 341 | { title = title 342 | , body = viewBody config model 343 | } 344 | 345 | 346 | viewBody : ViewConfiguration Msg -> Model -> List (Html Msg) 347 | viewBody config model = 348 | [ div [ class "flex", class "flex-column", class "flex-space-around" ] <| 349 | case model.flow of 350 | Idle -> 351 | div [ class "flex" ] 352 | [ viewAuthorizationStep False 353 | , viewStepSeparator False 354 | , viewGetUserInfoStep False 355 | ] 356 | :: viewIdle config 357 | 358 | Authorized _ -> 359 | div [ class "flex" ] 360 | [ viewAuthorizationStep True 361 | , viewStepSeparator True 362 | , viewGetUserInfoStep False 363 | ] 364 | :: viewAuthorized 365 | 366 | Done userInfo -> 367 | div [ class "flex" ] 368 | [ viewAuthorizationStep True 369 | , viewStepSeparator True 370 | , viewGetUserInfoStep True 371 | ] 372 | :: viewUserInfo config userInfo 373 | 374 | Errored err -> 375 | div [ class "flex" ] 376 | [ viewErroredStep 377 | ] 378 | :: viewErrored err 379 | ] 380 | 381 | 382 | viewIdle : ViewConfiguration Msg -> List (Html Msg) 383 | viewIdle { btnClass } = 384 | [ button 385 | [ onClick SignInRequested, btnClass ] 386 | [ text "Sign in" ] 387 | ] 388 | 389 | 390 | viewAuthorized : List (Html Msg) 391 | viewAuthorized = 392 | [ span [] [ text "Getting user info..." ] 393 | ] 394 | 395 | 396 | viewUserInfo : ViewConfiguration Msg -> UserInfo -> List (Html Msg) 397 | viewUserInfo { btnClass } { name, picture } = 398 | [ div [ class "flex", class "flex-column" ] 399 | [ img [ class "avatar", src picture ] [] 400 | , p [] [ text name ] 401 | , div [] 402 | [ button 403 | [ onClick SignOutRequested, btnClass ] 404 | [ text "Sign out" ] 405 | ] 406 | ] 407 | ] 408 | 409 | 410 | viewErrored : Error -> List (Html Msg) 411 | viewErrored error = 412 | [ span [ class "span-error" ] [ viewError error ] ] 413 | 414 | 415 | viewError : Error -> Html Msg 416 | viewError e = 417 | text <| 418 | case e of 419 | ErrStateMismatch -> 420 | "'state' doesn't match, the request has likely been forged by an adversary!" 421 | 422 | ErrAuthorization error -> 423 | oauthErrorToString { error = error.error, errorDescription = error.errorDescription } 424 | 425 | ErrHTTPGetUserInfo -> 426 | "Unable to retrieve user info: HTTP request failed." 427 | 428 | 429 | viewAuthorizationStep : Bool -> Html Msg 430 | viewAuthorizationStep isActive = 431 | viewStep isActive ( "Authorization", style "left" "-110%" ) 432 | 433 | 434 | viewGetUserInfoStep : Bool -> Html Msg 435 | viewGetUserInfoStep isActive = 436 | viewStep isActive ( "Get User Info", style "left" "-135%" ) 437 | 438 | 439 | viewErroredStep : Html Msg 440 | viewErroredStep = 441 | div 442 | [ class "step", class "step-errored" ] 443 | [ span [ style "left" "-50%" ] [ text "Errored" ] ] 444 | 445 | 446 | viewStep : Bool -> ( String, Attribute Msg ) -> Html Msg 447 | viewStep isActive ( step, position ) = 448 | let 449 | stepClass = 450 | class "step" 451 | :: (if isActive then 452 | [ class "step-active" ] 453 | 454 | else 455 | [] 456 | ) 457 | in 458 | div stepClass [ span [ position ] [ text step ] ] 459 | 460 | 461 | viewStepSeparator : Bool -> Html Msg 462 | viewStepSeparator isActive = 463 | let 464 | stepClass = 465 | class "step-separator" 466 | :: (if isActive then 467 | [ class "step-active" ] 468 | 469 | else 470 | [] 471 | ) 472 | in 473 | span stepClass [] 474 | 475 | 476 | 477 | -- 478 | -- Facebook Wrong Implementation Work-Arounds 479 | -- 480 | 481 | 482 | {-| No 'token\_type' is returned, so we have to provide a default one as Just "Bearer". 483 | -} 484 | tokenParser : Query.Parser (Maybe OAuth.Token) 485 | tokenParser = 486 | Query.map (OAuth.makeToken (Just "Bearer")) 487 | (Query.string "access_token") 488 | 489 | 490 | {-| In case of error, no 'error' field is returned, but instead we find a field named 'error\_code' 491 | -} 492 | errorParser : Query.Parser (Maybe OAuth.ErrorCode) 493 | errorParser = 494 | Query.map (Maybe.map OAuth.errorCodeFromString) 495 | (Query.string "error_code") 496 | 497 | 498 | {-| Put everything together and rely on `OAuth.parseTokenWith` instead of the default parser 499 | -} 500 | parsers : OAuth.Parsers OAuth.AuthorizationError OAuth.AuthorizationSuccess 501 | parsers = 502 | { tokenParser = tokenParser 503 | , errorParser = errorParser 504 | , authorizationSuccessParser = OAuth.defaultAuthorizationSuccessParser 505 | , authorizationErrorParser = OAuth.defaultAuthorizationErrorParser 506 | } 507 | 508 | 509 | {-| In addition, Facebook returns parameters as query parameters instead of a fragments, and sometimes, a noise fragment is present in the response. So, as a work-around, one can patch the Url to make it compliant with the original RFC specification as follows: 510 | -} 511 | patchUrl : Url -> Url 512 | patchUrl url = 513 | if url.fragment == Just "_=_" || url.fragment == Nothing then 514 | { url | fragment = url.query } 515 | 516 | else 517 | url 518 | 519 | 520 | 521 | -- 522 | -- Helpers 523 | -- 524 | 525 | 526 | toBytes : List Int -> Bytes 527 | toBytes = 528 | List.map Bytes.unsignedInt8 >> Bytes.sequence >> Bytes.encode 529 | 530 | 531 | base64 : Bytes -> String 532 | base64 = 533 | Base64.bytes >> Base64.encode 534 | 535 | 536 | convertBytes : List Int -> { state : String } 537 | convertBytes = 538 | toBytes >> base64 >> (\state -> { state = state }) 539 | 540 | 541 | oauthErrorToString : { error : OAuth.ErrorCode, errorDescription : Maybe String } -> String 542 | oauthErrorToString { error, errorDescription } = 543 | let 544 | desc = 545 | errorDescription |> Maybe.withDefault "" |> String.replace "+" " " 546 | in 547 | OAuth.errorCodeToString error ++ ": " ++ desc 548 | 549 | 550 | defaultHttpsUrl : Url 551 | defaultHttpsUrl = 552 | { protocol = Https 553 | , host = "" 554 | , path = "" 555 | , port_ = Nothing 556 | , query = Nothing 557 | , fragment = Nothing 558 | } 559 | -------------------------------------------------------------------------------- /examples/providers/facebook/implicit/elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src", 5 | "." 6 | ], 7 | "elm-version": "0.19.1", 8 | "dependencies": { 9 | "direct": { 10 | "andrewMacmurray/elm-delay": "3.0.0", 11 | "elm/browser": "1.0.2", 12 | "elm/bytes": "1.0.8", 13 | "elm/core": "1.0.4", 14 | "elm/html": "1.0.0", 15 | "elm/http": "2.0.0", 16 | "elm/json": "1.1.3", 17 | "elm/url": "1.0.0", 18 | "folkertdev/elm-sha2": "1.0.0", 19 | "chelovek0v/bbase64": "1.0.1" 20 | }, 21 | "indirect": { 22 | "danfishgold/base64-bytes": "1.0.3", 23 | "elm/file": "1.0.5", 24 | "elm/regex": "1.0.0", 25 | "elm/time": "1.0.0", 26 | "elm/virtual-dom": "1.0.2", 27 | "rtfeldman/elm-hex": "1.0.0" 28 | } 29 | }, 30 | "test-dependencies": { 31 | "direct": {}, 32 | "indirect": {} 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /examples/providers/facebook/implicit/src: -------------------------------------------------------------------------------- 1 | ../../../../src/ -------------------------------------------------------------------------------- /examples/providers/google/README.md: -------------------------------------------------------------------------------- 1 | # Google 2 | 3 | ## Authorization Flows 4 | 5 | Flow | Support | Remark | Example 6 | --- | --- | --- | --- 7 | Implicit | :heavy_check_mark: | \- | [live demo][implicit-demo] \| [source code][implicit-source] 8 | Authorization Code | :heavy_check_mark: | Requires secret key (server-side) | N/A 9 | Authorization Code w/ PKCE | :heavy_check_mark: | Requires secret key (server-side) | N/A 10 | Password | :heavy_check_mark: | Requires secret key (server-side) | N/A 11 | Client Credentials | :heavy_check_mark: | Requires secret key (server-side) | N/A 12 | 13 | ## OAuth Configuration 14 | 15 | \- | \- 16 | --- | --- 17 | Authorization Endpoint | accounts.google.com/o/oauth2/v2/auth 18 | Token Endpoint | www.googleapis.com/oauth2/v4/token 19 | User Info Endpoint | www.googleapis.com/oauth2/v1/userinfo 20 | 21 | --- 22 | 23 | :book: https://developers.google.com/identity/protocols/OAuth2 24 | 25 | 26 | [implicit-demo]: https://truqu.github.io/elm-oauth2/google/implicit/ 27 | [implicit-source]: https://github.com/truqu/elm-oauth2/blob/master/examples/providers/google/implicit/Main.elm 28 | -------------------------------------------------------------------------------- /examples/providers/google/implicit/Main.elm: -------------------------------------------------------------------------------- 1 | port module Main exposing (main) 2 | 3 | import Base64.Encode as Base64 4 | import Browser exposing (Document, application) 5 | import Browser.Navigation as Navigation exposing (Key) 6 | import Bytes exposing (Bytes) 7 | import Bytes.Encode as Bytes 8 | import Delay exposing (TimeUnit(..), after) 9 | import Html exposing (..) 10 | import Html.Attributes exposing (..) 11 | import Html.Events exposing (..) 12 | import Http 13 | import Json.Decode as Json 14 | import OAuth 15 | import OAuth.Implicit as OAuth 16 | import Url exposing (Protocol(..), Url) 17 | 18 | 19 | main : Program (Maybe (List Int)) Model Msg 20 | main = 21 | application 22 | { init = 23 | Maybe.map convertBytes >> init 24 | , update = 25 | update 26 | , subscriptions = 27 | always <| randomBytes GotRandomBytes 28 | , onUrlRequest = 29 | always NoOp 30 | , onUrlChange = 31 | always NoOp 32 | , view = 33 | view 34 | { title = "Google - Flow: Implicit" 35 | , btnClass = class "btn-google" 36 | } 37 | } 38 | 39 | 40 | {-| OAuth configuration. 41 | 42 | Note that this demo also fetches basic user information with the obtained access token, 43 | hence the user info endpoint and JSON decoder 44 | 45 | -} 46 | configuration : Configuration 47 | configuration = 48 | { authorizationEndpoint = 49 | { defaultHttpsUrl | host = "accounts.google.com", path = "/o/oauth2/v2/auth" } 50 | , userInfoEndpoint = 51 | { defaultHttpsUrl | host = "www.googleapis.com", path = "/oauth2/v1/userinfo" } 52 | , userInfoDecoder = 53 | Json.map2 UserInfo 54 | (Json.field "name" Json.string) 55 | (Json.field "picture" Json.string) 56 | , clientId = 57 | "909608474358-fkok86ks7e83c47aq01aiit47vsoh4s0.apps.googleusercontent.com" 58 | , scope = 59 | [ "profile" ] 60 | } 61 | 62 | 63 | 64 | -- 65 | -- Model 66 | -- 67 | 68 | 69 | type alias Model = 70 | { redirectUri : Url 71 | , flow : Flow 72 | } 73 | 74 | 75 | {-| This demo evolves around the following state-machine\* 76 | 77 | +--------+ 78 | | Idle | 79 | +--------+ 80 | | 81 | | Redirect user for authorization 82 | | 83 | v 84 | +--------------+ 85 | | Authorized | w/ Access Token 86 | +--------------+ 87 | | 88 | | Fetch user info using the access token 89 | v 90 | +--------+ 91 | | Done | 92 | +--------+ 93 | 94 | (\*) The 'Errored' state hasn't been represented here for simplicity. 95 | 96 | -} 97 | type Flow 98 | = Idle 99 | | Authorized OAuth.Token 100 | | Done UserInfo 101 | | Errored Error 102 | 103 | 104 | type Error 105 | = ErrStateMismatch 106 | | ErrAuthorization OAuth.AuthorizationError 107 | | ErrHTTPGetUserInfo 108 | 109 | 110 | type alias UserInfo = 111 | { name : String 112 | , picture : String 113 | } 114 | 115 | 116 | type alias Configuration = 117 | { authorizationEndpoint : Url 118 | , userInfoEndpoint : Url 119 | , userInfoDecoder : Json.Decoder UserInfo 120 | , clientId : String 121 | , scope : List String 122 | } 123 | 124 | 125 | {-| During the authentication flow, we'll run twice into the `init` function: 126 | 127 | - The first time, for the application very first run. And we proceed with the `Idle` state, 128 | waiting for the user (a.k.a you) to request a sign in. 129 | 130 | - The second time, after a sign in has been requested, the user is redirected to the 131 | authorization server and redirects the user back to our application, with an access 132 | token and other fields as query parameters. 133 | 134 | When query params are present (and valid), we consider the user `Authorized`. 135 | 136 | -} 137 | init : Maybe { state : String } -> Url -> Key -> ( Model, Cmd Msg ) 138 | init mflags origin navigationKey = 139 | let 140 | redirectUri = 141 | { origin | query = Nothing, fragment = Nothing } 142 | 143 | clearUrl = 144 | Navigation.replaceUrl navigationKey (Url.toString redirectUri) 145 | in 146 | case OAuth.parseToken origin of 147 | OAuth.Empty -> 148 | ( { flow = Idle, redirectUri = redirectUri } 149 | , Cmd.none 150 | ) 151 | 152 | -- It is important to set a `state` when making the authorization request 153 | -- and to verify it after the redirection. The state can be anything but its primary 154 | -- usage is to prevent cross-site request forgery; at minima, it should be a short, 155 | -- non-guessable string, generated on the fly. 156 | -- 157 | -- We remember any previously generated state state using the browser's local storage 158 | -- and give it back (if present) to the elm application upon start 159 | OAuth.Success { token, state } -> 160 | case mflags of 161 | Nothing -> 162 | ( { flow = Errored ErrStateMismatch, redirectUri = redirectUri } 163 | , clearUrl 164 | ) 165 | 166 | Just flags -> 167 | if state /= Just flags.state then 168 | ( { flow = Errored ErrStateMismatch, redirectUri = redirectUri } 169 | , clearUrl 170 | ) 171 | 172 | else 173 | ( { flow = Authorized token, redirectUri = redirectUri } 174 | , Cmd.batch 175 | -- Artificial delay to make the live demo easier to follow. 176 | -- In practice, the access token could be requested right here. 177 | [ after 750 Millisecond UserInfoRequested 178 | , clearUrl 179 | ] 180 | ) 181 | 182 | OAuth.Error error -> 183 | ( { flow = Errored <| ErrAuthorization error, redirectUri = redirectUri } 184 | , clearUrl 185 | ) 186 | 187 | 188 | 189 | -- 190 | -- Msg 191 | -- 192 | 193 | 194 | type Msg 195 | = NoOp 196 | | SignInRequested 197 | | GotRandomBytes (List Int) 198 | | GotAccessToken (Result Http.Error OAuth.AuthorizationSuccess) 199 | | UserInfoRequested 200 | | GotUserInfo (Result Http.Error UserInfo) 201 | | SignOutRequested 202 | 203 | 204 | getUserInfo : Configuration -> OAuth.Token -> Cmd Msg 205 | getUserInfo { userInfoDecoder, userInfoEndpoint } token = 206 | Http.request 207 | { method = "GET" 208 | , body = Http.emptyBody 209 | , headers = OAuth.useToken token [] 210 | , url = Url.toString userInfoEndpoint 211 | , expect = Http.expectJson GotUserInfo userInfoDecoder 212 | , timeout = Nothing 213 | , tracker = Nothing 214 | } 215 | 216 | 217 | 218 | {- On the JavaScript's side, we have: 219 | 220 | app.ports.genRandomBytes.subscribe(n => { 221 | const buffer = new Uint8Array(n); 222 | crypto.getRandomValues(buffer); 223 | const bytes = Array.from(buffer); 224 | localStorage.setItem("bytes", bytes); 225 | app.ports.randomBytes.send(bytes); 226 | }); 227 | -} 228 | 229 | 230 | port genRandomBytes : Int -> Cmd msg 231 | 232 | 233 | port randomBytes : (List Int -> msg) -> Sub msg 234 | 235 | 236 | 237 | -- 238 | -- Update 239 | -- 240 | 241 | 242 | update : Msg -> Model -> ( Model, Cmd Msg ) 243 | update msg model = 244 | case ( model.flow, msg ) of 245 | ( Idle, SignInRequested ) -> 246 | signInRequested model 247 | 248 | ( Idle, GotRandomBytes bytes ) -> 249 | gotRandomBytes model bytes 250 | 251 | ( Authorized token, UserInfoRequested ) -> 252 | userInfoRequested model token 253 | 254 | ( Authorized _, GotUserInfo userInfoResponse ) -> 255 | gotUserInfo model userInfoResponse 256 | 257 | ( Done _, SignOutRequested ) -> 258 | signOutRequested model 259 | 260 | _ -> 261 | noOp model 262 | 263 | 264 | noOp : Model -> ( Model, Cmd Msg ) 265 | noOp model = 266 | ( model, Cmd.none ) 267 | 268 | 269 | signInRequested : Model -> ( Model, Cmd Msg ) 270 | signInRequested model = 271 | ( { model | flow = Idle } 272 | , genRandomBytes 16 273 | ) 274 | 275 | 276 | gotRandomBytes : Model -> List Int -> ( Model, Cmd Msg ) 277 | gotRandomBytes model bytes = 278 | let 279 | { state } = 280 | convertBytes bytes 281 | 282 | authorization = 283 | { clientId = configuration.clientId 284 | , redirectUri = model.redirectUri 285 | , scope = configuration.scope 286 | , state = Just state 287 | , url = configuration.authorizationEndpoint 288 | } 289 | in 290 | ( { model | flow = Idle } 291 | , authorization 292 | |> OAuth.makeAuthorizationUrl 293 | |> Url.toString 294 | |> Navigation.load 295 | ) 296 | 297 | 298 | userInfoRequested : Model -> OAuth.Token -> ( Model, Cmd Msg ) 299 | userInfoRequested model token = 300 | ( { model | flow = Authorized token } 301 | , getUserInfo configuration token 302 | ) 303 | 304 | 305 | gotUserInfo : Model -> Result Http.Error UserInfo -> ( Model, Cmd Msg ) 306 | gotUserInfo model userInfoResponse = 307 | case userInfoResponse of 308 | Err _ -> 309 | ( { model | flow = Errored ErrHTTPGetUserInfo } 310 | , Cmd.none 311 | ) 312 | 313 | Ok userInfo -> 314 | ( { model | flow = Done userInfo } 315 | , Cmd.none 316 | ) 317 | 318 | 319 | signOutRequested : Model -> ( Model, Cmd Msg ) 320 | signOutRequested model = 321 | ( { model | flow = Idle } 322 | , Navigation.load (Url.toString model.redirectUri) 323 | ) 324 | 325 | 326 | 327 | -- 328 | -- View 329 | -- 330 | 331 | 332 | type alias ViewConfiguration msg = 333 | { title : String 334 | , btnClass : Attribute msg 335 | } 336 | 337 | 338 | view : ViewConfiguration Msg -> Model -> Document Msg 339 | view ({ title } as config) model = 340 | { title = title 341 | , body = viewBody config model 342 | } 343 | 344 | 345 | viewBody : ViewConfiguration Msg -> Model -> List (Html Msg) 346 | viewBody config model = 347 | [ div [ class "flex", class "flex-column", class "flex-space-around" ] <| 348 | case model.flow of 349 | Idle -> 350 | div [ class "flex" ] 351 | [ viewAuthorizationStep False 352 | , viewStepSeparator False 353 | , viewGetUserInfoStep False 354 | ] 355 | :: viewIdle config 356 | 357 | Authorized _ -> 358 | div [ class "flex" ] 359 | [ viewAuthorizationStep True 360 | , viewStepSeparator True 361 | , viewGetUserInfoStep False 362 | ] 363 | :: viewAuthorized 364 | 365 | Done userInfo -> 366 | div [ class "flex" ] 367 | [ viewAuthorizationStep True 368 | , viewStepSeparator True 369 | , viewGetUserInfoStep True 370 | ] 371 | :: viewUserInfo config userInfo 372 | 373 | Errored err -> 374 | div [ class "flex" ] 375 | [ viewErroredStep 376 | ] 377 | :: viewErrored err 378 | ] 379 | 380 | 381 | viewIdle : ViewConfiguration Msg -> List (Html Msg) 382 | viewIdle { btnClass } = 383 | [ button 384 | [ onClick SignInRequested, btnClass ] 385 | [ text "Sign in" ] 386 | ] 387 | 388 | 389 | viewAuthorized : List (Html Msg) 390 | viewAuthorized = 391 | [ span [] [ text "Getting user info..." ] 392 | ] 393 | 394 | 395 | viewUserInfo : ViewConfiguration Msg -> UserInfo -> List (Html Msg) 396 | viewUserInfo { btnClass } { name, picture } = 397 | [ div [ class "flex", class "flex-column" ] 398 | [ img [ class "avatar", src picture ] [] 399 | , p [] [ text name ] 400 | , div [] 401 | [ button 402 | [ onClick SignOutRequested, btnClass ] 403 | [ text "Sign out" ] 404 | ] 405 | ] 406 | ] 407 | 408 | 409 | viewErrored : Error -> List (Html Msg) 410 | viewErrored error = 411 | [ span [ class "span-error" ] [ viewError error ] ] 412 | 413 | 414 | viewError : Error -> Html Msg 415 | viewError e = 416 | text <| 417 | case e of 418 | ErrStateMismatch -> 419 | "'state' doesn't match, the request has likely been forged by an adversary!" 420 | 421 | ErrAuthorization error -> 422 | oauthErrorToString { error = error.error, errorDescription = error.errorDescription } 423 | 424 | ErrHTTPGetUserInfo -> 425 | "Unable to retrieve user info: HTTP request failed." 426 | 427 | 428 | viewAuthorizationStep : Bool -> Html Msg 429 | viewAuthorizationStep isActive = 430 | viewStep isActive ( "Authorization", style "left" "-110%" ) 431 | 432 | 433 | viewGetUserInfoStep : Bool -> Html Msg 434 | viewGetUserInfoStep isActive = 435 | viewStep isActive ( "Get User Info", style "left" "-135%" ) 436 | 437 | 438 | viewErroredStep : Html Msg 439 | viewErroredStep = 440 | div 441 | [ class "step", class "step-errored" ] 442 | [ span [ style "left" "-50%" ] [ text "Errored" ] ] 443 | 444 | 445 | viewStep : Bool -> ( String, Attribute Msg ) -> Html Msg 446 | viewStep isActive ( step, position ) = 447 | let 448 | stepClass = 449 | class "step" 450 | :: (if isActive then 451 | [ class "step-active" ] 452 | 453 | else 454 | [] 455 | ) 456 | in 457 | div stepClass [ span [ position ] [ text step ] ] 458 | 459 | 460 | viewStepSeparator : Bool -> Html Msg 461 | viewStepSeparator isActive = 462 | let 463 | stepClass = 464 | class "step-separator" 465 | :: (if isActive then 466 | [ class "step-active" ] 467 | 468 | else 469 | [] 470 | ) 471 | in 472 | span stepClass [] 473 | 474 | 475 | 476 | -- 477 | -- Helpers 478 | -- 479 | 480 | 481 | toBytes : List Int -> Bytes 482 | toBytes = 483 | List.map Bytes.unsignedInt8 >> Bytes.sequence >> Bytes.encode 484 | 485 | 486 | base64 : Bytes -> String 487 | base64 = 488 | Base64.bytes >> Base64.encode 489 | 490 | 491 | convertBytes : List Int -> { state : String } 492 | convertBytes = 493 | toBytes >> base64 >> (\state -> { state = state }) 494 | 495 | 496 | oauthErrorToString : { error : OAuth.ErrorCode, errorDescription : Maybe String } -> String 497 | oauthErrorToString { error, errorDescription } = 498 | let 499 | desc = 500 | errorDescription |> Maybe.withDefault "" |> String.replace "+" " " 501 | in 502 | OAuth.errorCodeToString error ++ ": " ++ desc 503 | 504 | 505 | defaultHttpsUrl : Url 506 | defaultHttpsUrl = 507 | { protocol = Https 508 | , host = "" 509 | , path = "" 510 | , port_ = Nothing 511 | , query = Nothing 512 | , fragment = Nothing 513 | } 514 | -------------------------------------------------------------------------------- /examples/providers/google/implicit/README.md: -------------------------------------------------------------------------------- 1 | # Auth0 2 | 3 |

4 | 5 |

6 | -------------------------------------------------------------------------------- /examples/providers/google/implicit/elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src", 5 | "." 6 | ], 7 | "elm-version": "0.19.1", 8 | "dependencies": { 9 | "direct": { 10 | "andrewMacmurray/elm-delay": "3.0.0", 11 | "elm/browser": "1.0.2", 12 | "elm/bytes": "1.0.8", 13 | "elm/core": "1.0.4", 14 | "elm/html": "1.0.0", 15 | "elm/http": "2.0.0", 16 | "elm/json": "1.1.3", 17 | "elm/url": "1.0.0", 18 | "folkertdev/elm-sha2": "1.0.0", 19 | "chelovek0v/bbase64": "1.0.1" 20 | }, 21 | "indirect": { 22 | "danfishgold/base64-bytes": "1.0.3", 23 | "elm/file": "1.0.5", 24 | "elm/regex": "1.0.0", 25 | "elm/time": "1.0.0", 26 | "elm/virtual-dom": "1.0.2", 27 | "rtfeldman/elm-hex": "1.0.0" 28 | } 29 | }, 30 | "test-dependencies": { 31 | "direct": {}, 32 | "indirect": {} 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /examples/providers/google/implicit/src: -------------------------------------------------------------------------------- 1 | ../../../../src/ -------------------------------------------------------------------------------- /examples/providers/spotify/implicit/Main.elm: -------------------------------------------------------------------------------- 1 | port module Main exposing (main) 2 | 3 | import Base64.Encode as Base64 4 | import Browser exposing (Document, application) 5 | import Browser.Navigation as Navigation exposing (Key) 6 | import Bytes exposing (Bytes) 7 | import Bytes.Encode as Bytes 8 | import Delay exposing (TimeUnit(..), after) 9 | import Html exposing (..) 10 | import Html.Attributes exposing (..) 11 | import Html.Events exposing (..) 12 | import Http 13 | import Json.Decode as Json 14 | import OAuth 15 | import OAuth.Implicit as OAuth 16 | import Url exposing (Protocol(..), Url) 17 | 18 | 19 | main : Program (Maybe (List Int)) Model Msg 20 | main = 21 | application 22 | { init = 23 | Maybe.map convertBytes >> init 24 | , update = 25 | update 26 | , subscriptions = 27 | always <| randomBytes GotRandomBytes 28 | , onUrlRequest = 29 | always NoOp 30 | , onUrlChange = 31 | always NoOp 32 | , view = 33 | view 34 | { title = "Spotify - Flow: Implicit" 35 | , btnClass = class "btn-spotify" 36 | } 37 | } 38 | 39 | 40 | {-| OAuth configuration. 41 | 42 | Note that this demo also fetches basic user information with the obtained access token, 43 | hence the user info endpoint and JSON decoder 44 | 45 | -} 46 | configuration : Configuration 47 | configuration = 48 | { authorizationEndpoint = 49 | { defaultHttpsUrl | host = "accounts.spotify.com", path = "/authorize" } 50 | , userInfoEndpoint = 51 | { defaultHttpsUrl | host = "api.spotify.com", path = "/v1/me" } 52 | , userInfoDecoder = 53 | Json.map2 UserInfo 54 | (Json.field "display_name" Json.string) 55 | (Json.field "images" <| Json.index 0 <| Json.field "url" Json.string) 56 | , clientId = 57 | "391d08ef3d7a46558493cb822a991dbb" 58 | , scope = 59 | [] 60 | } 61 | 62 | 63 | 64 | -- 65 | -- Model 66 | -- 67 | 68 | 69 | type alias Model = 70 | { redirectUri : Url 71 | , flow : Flow 72 | } 73 | 74 | 75 | {-| This demo evolves around the following state-machine\* 76 | 77 | +--------+ 78 | | Idle | 79 | +--------+ 80 | | 81 | | Redirect user for authorization 82 | | 83 | v 84 | +--------------+ 85 | | Authorized | w/ Access Token 86 | +--------------+ 87 | | 88 | | Fetch user info using the access token 89 | v 90 | +--------+ 91 | | Done | 92 | +--------+ 93 | 94 | (\*) The 'Errored' state hasn't been represented here for simplicity. 95 | 96 | -} 97 | type Flow 98 | = Idle 99 | | Authorized OAuth.Token 100 | | Done UserInfo 101 | | Errored Error 102 | 103 | 104 | type Error 105 | = ErrStateMismatch 106 | | ErrAuthorization OAuth.AuthorizationError 107 | | ErrHTTPGetUserInfo 108 | 109 | 110 | type alias UserInfo = 111 | { name : String 112 | , picture : String 113 | } 114 | 115 | 116 | type alias Configuration = 117 | { authorizationEndpoint : Url 118 | , userInfoEndpoint : Url 119 | , userInfoDecoder : Json.Decoder UserInfo 120 | , clientId : String 121 | , scope : List String 122 | } 123 | 124 | 125 | {-| During the authentication flow, we'll run twice into the `init` function: 126 | 127 | - The first time, for the application very first run. And we proceed with the `Idle` state, 128 | waiting for the user (a.k.a you) to request a sign in. 129 | 130 | - The second time, after a sign in has been requested, the user is redirected to the 131 | authorization server and redirects the user back to our application, with an access 132 | token and other fields as query parameters. 133 | 134 | When query params are present (and valid), we consider the user `Authorized`. 135 | 136 | -} 137 | init : Maybe { state : String } -> Url -> Key -> ( Model, Cmd Msg ) 138 | init mflags origin navigationKey = 139 | let 140 | redirectUri = 141 | { origin | query = Nothing, fragment = Nothing } 142 | 143 | clearUrl = 144 | Navigation.replaceUrl navigationKey (Url.toString redirectUri) 145 | in 146 | case OAuth.parseToken origin of 147 | OAuth.Empty -> 148 | ( { flow = Idle, redirectUri = redirectUri } 149 | , Cmd.none 150 | ) 151 | 152 | -- It is important to set a `state` when making the authorization request 153 | -- and to verify it after the redirection. The state can be anything but its primary 154 | -- usage is to prevent cross-site request forgery; at minima, it should be a short, 155 | -- non-guessable string, generated on the fly. 156 | -- 157 | -- We remember any previously generated state state using the browser's local storage 158 | -- and give it back (if present) to the elm application upon start 159 | OAuth.Success { token, state } -> 160 | case mflags of 161 | Nothing -> 162 | ( { flow = Errored ErrStateMismatch, redirectUri = redirectUri } 163 | , clearUrl 164 | ) 165 | 166 | Just flags -> 167 | if state /= Just flags.state then 168 | ( { flow = Errored ErrStateMismatch, redirectUri = redirectUri } 169 | , clearUrl 170 | ) 171 | 172 | else 173 | ( { flow = Authorized token, redirectUri = redirectUri } 174 | , Cmd.batch 175 | -- Artificial delay to make the live demo easier to follow. 176 | -- In practice, the access token could be requested right here. 177 | [ after 750 Millisecond UserInfoRequested 178 | , clearUrl 179 | ] 180 | ) 181 | 182 | OAuth.Error error -> 183 | ( { flow = Errored <| ErrAuthorization error, redirectUri = redirectUri } 184 | , clearUrl 185 | ) 186 | 187 | 188 | 189 | -- 190 | -- Msg 191 | -- 192 | 193 | 194 | type Msg 195 | = NoOp 196 | | SignInRequested 197 | | GotRandomBytes (List Int) 198 | | GotAccessToken (Result Http.Error OAuth.AuthorizationSuccess) 199 | | UserInfoRequested 200 | | GotUserInfo (Result Http.Error UserInfo) 201 | | SignOutRequested 202 | 203 | 204 | getUserInfo : Configuration -> OAuth.Token -> Cmd Msg 205 | getUserInfo { userInfoDecoder, userInfoEndpoint } token = 206 | Http.request 207 | { method = "GET" 208 | , body = Http.emptyBody 209 | , headers = OAuth.useToken token [] 210 | , url = Url.toString userInfoEndpoint 211 | , expect = Http.expectJson GotUserInfo userInfoDecoder 212 | , timeout = Nothing 213 | , tracker = Nothing 214 | } 215 | 216 | 217 | 218 | {- On the JavaScript's side, we have: 219 | 220 | app.ports.genRandomBytes.subscribe(n => { 221 | const buffer = new Uint8Array(n); 222 | crypto.getRandomValues(buffer); 223 | const bytes = Array.from(buffer); 224 | localStorage.setItem("bytes", bytes); 225 | app.ports.randomBytes.send(bytes); 226 | }); 227 | -} 228 | 229 | 230 | port genRandomBytes : Int -> Cmd msg 231 | 232 | 233 | port randomBytes : (List Int -> msg) -> Sub msg 234 | 235 | 236 | 237 | -- 238 | -- Update 239 | -- 240 | 241 | 242 | update : Msg -> Model -> ( Model, Cmd Msg ) 243 | update msg model = 244 | case ( model.flow, msg ) of 245 | ( Idle, SignInRequested ) -> 246 | signInRequested model 247 | 248 | ( Idle, GotRandomBytes bytes ) -> 249 | gotRandomBytes model bytes 250 | 251 | ( Authorized token, UserInfoRequested ) -> 252 | userInfoRequested model token 253 | 254 | ( Authorized _, GotUserInfo userInfoResponse ) -> 255 | gotUserInfo model userInfoResponse 256 | 257 | ( Done _, SignOutRequested ) -> 258 | signOutRequested model 259 | 260 | _ -> 261 | noOp model 262 | 263 | 264 | noOp : Model -> ( Model, Cmd Msg ) 265 | noOp model = 266 | ( model, Cmd.none ) 267 | 268 | 269 | signInRequested : Model -> ( Model, Cmd Msg ) 270 | signInRequested model = 271 | ( { model | flow = Idle } 272 | , genRandomBytes 16 273 | ) 274 | 275 | 276 | gotRandomBytes : Model -> List Int -> ( Model, Cmd Msg ) 277 | gotRandomBytes model bytes = 278 | let 279 | { state } = 280 | convertBytes bytes 281 | 282 | authorization = 283 | { clientId = configuration.clientId 284 | , redirectUri = model.redirectUri 285 | , scope = configuration.scope 286 | , state = Just state 287 | , url = configuration.authorizationEndpoint 288 | } 289 | in 290 | ( { model | flow = Idle } 291 | , authorization 292 | |> OAuth.makeAuthorizationUrl 293 | |> Url.toString 294 | |> Navigation.load 295 | ) 296 | 297 | 298 | userInfoRequested : Model -> OAuth.Token -> ( Model, Cmd Msg ) 299 | userInfoRequested model token = 300 | ( { model | flow = Authorized token } 301 | , getUserInfo configuration token 302 | ) 303 | 304 | 305 | gotUserInfo : Model -> Result Http.Error UserInfo -> ( Model, Cmd Msg ) 306 | gotUserInfo model userInfoResponse = 307 | case userInfoResponse of 308 | Err _ -> 309 | ( { model | flow = Errored ErrHTTPGetUserInfo } 310 | , Cmd.none 311 | ) 312 | 313 | Ok userInfo -> 314 | ( { model | flow = Done userInfo } 315 | , Cmd.none 316 | ) 317 | 318 | 319 | signOutRequested : Model -> ( Model, Cmd Msg ) 320 | signOutRequested model = 321 | ( { model | flow = Idle } 322 | , Navigation.load (Url.toString model.redirectUri) 323 | ) 324 | 325 | 326 | 327 | -- 328 | -- View 329 | -- 330 | 331 | 332 | type alias ViewConfiguration msg = 333 | { title : String 334 | , btnClass : Attribute msg 335 | } 336 | 337 | 338 | view : ViewConfiguration Msg -> Model -> Document Msg 339 | view ({ title } as config) model = 340 | { title = title 341 | , body = viewBody config model 342 | } 343 | 344 | 345 | viewBody : ViewConfiguration Msg -> Model -> List (Html Msg) 346 | viewBody config model = 347 | [ div [ class "flex", class "flex-column", class "flex-space-around" ] <| 348 | case model.flow of 349 | Idle -> 350 | div [ class "flex" ] 351 | [ viewAuthorizationStep False 352 | , viewStepSeparator False 353 | , viewGetUserInfoStep False 354 | ] 355 | :: viewIdle config 356 | 357 | Authorized _ -> 358 | div [ class "flex" ] 359 | [ viewAuthorizationStep True 360 | , viewStepSeparator True 361 | , viewGetUserInfoStep False 362 | ] 363 | :: viewAuthorized 364 | 365 | Done userInfo -> 366 | div [ class "flex" ] 367 | [ viewAuthorizationStep True 368 | , viewStepSeparator True 369 | , viewGetUserInfoStep True 370 | ] 371 | :: viewUserInfo config userInfo 372 | 373 | Errored err -> 374 | div [ class "flex" ] 375 | [ viewErroredStep 376 | ] 377 | :: viewErrored err 378 | ] 379 | 380 | 381 | viewIdle : ViewConfiguration Msg -> List (Html Msg) 382 | viewIdle { btnClass } = 383 | [ button 384 | [ onClick SignInRequested, btnClass ] 385 | [ text "Sign in" ] 386 | ] 387 | 388 | 389 | viewAuthorized : List (Html Msg) 390 | viewAuthorized = 391 | [ span [] [ text "Getting user info..." ] 392 | ] 393 | 394 | 395 | viewUserInfo : ViewConfiguration Msg -> UserInfo -> List (Html Msg) 396 | viewUserInfo { btnClass } { name, picture } = 397 | [ div [ class "flex", class "flex-column" ] 398 | [ img [ class "avatar", src picture ] [] 399 | , p [] [ text name ] 400 | , div [] 401 | [ button 402 | [ onClick SignOutRequested, btnClass ] 403 | [ text "Sign out" ] 404 | ] 405 | ] 406 | ] 407 | 408 | 409 | viewErrored : Error -> List (Html Msg) 410 | viewErrored error = 411 | [ span [ class "span-error" ] [ viewError error ] ] 412 | 413 | 414 | viewError : Error -> Html Msg 415 | viewError e = 416 | text <| 417 | case e of 418 | ErrStateMismatch -> 419 | "'state' doesn't match, the request has likely been forged by an adversary!" 420 | 421 | ErrAuthorization error -> 422 | oauthErrorToString { error = error.error, errorDescription = error.errorDescription } 423 | 424 | ErrHTTPGetUserInfo -> 425 | "Unable to retrieve user info: HTTP request failed." 426 | 427 | 428 | viewAuthorizationStep : Bool -> Html Msg 429 | viewAuthorizationStep isActive = 430 | viewStep isActive ( "Authorization", style "left" "-110%" ) 431 | 432 | 433 | viewGetUserInfoStep : Bool -> Html Msg 434 | viewGetUserInfoStep isActive = 435 | viewStep isActive ( "Get User Info", style "left" "-135%" ) 436 | 437 | 438 | viewErroredStep : Html Msg 439 | viewErroredStep = 440 | div 441 | [ class "step", class "step-errored" ] 442 | [ span [ style "left" "-50%" ] [ text "Errored" ] ] 443 | 444 | 445 | viewStep : Bool -> ( String, Attribute Msg ) -> Html Msg 446 | viewStep isActive ( step, position ) = 447 | let 448 | stepClass = 449 | class "step" 450 | :: (if isActive then 451 | [ class "step-active" ] 452 | 453 | else 454 | [] 455 | ) 456 | in 457 | div stepClass [ span [ position ] [ text step ] ] 458 | 459 | 460 | viewStepSeparator : Bool -> Html Msg 461 | viewStepSeparator isActive = 462 | let 463 | stepClass = 464 | class "step-separator" 465 | :: (if isActive then 466 | [ class "step-active" ] 467 | 468 | else 469 | [] 470 | ) 471 | in 472 | span stepClass [] 473 | 474 | 475 | 476 | -- 477 | -- Helpers 478 | -- 479 | 480 | 481 | toBytes : List Int -> Bytes 482 | toBytes = 483 | List.map Bytes.unsignedInt8 >> Bytes.sequence >> Bytes.encode 484 | 485 | 486 | base64 : Bytes -> String 487 | base64 = 488 | Base64.bytes >> Base64.encode 489 | 490 | 491 | convertBytes : List Int -> { state : String } 492 | convertBytes = 493 | toBytes >> base64 >> (\state -> { state = state }) 494 | 495 | 496 | oauthErrorToString : { error : OAuth.ErrorCode, errorDescription : Maybe String } -> String 497 | oauthErrorToString { error, errorDescription } = 498 | let 499 | desc = 500 | errorDescription |> Maybe.withDefault "" |> String.replace "+" " " 501 | in 502 | OAuth.errorCodeToString error ++ ": " ++ desc 503 | 504 | 505 | defaultHttpsUrl : Url 506 | defaultHttpsUrl = 507 | { protocol = Https 508 | , host = "" 509 | , path = "" 510 | , port_ = Nothing 511 | , query = Nothing 512 | , fragment = Nothing 513 | } 514 | -------------------------------------------------------------------------------- /examples/providers/spotify/implicit/README.md: -------------------------------------------------------------------------------- 1 | # Auth0 2 | 3 |

4 | 5 |

6 | -------------------------------------------------------------------------------- /examples/providers/spotify/implicit/elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src", 5 | "." 6 | ], 7 | "elm-version": "0.19.1", 8 | "dependencies": { 9 | "direct": { 10 | "andrewMacmurray/elm-delay": "3.0.0", 11 | "elm/browser": "1.0.2", 12 | "elm/bytes": "1.0.8", 13 | "elm/core": "1.0.4", 14 | "elm/html": "1.0.0", 15 | "elm/http": "2.0.0", 16 | "elm/json": "1.1.3", 17 | "elm/url": "1.0.0", 18 | "folkertdev/elm-sha2": "1.0.0", 19 | "chelovek0v/bbase64": "1.0.1" 20 | }, 21 | "indirect": { 22 | "danfishgold/base64-bytes": "1.0.3", 23 | "elm/file": "1.0.5", 24 | "elm/regex": "1.0.0", 25 | "elm/time": "1.0.0", 26 | "elm/virtual-dom": "1.0.2", 27 | "rtfeldman/elm-hex": "1.0.0" 28 | } 29 | }, 30 | "test-dependencies": { 31 | "direct": {}, 32 | "indirect": {} 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /examples/providers/spotify/implicit/src: -------------------------------------------------------------------------------- 1 | ../../../../src/ -------------------------------------------------------------------------------- /src/Extra/Maybe.elm: -------------------------------------------------------------------------------- 1 | module Extra.Maybe exposing (andThen2) 2 | 3 | {-| Extra helpers for `Maybe` 4 | 5 | @docs andThen2 6 | 7 | -} 8 | 9 | 10 | andThen2 : (a -> b -> Maybe c) -> Maybe a -> Maybe b -> Maybe c 11 | andThen2 fn ma mb = 12 | Maybe.andThen identity (Maybe.map2 fn ma mb) 13 | -------------------------------------------------------------------------------- /src/Internal.elm: -------------------------------------------------------------------------------- 1 | module Internal exposing 2 | ( AuthenticationError 3 | , AuthenticationSuccess 4 | , Authorization 5 | , AuthorizationError 6 | , RequestParts 7 | , authenticationErrorDecoder 8 | , authenticationSuccessDecoder 9 | , authorizationErrorParser 10 | , decoderFromJust 11 | , decoderFromResult 12 | , errorDecoder 13 | , errorDescriptionDecoder 14 | , errorDescriptionParser 15 | , errorParser 16 | , errorUriDecoder 17 | , errorUriParser 18 | , expiresInDecoder 19 | , expiresInParser 20 | , extractTokenString 21 | , lenientScopeDecoder 22 | , makeAuthorizationUrl 23 | , makeHeaders 24 | , makeRedirectUri 25 | , makeRequest 26 | , parseUrlQuery 27 | , protocolToString 28 | , refreshTokenDecoder 29 | , scopeDecoder 30 | , scopeParser 31 | , spaceSeparatedListParser 32 | , stateParser 33 | , tokenDecoder 34 | , tokenParser 35 | , urlAddExtraFields 36 | , urlAddList 37 | , urlAddMaybe 38 | ) 39 | 40 | import Base64.Encode as Base64 41 | import Dict as Dict exposing (Dict) 42 | import Http as Http 43 | import Json.Decode as Json 44 | import OAuth exposing (..) 45 | import Url exposing (Protocol(..), Url) 46 | import Url.Builder as Builder exposing (QueryParameter) 47 | import Url.Parser as Url 48 | import Url.Parser.Query as Query 49 | 50 | 51 | 52 | -- 53 | -- Json Decoders 54 | -- 55 | 56 | 57 | {-| Json decoder for a response. You may provide a custom response decoder using other decoders 58 | from this module, or some of your own craft. 59 | -} 60 | authenticationSuccessDecoder : Json.Decoder AuthenticationSuccess 61 | authenticationSuccessDecoder = 62 | Json.map4 AuthenticationSuccess 63 | tokenDecoder 64 | refreshTokenDecoder 65 | expiresInDecoder 66 | scopeDecoder 67 | 68 | 69 | authenticationErrorDecoder : Json.Decoder e -> Json.Decoder (AuthenticationError e) 70 | authenticationErrorDecoder errorCodeDecoder = 71 | Json.map3 AuthenticationError 72 | errorCodeDecoder 73 | errorDescriptionDecoder 74 | errorUriDecoder 75 | 76 | 77 | {-| Json decoder for an expire timestamp 78 | -} 79 | expiresInDecoder : Json.Decoder (Maybe Int) 80 | expiresInDecoder = 81 | Json.maybe <| Json.field "expires_in" Json.int 82 | 83 | 84 | {-| Json decoder for a scope 85 | -} 86 | scopeDecoder : Json.Decoder (List String) 87 | scopeDecoder = 88 | Json.map (Maybe.withDefault []) <| Json.maybe <| Json.field "scope" (Json.list Json.string) 89 | 90 | 91 | {-| Json decoder for a scope, allowing comma- or space-separated scopes 92 | -} 93 | lenientScopeDecoder : Json.Decoder (List String) 94 | lenientScopeDecoder = 95 | Json.map (Maybe.withDefault []) <| 96 | Json.maybe <| 97 | Json.field "scope" <| 98 | Json.oneOf 99 | [ Json.list Json.string 100 | , Json.map (String.split ",") Json.string 101 | ] 102 | 103 | 104 | {-| Json decoder for an access token 105 | -} 106 | tokenDecoder : Json.Decoder Token 107 | tokenDecoder = 108 | Json.andThen (decoderFromJust "missing or invalid 'access_token' / 'token_type'") <| 109 | Json.map2 makeToken 110 | (Json.field "token_type" Json.string |> Json.map Just) 111 | (Json.field "access_token" Json.string |> Json.map Just) 112 | 113 | 114 | {-| Json decoder for a refresh token 115 | -} 116 | refreshTokenDecoder : Json.Decoder (Maybe Token) 117 | refreshTokenDecoder = 118 | Json.andThen (decoderFromJust "missing or invalid 'refresh_token' / 'token_type'") <| 119 | Json.map2 makeRefreshToken 120 | (Json.field "token_type" Json.string) 121 | (Json.field "refresh_token" Json.string |> Json.maybe) 122 | 123 | 124 | {-| Json decoder for 'error' field 125 | -} 126 | errorDecoder : (String -> a) -> Json.Decoder a 127 | errorDecoder errorCodeFromString = 128 | Json.map errorCodeFromString <| Json.field "error" Json.string 129 | 130 | 131 | {-| Json decoder for 'error\_description' field 132 | -} 133 | errorDescriptionDecoder : Json.Decoder (Maybe String) 134 | errorDescriptionDecoder = 135 | Json.maybe <| Json.field "error_description" Json.string 136 | 137 | 138 | {-| Json decoder for 'error\_uri' field 139 | -} 140 | errorUriDecoder : Json.Decoder (Maybe String) 141 | errorUriDecoder = 142 | Json.maybe <| Json.field "error_uri" Json.string 143 | 144 | 145 | {-| Combinator for JSON decoders to extract values from a `Maybe` or fail 146 | with the given message (when `Nothing` is encountered) 147 | -} 148 | decoderFromJust : String -> Maybe a -> Json.Decoder a 149 | decoderFromJust msg = 150 | Maybe.map Json.succeed >> Maybe.withDefault (Json.fail msg) 151 | 152 | 153 | {-| Combinator for JSON decoders to extact values from a `Result _ _` or fail 154 | with an appropriate message 155 | -} 156 | decoderFromResult : Result String a -> Json.Decoder a 157 | decoderFromResult res = 158 | case res of 159 | Err msg -> 160 | Json.fail msg 161 | 162 | Ok a -> 163 | Json.succeed a 164 | 165 | 166 | 167 | -- 168 | -- Query Parsers 169 | -- 170 | 171 | 172 | authorizationErrorParser : e -> Query.Parser (AuthorizationError e) 173 | authorizationErrorParser errorCode = 174 | Query.map3 (AuthorizationError errorCode) 175 | errorDescriptionParser 176 | errorUriParser 177 | stateParser 178 | 179 | 180 | tokenParser : Query.Parser (Maybe Token) 181 | tokenParser = 182 | Query.map2 makeToken 183 | (Query.string "token_type") 184 | (Query.string "access_token") 185 | 186 | 187 | errorParser : (String -> e) -> Query.Parser (Maybe e) 188 | errorParser errorCodeFromString = 189 | Query.map (Maybe.map errorCodeFromString) 190 | (Query.string "error") 191 | 192 | 193 | expiresInParser : Query.Parser (Maybe Int) 194 | expiresInParser = 195 | Query.int "expires_in" 196 | 197 | 198 | scopeParser : Query.Parser (List String) 199 | scopeParser = 200 | spaceSeparatedListParser "scope" 201 | 202 | 203 | stateParser : Query.Parser (Maybe String) 204 | stateParser = 205 | Query.string "state" 206 | 207 | 208 | errorDescriptionParser : Query.Parser (Maybe String) 209 | errorDescriptionParser = 210 | Query.string "error_description" 211 | 212 | 213 | errorUriParser : Query.Parser (Maybe String) 214 | errorUriParser = 215 | Query.string "error_uri" 216 | 217 | 218 | spaceSeparatedListParser : String -> Query.Parser (List String) 219 | spaceSeparatedListParser param = 220 | Query.map 221 | (\s -> 222 | case s of 223 | Nothing -> 224 | [] 225 | 226 | Just str -> 227 | String.split " " str 228 | ) 229 | (Query.string param) 230 | 231 | 232 | urlAddList : String -> List String -> List QueryParameter -> List QueryParameter 233 | urlAddList param xs qs = 234 | qs 235 | ++ (case xs of 236 | [] -> 237 | [] 238 | 239 | _ -> 240 | [ Builder.string param (String.join " " xs) ] 241 | ) 242 | 243 | 244 | urlAddMaybe : String -> Maybe String -> List QueryParameter -> List QueryParameter 245 | urlAddMaybe param ms qs = 246 | qs 247 | ++ (case ms of 248 | Nothing -> 249 | [] 250 | 251 | Just s -> 252 | [ Builder.string param s ] 253 | ) 254 | 255 | 256 | urlAddExtraFields : Dict String String -> List QueryParameter -> List QueryParameter 257 | urlAddExtraFields extraFields zero = 258 | Dict.foldr (\k v qs -> Builder.string k v :: qs) zero extraFields 259 | 260 | 261 | 262 | -- 263 | -- Smart Constructors 264 | -- 265 | 266 | 267 | makeAuthorizationUrl : ResponseType -> Dict String String -> Authorization -> Url 268 | makeAuthorizationUrl responseType extraFields { clientId, url, redirectUri, scope, state } = 269 | let 270 | query = 271 | [ Builder.string "client_id" clientId 272 | , Builder.string "redirect_uri" (makeRedirectUri redirectUri) 273 | , Builder.string "response_type" (responseTypeToString responseType) 274 | ] 275 | |> urlAddList "scope" scope 276 | |> urlAddMaybe "state" state 277 | |> urlAddExtraFields extraFields 278 | |> Builder.toQuery 279 | |> String.dropLeft 1 280 | in 281 | case url.query of 282 | Nothing -> 283 | { url | query = Just query } 284 | 285 | Just baseQuery -> 286 | { url | query = Just (baseQuery ++ "&" ++ query) } 287 | 288 | 289 | makeRequest : Json.Decoder success -> (Result Http.Error success -> msg) -> Url -> List Http.Header -> String -> RequestParts msg 290 | makeRequest decoder toMsg url headers body = 291 | { method = "POST" 292 | , headers = headers 293 | , url = Url.toString url 294 | , body = Http.stringBody "application/x-www-form-urlencoded" body 295 | , expect = Http.expectJson toMsg decoder 296 | , timeout = Nothing 297 | , tracker = Nothing 298 | } 299 | 300 | 301 | makeHeaders : Maybe { clientId : String, secret : String } -> List Http.Header 302 | makeHeaders credentials = 303 | credentials 304 | |> Maybe.map (\{ clientId, secret } -> Base64.encode <| Base64.string <| (clientId ++ ":" ++ secret)) 305 | |> Maybe.map (\s -> [ Http.header "Authorization" ("Basic " ++ s) ]) 306 | |> Maybe.withDefault [] 307 | 308 | 309 | makeRedirectUri : Url -> String 310 | makeRedirectUri url = 311 | String.concat 312 | [ protocolToString url.protocol 313 | , "://" 314 | , url.host 315 | , Maybe.withDefault "" (Maybe.map (\i -> ":" ++ String.fromInt i) url.port_) 316 | , url.path 317 | , Maybe.withDefault "" (Maybe.map (\q -> "?" ++ q) url.query) 318 | ] 319 | 320 | 321 | 322 | -- 323 | -- String utilities 324 | -- 325 | 326 | 327 | {-| Gets the `String` representation of an `Protocol` 328 | -} 329 | protocolToString : Protocol -> String 330 | protocolToString protocol = 331 | case protocol of 332 | Http -> 333 | "http" 334 | 335 | Https -> 336 | "https" 337 | 338 | 339 | 340 | -- 341 | -- Utils 342 | -- 343 | 344 | 345 | parseUrlQuery : Url -> a -> Query.Parser a -> a 346 | parseUrlQuery url def parser = 347 | Maybe.withDefault def <| Url.parse (Url.query parser) url 348 | 349 | 350 | {-| Extracts the intrinsic value of a `Token`. Careful with this, we don't have 351 | access to the `Token` constructors, so it's a bit Houwje-Touwje 352 | -} 353 | extractTokenString : Token -> String 354 | extractTokenString = 355 | tokenToString >> String.dropLeft 7 356 | 357 | 358 | 359 | -- 360 | -- Record Alias Re-Definition 361 | -- 362 | 363 | 364 | type alias RequestParts a = 365 | { method : String 366 | , headers : List Http.Header 367 | , url : String 368 | , body : Http.Body 369 | , expect : Http.Expect a 370 | , timeout : Maybe Float 371 | , tracker : Maybe String 372 | } 373 | 374 | 375 | type alias Authorization = 376 | { clientId : String 377 | , url : Url 378 | , redirectUri : Url 379 | , scope : List String 380 | , state : Maybe String 381 | } 382 | 383 | 384 | type alias AuthorizationError e = 385 | { error : e 386 | , errorDescription : Maybe String 387 | , errorUri : Maybe String 388 | , state : Maybe String 389 | } 390 | 391 | 392 | type alias AuthenticationSuccess = 393 | { token : Token 394 | , refreshToken : Maybe Token 395 | , expiresIn : Maybe Int 396 | , scope : List String 397 | } 398 | 399 | 400 | type alias AuthenticationError e = 401 | { error : e 402 | , errorDescription : Maybe String 403 | , errorUri : Maybe String 404 | } 405 | -------------------------------------------------------------------------------- /src/OAuth.elm: -------------------------------------------------------------------------------- 1 | module OAuth exposing 2 | ( Token, useToken, tokenToString, tokenFromString 3 | , ErrorCode(..), errorCodeToString, errorCodeFromString 4 | , ResponseType(..), responseTypeToString, GrantType(..), grantTypeToString 5 | , TokenType, TokenString, makeToken, makeRefreshToken 6 | ) 7 | 8 | {-| Utility library to manage client-side OAuth 2.0 authentications 9 | 10 | The library contains a main OAuth module exposing types used accross other modules. In practice, 11 | you'll only need to use one of the additional modules: 12 | 13 | - OAuth.AuthorizationCode: The authorization code grant type is used to obtain both access tokens 14 | and refresh tokens via a redirection-based flow and is optimized for confidential clients 15 | [4.1](https://tools.ietf.org/html/rfc6749#section-4.1). 16 | 17 | - OAuth.AuthorizationCode.PKCE: An extension of the original OAuth 2.0 specification to mitigate 18 | authorization code interception attacks through the use of Proof Key for Code Exchange (PKCE). 19 | 20 | - OAuth.Implicit: The implicit grant type is used to obtain access tokens (it does not support the 21 | issuance of refresh tokens) and is optimized for public clients known to operate a particular 22 | redirection URI [4.2](https://tools.ietf.org/html/rfc6749#section-4.2). 23 | 24 | - OAuth.Password: The resource owner password credentials grant type is suitable in cases where the 25 | resource owner has a trust relationship with the client, such as the device operating system or a 26 | highly privileged application [4.3](https://tools.ietf.org/html/rfc6749#section-4.3) 27 | 28 | - OAuth.ClientCredentials: The client can request an access token using only its client credentials 29 | (or other supported means of authentication) when the client is requesting access to the protected 30 | resources under its control, or those of another resource owner that have been previously arranged 31 | with the authorization server (the method of which is beyond the scope of this specification) 32 | [4.4](https://tools.ietf.org/html/rfc6749#section-4.3). 33 | 34 | In practice, you most probably want to use the 35 | [`OAuth.AuthorizationCode`](http://package.elm-lang.org/packages/truqu/elm-oauth2/latest/OAuth-AuthorizationCode). 36 | If your authorization server supports it, you should look at the PKCE extension in a second-time! 37 | 38 | which is the most commonly 39 | used. 40 | 41 | 42 | ## Token 43 | 44 | @docs Token, useToken, tokenToString, tokenFromString 45 | 46 | 47 | ## ErrorCode 48 | 49 | @docs ErrorCode, errorCodeToString, errorCodeFromString 50 | 51 | 52 | ## Response & Grant types (Advanced) 53 | 54 | The following section can be ignored if you're dealing with a very generic OAuth2.0 implementation. If however, your authorization server does implement some extra features on top of the OAuth2.0 protocol (e.g. OpenID Connect), you will require to tweak response parsers and possibly, response type to cope with these discrepancies. In short, unless you're planning on using `makeTokenRequestWith` or `makeAuthorizationUrlWith`, you most probably won't need any of the functions below. 55 | 56 | @docs ResponseType, responseTypeToString, GrantType, grantTypeToString 57 | 58 | 59 | ## Decoders & Parsers Utils (advanced) 60 | 61 | @docs TokenType, TokenString, makeToken, makeRefreshToken 62 | 63 | -} 64 | 65 | import Extra.Maybe as Maybe 66 | import Http as Http 67 | 68 | 69 | 70 | -- 71 | -- Token 72 | -- 73 | 74 | 75 | {-| Describes the type of access token to use. 76 | 77 | - Bearer: Utilized by simply including the access token string in the request 78 | [rfc6750](https://tools.ietf.org/html/rfc6750) 79 | 80 | - Mac: Not supported. 81 | 82 | -} 83 | type Token 84 | = Bearer String 85 | 86 | 87 | {-| Alias for readability 88 | -} 89 | type alias TokenType = 90 | String 91 | 92 | 93 | {-| Alias for readability 94 | -} 95 | type alias TokenString = 96 | String 97 | 98 | 99 | {-| Use a token to authenticate a request. 100 | -} 101 | useToken : Token -> List Http.Header -> List Http.Header 102 | useToken token = 103 | (::) (Http.header "Authorization" (tokenToString token)) 104 | 105 | 106 | {-| Create a token from two string representing a token type and 107 | an actual token value. This is intended to be used in Json decoders 108 | or Query parsers. 109 | 110 | Returns `Nothing` when the token type is `Nothing` 111 | , different from `Just "Bearer"` or when there's no token at all. 112 | 113 | -} 114 | makeToken : Maybe TokenType -> Maybe TokenString -> Maybe Token 115 | makeToken = 116 | Maybe.andThen2 tryMakeToken 117 | 118 | 119 | {-| See `makeToken`, with the subtle difference that a token value may or 120 | may not be there. 121 | 122 | Returns `Nothing` when the token type isn't `"Bearer"`. 123 | 124 | Returns `Just Nothing` or `Just (Just token)` otherwise, depending on whether a token is 125 | present or not. 126 | 127 | -} 128 | makeRefreshToken : TokenType -> Maybe TokenString -> Maybe (Maybe Token) 129 | makeRefreshToken tokenType mToken = 130 | case ( mToken, Maybe.andThen2 tryMakeToken (Just tokenType) mToken ) of 131 | ( Nothing, _ ) -> 132 | Just Nothing 133 | 134 | ( _, Just token ) -> 135 | Just <| Just token 136 | 137 | _ -> 138 | Nothing 139 | 140 | 141 | {-| Internal, attempt to make a Bearer token from a type and a token string 142 | -} 143 | tryMakeToken : TokenType -> TokenString -> Maybe Token 144 | tryMakeToken tokenType token = 145 | case String.toLower tokenType of 146 | "bearer" -> 147 | Just (Bearer token) 148 | 149 | _ -> 150 | Nothing 151 | 152 | 153 | {-| Get the `String` representation of a `Token` to be used in an 'Authorization' header 154 | -} 155 | tokenToString : Token -> String 156 | tokenToString (Bearer t) = 157 | "Bearer " ++ t 158 | 159 | 160 | {-| Parse a token from an 'Authorization' header string. 161 | 162 | tokenFromString (tokenToString token) == Just token 163 | 164 | -} 165 | tokenFromString : String -> Maybe Token 166 | tokenFromString str = 167 | case ( String.left 6 str, String.dropLeft 7 str ) of 168 | ( "Bearer", t ) -> 169 | Just (Bearer t) 170 | 171 | _ -> 172 | Nothing 173 | 174 | 175 | 176 | -- 177 | -- ResponseType / GrandType 178 | -- 179 | 180 | 181 | {-| Describes the desired type of response to an authorization. Use `Code` to ask for an 182 | authorization code and continue with the according flow. Use `Token` to do an implicit 183 | authentication and directly retrieve a `Token` from the authorization. If need be, you may provide a 184 | custom response type should the server returns a non-standard response type. 185 | -} 186 | type ResponseType 187 | = Code 188 | | Token 189 | | CustomResponse String 190 | 191 | 192 | {-| Gets the `String` representation of a `ResponseType`. 193 | -} 194 | responseTypeToString : ResponseType -> String 195 | responseTypeToString r = 196 | case r of 197 | Code -> 198 | "code" 199 | 200 | Token -> 201 | "token" 202 | 203 | CustomResponse str -> 204 | str 205 | 206 | 207 | {-| Describes the desired type of grant to an authentication. 208 | -} 209 | type GrantType 210 | = AuthorizationCode 211 | | Password 212 | | ClientCredentials 213 | | RefreshToken 214 | | CustomGrant String 215 | 216 | 217 | {-| Gets the `String` representation of a `GrantType` 218 | -} 219 | grantTypeToString : GrantType -> String 220 | grantTypeToString g = 221 | case g of 222 | AuthorizationCode -> 223 | "authorization_code" 224 | 225 | Password -> 226 | "password" 227 | 228 | ClientCredentials -> 229 | "client_credentials" 230 | 231 | RefreshToken -> 232 | "refresh_token" 233 | 234 | CustomGrant str -> 235 | str 236 | 237 | 238 | 239 | -- 240 | -- Error 241 | -- 242 | 243 | 244 | {-| Describes an OAuth error response [4.1.2.1](https://tools.ietf.org/html/rfc6749#section-4.1.2.1) 245 | 246 | - `InvalidRequest`: The request is missing a required parameter, includes an invalid parameter value, 247 | includes a parameter more than once, or is otherwise malformed. 248 | 249 | - `UnauthorizedClient`: The client is not authorized to request an authorization code using this 250 | method. 251 | 252 | - `AccessDenied`: The resource owner or authorization server denied the request. 253 | 254 | - `UnsupportedResponseType`: The authorization server does not support obtaining an authorization code 255 | using this method. 256 | 257 | - `InvalidScope`: The requested scope is invalid, unknown, or malformed. 258 | 259 | - `ServerError`: The authorization server encountered an unexpected condition that prevented it from 260 | fulfilling the request. (This error code is needed because a 500 Internal Server Error HTTP status 261 | code cannot be returned to the client via an HTTP redirect.) 262 | 263 | - `TemporarilyUnavailable`: The authorization server is currently unable to handle the request due to 264 | a temporary overloading or maintenance of the server. (This error code is needed because a 503 265 | Service Unavailable HTTP status code cannot be returned to the client via an HTTP redirect.) 266 | 267 | - `Custom`: Encountered a 'free-string' or custom code not specified by the official RFC but returned 268 | by the authorization server. 269 | 270 | -} 271 | type ErrorCode 272 | = InvalidRequest 273 | | UnauthorizedClient 274 | | AccessDenied 275 | | UnsupportedResponseType 276 | | InvalidScope 277 | | ServerError 278 | | TemporarilyUnavailable 279 | | Custom String 280 | 281 | 282 | {-| Get the `String` representation of an `ErrorCode`. 283 | -} 284 | errorCodeToString : ErrorCode -> String 285 | errorCodeToString err = 286 | case err of 287 | InvalidRequest -> 288 | "invalid_request" 289 | 290 | UnauthorizedClient -> 291 | "unauthorized_client" 292 | 293 | AccessDenied -> 294 | "access_denied" 295 | 296 | UnsupportedResponseType -> 297 | "unsupported_response_type" 298 | 299 | InvalidScope -> 300 | "invalid_scope" 301 | 302 | ServerError -> 303 | "server_error" 304 | 305 | TemporarilyUnavailable -> 306 | "temporarily_unavailable" 307 | 308 | Custom str -> 309 | str 310 | 311 | 312 | {-| Build a string back into an error code. Returns `Custom _` 313 | when the string isn't recognized from the ones specified in the RFC 314 | -} 315 | errorCodeFromString : String -> ErrorCode 316 | errorCodeFromString str = 317 | case str of 318 | "invalid_request" -> 319 | InvalidRequest 320 | 321 | "unauthorized_client" -> 322 | UnauthorizedClient 323 | 324 | "access_denied" -> 325 | AccessDenied 326 | 327 | "unsupported_response_type" -> 328 | UnsupportedResponseType 329 | 330 | "invalid_scope" -> 331 | InvalidScope 332 | 333 | "server_error" -> 334 | ServerError 335 | 336 | "temporarily_unavailable" -> 337 | TemporarilyUnavailable 338 | 339 | _ -> 340 | Custom str 341 | -------------------------------------------------------------------------------- /src/OAuth/ClientCredentials.elm: -------------------------------------------------------------------------------- 1 | module OAuth.ClientCredentials exposing 2 | ( makeTokenRequest, Authentication, Credentials, AuthenticationSuccess, AuthenticationError, RequestParts 3 | , defaultAuthenticationSuccessDecoder, defaultAuthenticationErrorDecoder 4 | , makeTokenRequestWith, defaultExpiresInDecoder, defaultScopeDecoder, lenientScopeDecoder, defaultTokenDecoder, defaultRefreshTokenDecoder, defaultErrorDecoder, defaultErrorDescriptionDecoder, defaultErrorUriDecoder 5 | ) 6 | 7 | {-| The client can request an access token using only its client 8 | credentials (or other supported means of authentication) when the client is requesting access to 9 | the protected resources under its control, or those of another resource owner that have been 10 | previously arranged with the authorization server (the method of which is beyond the scope of 11 | this specification). 12 | 13 | There's only one step in this process: 14 | 15 | - The client authenticates itself directly using credentials it owns. 16 | 17 | After this step, the client owns a `Token` that can be used to authorize any subsequent 18 | request. 19 | 20 | 21 | ## Authenticate 22 | 23 | @docs makeTokenRequest, Authentication, Credentials, AuthenticationSuccess, AuthenticationError, RequestParts 24 | 25 | 26 | ## JSON Decoders 27 | 28 | @docs defaultAuthenticationSuccessDecoder, defaultAuthenticationErrorDecoder 29 | 30 | 31 | ## Custom Decoders & Parsers (advanced) 32 | 33 | @docs makeTokenRequestWith, defaultExpiresInDecoder, defaultScopeDecoder, lenientScopeDecoder, defaultTokenDecoder, defaultRefreshTokenDecoder, defaultErrorDecoder, defaultErrorDescriptionDecoder, defaultErrorUriDecoder 34 | 35 | -} 36 | 37 | import Dict as Dict exposing (Dict) 38 | import Http 39 | import Internal as Internal exposing (..) 40 | import Json.Decode as Json 41 | import OAuth exposing (ErrorCode(..), GrantType(..), Token, errorCodeFromString, grantTypeToString) 42 | import Url exposing (Url) 43 | import Url.Builder as Builder 44 | 45 | 46 | 47 | -- 48 | -- Authenticate 49 | -- 50 | 51 | 52 | {-| Request configuration for a ClientCredentials authentication 53 | 54 | - `credentials` (_REQUIRED_): 55 | Credentials needed for Basic authentication. 56 | 57 | - `url` (_REQUIRED_): 58 | The token endpoint to contact the authorization server. 59 | 60 | - `scope` (_OPTIONAL_): 61 | The scope of the access request. 62 | 63 | -} 64 | type alias Authentication = 65 | { credentials : Credentials 66 | , url : Url 67 | , scope : List String 68 | } 69 | 70 | 71 | {-| Describes a couple of client credentials used for Basic authentication 72 | 73 | { clientId = "" 74 | , secret = "" 75 | } 76 | 77 | -} 78 | type alias Credentials = 79 | { clientId : String, secret : String } 80 | 81 | 82 | {-| The response obtained as a result of an authentication (implicit or not) 83 | 84 | - `token` (_REQUIRED_): 85 | The access token issued by the authorization server. 86 | 87 | - `refreshToken` (_OPTIONAL_): 88 | The refresh token, which can be used to obtain new access tokens using the same authorization 89 | grant as described in [Section 6](https://tools.ietf.org/html/rfc6749#section-6). 90 | 91 | - `expiresIn` (_RECOMMENDED_): 92 | The lifetime in seconds of the access token. For example, the value "3600" denotes that the 93 | access token will expire in one hour from the time the response was generated. If omitted, the 94 | authorization server SHOULD provide the expiration time via other means or document the default 95 | value. 96 | 97 | - `scope` (_OPTIONAL, if identical to the scope requested; otherwise, REQUIRED_): 98 | The scope of the access token as described by [Section 3.3](https://tools.ietf.org/html/rfc6749#section-3.3). 99 | 100 | -} 101 | type alias AuthenticationSuccess = 102 | { token : Token 103 | , refreshToken : Maybe Token 104 | , expiresIn : Maybe Int 105 | , scope : List String 106 | } 107 | 108 | 109 | {-| Describes an OAuth error as a result of a request failure 110 | 111 | - `error` (_REQUIRED_): 112 | A single ASCII error code. 113 | 114 | - `errorDescription` (_OPTIONAL_) 115 | Human-readable ASCII text providing additional information, used to assist the client developer 116 | in understanding the error that occurred. Values for the `errorDescription` parameter MUST NOT 117 | include characters outside the set `%x20-21 / %x23-5B / %x5D-7E`. 118 | 119 | - `errorUri` (_OPTIONAL_): 120 | A URI identifying a human-readable web page with information about the error, used to provide 121 | the client developer with additional information about the error. Values for the `errorUri` 122 | parameter MUST conform to the URI-reference syntax and thus MUST NOT include characters outside 123 | the set `%x21 / %x23-5B / %x5D-7E`. 124 | 125 | -} 126 | type alias AuthenticationError = 127 | { error : ErrorCode 128 | , errorDescription : Maybe String 129 | , errorUri : Maybe String 130 | } 131 | 132 | 133 | {-| Parts required to build a request. This record is given to [`Http.request`](https://package.elm-lang.org/packages/elm/http/latest/Http#request) 134 | in order to create a new request and may be adjusted at will. 135 | -} 136 | type alias RequestParts a = 137 | { method : String 138 | , headers : List Http.Header 139 | , url : String 140 | , body : Http.Body 141 | , expect : Http.Expect a 142 | , timeout : Maybe Float 143 | , tracker : Maybe String 144 | } 145 | 146 | 147 | {-| Builds a the request components required to get a token from client credentials 148 | 149 | let req : Http.Request TokenResponse 150 | req = makeTokenRequest toMsg authentication |> Http.request 151 | 152 | -} 153 | makeTokenRequest : (Result Http.Error AuthenticationSuccess -> msg) -> Authentication -> RequestParts msg 154 | makeTokenRequest = 155 | makeTokenRequestWith ClientCredentials defaultAuthenticationSuccessDecoder Dict.empty 156 | 157 | 158 | 159 | -- 160 | -- Json Decoders 161 | -- 162 | 163 | 164 | {-| Json decoder for a positive response. You may provide a custom response decoder using other decoders 165 | from this module, or some of your own craft. 166 | 167 | defaultAuthenticationSuccessDecoder : Decoder AuthenticationSuccess 168 | defaultAuthenticationSuccessDecoder = 169 | D.map4 AuthenticationSuccess 170 | tokenDecoder 171 | refreshTokenDecoder 172 | expiresInDecoder 173 | scopeDecoder 174 | 175 | -} 176 | defaultAuthenticationSuccessDecoder : Json.Decoder AuthenticationSuccess 177 | defaultAuthenticationSuccessDecoder = 178 | Internal.authenticationSuccessDecoder 179 | 180 | 181 | {-| Json decoder for an errored response. 182 | 183 | case res of 184 | Err (Http.BadStatus { body }) -> 185 | case Json.decodeString OAuth.ClientCredentials.defaultAuthenticationErrorDecoder body of 186 | Ok { error, errorDescription } -> 187 | doSomething 188 | 189 | _ -> 190 | parserFailed 191 | 192 | _ -> 193 | someOtherError 194 | 195 | -} 196 | defaultAuthenticationErrorDecoder : Json.Decoder AuthenticationError 197 | defaultAuthenticationErrorDecoder = 198 | Internal.authenticationErrorDecoder defaultErrorDecoder 199 | 200 | 201 | 202 | -- 203 | -- Custom Decoders & Parsers (advanced) 204 | -- 205 | 206 | 207 | {-| Like [`makeTokenRequest`](#makeTokenRequest), but gives you the ability to specify custom grant 208 | type and extra fields to be set on the query. 209 | 210 | makeTokenRequest : (Result Http.Error AuthenticationSuccess -> msg) -> Authentication -> RequestParts msg 211 | makeTokenRequest = 212 | makeTokenRequestWith ClientCredentials defaultAuthenticationSuccessDecoder Dict.empty 213 | 214 | -} 215 | makeTokenRequestWith : GrantType -> Json.Decoder success -> Dict String String -> (Result Http.Error success -> msg) -> Authentication -> RequestParts msg 216 | makeTokenRequestWith grantType decoder extraFields toMsg { credentials, scope, url } = 217 | let 218 | body = 219 | [ Builder.string "grant_type" (grantTypeToString grantType) ] 220 | |> urlAddList "scope" scope 221 | |> urlAddExtraFields extraFields 222 | |> Builder.toQuery 223 | |> String.dropLeft 1 224 | 225 | headers = 226 | makeHeaders <| 227 | Just 228 | { clientId = credentials.clientId 229 | , secret = credentials.secret 230 | } 231 | in 232 | makeRequest decoder toMsg url headers body 233 | 234 | 235 | {-| Json decoder for the `expiresIn` field. 236 | -} 237 | defaultExpiresInDecoder : Json.Decoder (Maybe Int) 238 | defaultExpiresInDecoder = 239 | Internal.expiresInDecoder 240 | 241 | 242 | {-| Json decoder for the `scope` field (space-separated). 243 | -} 244 | defaultScopeDecoder : Json.Decoder (List String) 245 | defaultScopeDecoder = 246 | Internal.scopeDecoder 247 | 248 | 249 | {-| Json decoder for the `scope` field (comma- or space-separated). 250 | -} 251 | lenientScopeDecoder : Json.Decoder (List String) 252 | lenientScopeDecoder = 253 | Internal.lenientScopeDecoder 254 | 255 | 256 | {-| Json decoder for the `access_token` field. 257 | -} 258 | defaultTokenDecoder : Json.Decoder Token 259 | defaultTokenDecoder = 260 | Internal.tokenDecoder 261 | 262 | 263 | {-| Json decoder for the `refresh_token` field. 264 | -} 265 | defaultRefreshTokenDecoder : Json.Decoder (Maybe Token) 266 | defaultRefreshTokenDecoder = 267 | Internal.refreshTokenDecoder 268 | 269 | 270 | {-| Json decoder for the `error` field. 271 | -} 272 | defaultErrorDecoder : Json.Decoder ErrorCode 273 | defaultErrorDecoder = 274 | Internal.errorDecoder errorCodeFromString 275 | 276 | 277 | {-| Json decoder for the `error_description` field. 278 | -} 279 | defaultErrorDescriptionDecoder : Json.Decoder (Maybe String) 280 | defaultErrorDescriptionDecoder = 281 | Internal.errorDescriptionDecoder 282 | 283 | 284 | {-| Json decoder for the `error_uri` field. 285 | -} 286 | defaultErrorUriDecoder : Json.Decoder (Maybe String) 287 | defaultErrorUriDecoder = 288 | Internal.errorUriDecoder 289 | -------------------------------------------------------------------------------- /src/OAuth/Implicit.elm: -------------------------------------------------------------------------------- 1 | module OAuth.Implicit exposing 2 | ( makeAuthorizationUrl, Authorization, parseToken, AuthorizationResult, AuthorizationResultWith(..), AuthorizationError, AuthorizationSuccess 3 | , makeAuthorizationUrlWith, parseTokenWith, Parsers, defaultParsers, defaultTokenParser, defaultErrorParser, defaultAuthorizationSuccessParser, defaultAuthorizationErrorParser 4 | ) 5 | 6 | {-| **⚠ (DEPRECATED) ⚠ You should probably look into [OAuth.AuthorizationCode](http://package.elm-lang.org/packages/truqu/elm-oauth2/latest/OAuth-AuthorizationCode) instead.** 7 | 8 | The implicit grant type is used to obtain access tokens (it does not 9 | support the issuance of refresh tokens) and is optimized for public clients known to operate a 10 | particular redirection URI. These clients are typically implemented in a browser using a 11 | scripting language such as JavaScript. 12 | 13 | 14 | ## Quick Start 15 | 16 | To get started, have a look at the [live-demo](https://truqu.github.io/elm-oauth2/auth0/implicit/) and its 17 | corresponding [source 18 | code](https://github.com/truqu/elm-oauth2/blob/master/examples/providers/auth0/implicit/Main.elm). 19 | 20 | 21 | ## Overview 22 | 23 | +---------+ +--------+ 24 | | |---(A)- Auth Redirection ------>| | 25 | | | | Auth | 26 | | Browser | | Server | 27 | | | | | 28 | | |<--(B)- Redirection Callback ---| | 29 | +---------+ w/ Access Token +--------+ 30 | ^ | 31 | | | 32 | (A) (B) 33 | | | 34 | | v 35 | +---------+ 36 | | | 37 | | Elm App | 38 | | | 39 | | | 40 | +---------+ 41 | 42 | After those steps, the client owns a `Token` that can be used to authorize any subsequent 43 | request. 44 | 45 | 46 | ## Authorize 47 | 48 | @docs makeAuthorizationUrl, Authorization, parseToken, AuthorizationResult, AuthorizationResultWith, AuthorizationError, AuthorizationSuccess 49 | 50 | 51 | ## Custom Parsers (advanced) 52 | 53 | @docs makeAuthorizationUrlWith, parseTokenWith, Parsers, defaultParsers, defaultTokenParser, defaultErrorParser, defaultAuthorizationSuccessParser, defaultAuthorizationErrorParser 54 | 55 | -} 56 | 57 | import Dict as Dict exposing (Dict) 58 | import Internal exposing (..) 59 | import OAuth exposing (ErrorCode(..), ResponseType(..), Token, errorCodeFromString) 60 | import Url exposing (Protocol(..), Url) 61 | import Url.Parser as Url exposing (()) 62 | import Url.Parser.Query as Query 63 | 64 | 65 | 66 | -- 67 | -- Authorize 68 | -- 69 | 70 | 71 | {-| Request configuration for an authorization 72 | 73 | - `clientId` (_REQUIRED_): 74 | The client identifier issues by the authorization server via an off-band mechanism. 75 | 76 | - `url` (_REQUIRED_): 77 | The authorization endpoint to contact the authorization server. 78 | 79 | - `redirectUri` (_OPTIONAL_): 80 | After completing its interaction with the resource owner, the authorization 81 | server directs the resource owner's user-agent back to the client via this 82 | URL. May be already defined on the authorization server itself. 83 | 84 | - `scope` (_OPTIONAL_): 85 | The scope of the access request. 86 | 87 | - `state` (_RECOMMENDED_): 88 | An opaque value used by the client to maintain state between the request 89 | and callback. The authorization server includes this value when redirecting 90 | the user-agent back to the client. The parameter SHOULD be used for preventing 91 | cross-site request forgery. 92 | 93 | -} 94 | type alias Authorization = 95 | { clientId : String 96 | , url : Url 97 | , redirectUri : Url 98 | , scope : List String 99 | , state : Maybe String 100 | } 101 | 102 | 103 | {-| Describes an OAuth error as a result of an authorization request failure 104 | 105 | - `error` (_REQUIRED_): 106 | A single ASCII error code. 107 | 108 | - `errorDescription` (_OPTIONAL_) 109 | Human-readable ASCII text providing additional information, used to assist the client developer in 110 | understanding the error that occurred. Values for the `errorDescription` parameter MUST NOT 111 | include characters outside the set `%x20-21 / %x23-5B / %x5D-7E`. 112 | 113 | - `errorUri` (_OPTIONAL_): 114 | A URI identifying a human-readable web page with information about the error, used to 115 | provide the client developer with additional information about the error. Values for the 116 | `errorUri` parameter MUST conform to the URI-reference syntax and thus MUST NOT include 117 | characters outside the set `%x21 / %x23-5B / %x5D-7E`. 118 | 119 | - `state` (_REQUIRED if `state` was present in the authorization request_): 120 | The exact value received from the client 121 | 122 | -} 123 | type alias AuthorizationError = 124 | { error : ErrorCode 125 | , errorDescription : Maybe String 126 | , errorUri : Maybe String 127 | , state : Maybe String 128 | } 129 | 130 | 131 | {-| The response obtained as a result of an authentication (implicit or not) 132 | 133 | - `token` (_REQUIRED_): 134 | The access token issued by the authorization server. 135 | 136 | - `refreshToken` (_OPTIONAL_): 137 | The refresh token, which can be used to obtain new access tokens using the same authorization 138 | grant as described in [Section 6](https://tools.ietf.org/html/rfc6749#section-6). 139 | 140 | - `expiresIn` (_RECOMMENDED_): 141 | The lifetime in seconds of the access token. For example, the value "3600" denotes that the 142 | access token will expire in one hour from the time the response was generated. If omitted, the 143 | authorization server SHOULD provide the expiration time via other means or document the default 144 | value. 145 | 146 | - `scope` (_OPTIONAL, if identical to the scope requested; otherwise, REQUIRED_): 147 | The scope of the access token as described by [Section 3.3](https://tools.ietf.org/html/rfc6749#section-3.3). 148 | 149 | - `state` (_REQUIRED if `state` was present in the authorization request_): 150 | The exact value received from the client 151 | 152 | -} 153 | type alias AuthorizationSuccess = 154 | { token : Token 155 | , refreshToken : Maybe Token 156 | , expiresIn : Maybe Int 157 | , scope : List String 158 | , state : Maybe String 159 | } 160 | 161 | 162 | {-| Describes errors coming from attempting to parse a url after an OAuth redirection. 163 | -} 164 | type alias AuthorizationResult = 165 | AuthorizationResultWith AuthorizationError AuthorizationSuccess 166 | 167 | 168 | {-| A parameterized [`AuthorizationResult`](#AuthorizationResult), see [`parseTokenWith`](#parseTokenWith). 169 | 170 | - `Empty`: means there were nothing (related to OAuth 2.0) to parse 171 | - `Error`: a successfully parsed OAuth 2.0 error 172 | - `Success`: a successfully parsed token and response 173 | 174 | -} 175 | type AuthorizationResultWith error success 176 | = Empty 177 | | Error error 178 | | Success success 179 | 180 | 181 | {-| Redirects the resource owner (user) to the resource provider server using the specified 182 | authorization flow. 183 | -} 184 | makeAuthorizationUrl : Authorization -> Url 185 | makeAuthorizationUrl = 186 | makeAuthorizationUrlWith Token Dict.empty 187 | 188 | 189 | {-| Parses the location looking for parameters in the fragment set by the 190 | authorization server after redirecting the resource owner (user). 191 | 192 | Returns `ParseResult Empty` when there's nothing or an invalid Url is passed 193 | 194 | -} 195 | parseToken : Url -> AuthorizationResult 196 | parseToken = 197 | parseTokenWith defaultParsers 198 | 199 | 200 | 201 | -- 202 | -- Custom Parsers (Advanced) 203 | -- 204 | 205 | 206 | {-| Like [`makeAuthorizationUrl`](#makeAuthorizationUrl), but gives you the ability to specify a 207 | custom response type and extra fields to be set on the query. 208 | 209 | makeAuthorizationUrl : Authorization -> Url 210 | makeAuthorizationUrl = 211 | makeAuthorizationUrlWith Token Dict.empty 212 | 213 | For example, to interact with a service implementing `OpenID+Connect` you may require a different 214 | token type and an extra query parameter as such: 215 | 216 | makeAuthorizationUrlWith 217 | (CustomResponse "token+id_token") 218 | (Dict.fromList [ ( "resource", "001" ) ]) 219 | authorization 220 | 221 | -} 222 | makeAuthorizationUrlWith : ResponseType -> Dict String String -> Authorization -> Url 223 | makeAuthorizationUrlWith responseType extraFields { clientId, url, redirectUri, scope, state } = 224 | Internal.makeAuthorizationUrl 225 | responseType 226 | extraFields 227 | { clientId = clientId 228 | , url = url 229 | , redirectUri = redirectUri 230 | , scope = scope 231 | , state = state 232 | } 233 | 234 | 235 | {-| Like [`parseToken`](#parseToken), but gives you the ability to provide your own custom parsers. 236 | 237 | This is especially useful when interacting with authorization servers that don't quite 238 | implement the OAuth2.0 specifications. 239 | 240 | parseToken : Url -> AuthorizationResultWith AuthorizationError AuthorizationSuccess 241 | parseToken = 242 | parseTokenWith defaultParsers 243 | 244 | -} 245 | parseTokenWith : Parsers error success -> Url -> AuthorizationResultWith error success 246 | parseTokenWith { tokenParser, errorParser, authorizationSuccessParser, authorizationErrorParser } url_ = 247 | let 248 | url = 249 | { url_ | path = "/", query = url_.fragment, fragment = Nothing } 250 | in 251 | case Url.parse (Url.top Query.map2 Tuple.pair tokenParser errorParser) url of 252 | Just ( Just accessToken, _ ) -> 253 | parseUrlQuery url Empty (Query.map Success <| authorizationSuccessParser accessToken) 254 | 255 | Just ( _, Just error ) -> 256 | parseUrlQuery url Empty (Query.map Error <| authorizationErrorParser error) 257 | 258 | _ -> 259 | Empty 260 | 261 | 262 | {-| Parsers used in the [`parseToken`](#parseToken) function. 263 | 264 | - `tokenParser`: Looks for an `access_token` and `token_type` to build a `Token` 265 | - `errorParser`: Looks for an `error` to build a corresponding `ErrorCode` 266 | - `authorizationSuccessParser`: Selected when the `tokenParser` succeeded to parse the remaining parts 267 | - `authorizationErrorParser`: Selected when the `errorParser` succeeded to parse the remaining parts 268 | 269 | -} 270 | type alias Parsers error success = 271 | { tokenParser : Query.Parser (Maybe Token) 272 | , errorParser : Query.Parser (Maybe ErrorCode) 273 | , authorizationSuccessParser : Token -> Query.Parser success 274 | , authorizationErrorParser : ErrorCode -> Query.Parser error 275 | } 276 | 277 | 278 | {-| Default parsers according to RFC-6749. 279 | -} 280 | defaultParsers : Parsers AuthorizationError AuthorizationSuccess 281 | defaultParsers = 282 | { tokenParser = defaultTokenParser 283 | , errorParser = defaultErrorParser 284 | , authorizationSuccessParser = defaultAuthorizationSuccessParser 285 | , authorizationErrorParser = defaultAuthorizationErrorParser 286 | } 287 | 288 | 289 | {-| Default `access_token` parser according to RFC-6749. 290 | -} 291 | defaultTokenParser : Query.Parser (Maybe Token) 292 | defaultTokenParser = 293 | tokenParser 294 | 295 | 296 | {-| Default `error` parser according to RFC-6749. 297 | -} 298 | defaultErrorParser : Query.Parser (Maybe ErrorCode) 299 | defaultErrorParser = 300 | errorParser errorCodeFromString 301 | 302 | 303 | {-| Default response success parser according to RFC-6749. 304 | -} 305 | defaultAuthorizationSuccessParser : Token -> Query.Parser AuthorizationSuccess 306 | defaultAuthorizationSuccessParser accessToken = 307 | Query.map3 (AuthorizationSuccess accessToken Nothing) 308 | expiresInParser 309 | scopeParser 310 | stateParser 311 | 312 | 313 | {-| Default response error parser according to RFC-6749. 314 | -} 315 | defaultAuthorizationErrorParser : ErrorCode -> Query.Parser AuthorizationError 316 | defaultAuthorizationErrorParser = 317 | authorizationErrorParser 318 | -------------------------------------------------------------------------------- /src/OAuth/Password.elm: -------------------------------------------------------------------------------- 1 | module OAuth.Password exposing 2 | ( makeTokenRequest, Authentication, Credentials, AuthenticationSuccess, AuthenticationError, RequestParts 3 | , defaultAuthenticationSuccessDecoder, defaultAuthenticationErrorDecoder 4 | , makeTokenRequestWith, defaultExpiresInDecoder, defaultScopeDecoder, lenientScopeDecoder, defaultTokenDecoder, defaultRefreshTokenDecoder, defaultErrorDecoder, defaultErrorDescriptionDecoder, defaultErrorUriDecoder 5 | ) 6 | 7 | {-| The resource owner password credentials grant type is suitable in 8 | cases where the resource owner has a trust relationship with the 9 | client, such as the device operating system or a highly privileged 10 | application. The authorization server should take special care when 11 | enabling this grant type and only allow it when other flows are not 12 | viable. 13 | 14 | There's only one step in this process: 15 | 16 | - The client authenticates itself directly using the resource owner (user) credentials 17 | 18 | After this step, the client owns a `Token` that can be used to authorize any subsequent 19 | request. 20 | 21 | 22 | ## Authenticate 23 | 24 | @docs makeTokenRequest, Authentication, Credentials, AuthenticationSuccess, AuthenticationError, RequestParts 25 | 26 | 27 | ## JSON Decoders 28 | 29 | @docs defaultAuthenticationSuccessDecoder, defaultAuthenticationErrorDecoder 30 | 31 | 32 | ## Custom Decoders & Parsers (advanced) 33 | 34 | @docs makeTokenRequestWith, defaultExpiresInDecoder, defaultScopeDecoder, lenientScopeDecoder, defaultTokenDecoder, defaultRefreshTokenDecoder, defaultErrorDecoder, defaultErrorDescriptionDecoder, defaultErrorUriDecoder 35 | 36 | -} 37 | 38 | import Dict as Dict exposing (Dict) 39 | import Http 40 | import Internal as Internal exposing (..) 41 | import Json.Decode as Json 42 | import OAuth exposing (ErrorCode(..), GrantType(..), Token, errorCodeFromString, grantTypeToString) 43 | import Url exposing (Url) 44 | import Url.Builder as Builder 45 | 46 | 47 | {-| Request configuration for a Password authentication 48 | 49 | - `credentials` (_RECOMMENDED_): 50 | Credentials needed for `Basic` authentication, if needed by the 51 | authorization server. 52 | 53 | - `url` (_REQUIRED_): 54 | The token endpoint to contact the authorization server. 55 | 56 | - `scope` (_OPTIONAL_): 57 | The scope of the access request. 58 | 59 | - `password` (_REQUIRED_): 60 | Resource owner's password 61 | 62 | - `username` (_REQUIRED_): 63 | Resource owner's username 64 | 65 | -} 66 | type alias Authentication = 67 | { credentials : Maybe Credentials 68 | , url : Url 69 | , scope : List String 70 | , username : String 71 | , password : String 72 | } 73 | 74 | 75 | {-| Describes at least a `clientId` and if defined, a complete set of credentials 76 | with the `secret`. Optional but may be required by the authorization server you 77 | interact with to perform a 'Basic' authentication on top of the authentication request. 78 | 79 | { clientId = "" 80 | , secret = "" 81 | } 82 | 83 | -} 84 | type alias Credentials = 85 | { clientId : String, secret : String } 86 | 87 | 88 | {-| The response obtained as a result of an authentication: 89 | 90 | - `token` (_REQUIRED_): 91 | The access token issued by the authorization server. 92 | 93 | - `refreshToken` (_OPTIONAL_): 94 | The refresh token, which can be used to obtain new access tokens using the same authorization 95 | grant as described in [Section 6](https://tools.ietf.org/html/rfc6749#section-6). 96 | 97 | - `expiresIn` (_RECOMMENDED_): 98 | The lifetime in seconds of the access token. For example, the value "3600" denotes that the 99 | access token will expire in one hour from the time the response was generated. If omitted, the 100 | authorization server SHOULD provide the expiration time via other means or document the default 101 | value. 102 | 103 | - `scope` (_OPTIONAL, if identical to the scope requested; otherwise, REQUIRED_): 104 | The scope of the access token as described by [Section 3.3](https://tools.ietf.org/html/rfc6749#section-3.3). 105 | 106 | -} 107 | type alias AuthenticationSuccess = 108 | { token : Token 109 | , refreshToken : Maybe Token 110 | , expiresIn : Maybe Int 111 | , scope : List String 112 | } 113 | 114 | 115 | {-| Describes an OAuth error as a result of a request failure 116 | 117 | - `error` (_REQUIRED_): 118 | A single ASCII error code. 119 | 120 | - `errorDescription` (_OPTIONAL_) 121 | Human-readable ASCII text providing additional information, used to assist the client developer in 122 | understanding the error that occurred. Values for the `errorDescription` parameter MUST NOT 123 | include characters outside the set `%x20-21 / %x23-5B / %x5D-7E`. 124 | 125 | - `errorUri` (_OPTIONAL_): 126 | A URI identifying a human-readable web page with information about the error, used to 127 | provide the client developer with additional information about the error. Values for the 128 | `errorUri` parameter MUST conform to the URI-reference syntax and thus MUST NOT include 129 | characters outside the set `%x21 / %x23-5B / %x5D-7E`. 130 | 131 | -} 132 | type alias AuthenticationError = 133 | { error : ErrorCode 134 | , errorDescription : Maybe String 135 | , errorUri : Maybe String 136 | } 137 | 138 | 139 | {-| Parts required to build a request. This record is given to `Http.request` in order 140 | to create a new request and may be adjusted at will. 141 | -} 142 | type alias RequestParts a = 143 | { method : String 144 | , headers : List Http.Header 145 | , url : String 146 | , body : Http.Body 147 | , expect : Http.Expect a 148 | , timeout : Maybe Float 149 | , tracker : Maybe String 150 | } 151 | 152 | 153 | {-| Builds the request components required to get a token in exchange of the resource owner (user) credentials 154 | 155 | let req : Http.Request TokenResponse 156 | req = makeTokenRequest toMsg authentication |> Http.request 157 | 158 | -} 159 | makeTokenRequest : (Result Http.Error AuthenticationSuccess -> msg) -> Authentication -> RequestParts msg 160 | makeTokenRequest = 161 | makeTokenRequestWith Password defaultAuthenticationSuccessDecoder Dict.empty 162 | 163 | 164 | 165 | -- 166 | -- Json Decoders 167 | -- 168 | 169 | 170 | {-| Json decoder for a positive response. You may provide a custom response decoder using other decoders 171 | from this module, or some of your own craft. 172 | 173 | defaultAuthenticationSuccessDecoder : Decoder AuthenticationSuccess 174 | defaultAuthenticationSuccessDecoder = 175 | D.map4 AuthenticationSuccess 176 | tokenDecoder 177 | refreshTokenDecoder 178 | expiresInDecoder 179 | scopeDecoder 180 | 181 | -} 182 | defaultAuthenticationSuccessDecoder : Json.Decoder AuthenticationSuccess 183 | defaultAuthenticationSuccessDecoder = 184 | Internal.authenticationSuccessDecoder 185 | 186 | 187 | {-| Json decoder for an errored response. 188 | 189 | case res of 190 | Err (Http.BadStatus { body }) -> 191 | case Json.decodeString OAuth.Password.defaultAuthenticationErrorDecoder body of 192 | Ok { error, errorDescription } -> 193 | doSomething 194 | 195 | _ -> 196 | parserFailed 197 | 198 | _ -> 199 | someOtherError 200 | 201 | -} 202 | defaultAuthenticationErrorDecoder : Json.Decoder AuthenticationError 203 | defaultAuthenticationErrorDecoder = 204 | Internal.authenticationErrorDecoder defaultErrorDecoder 205 | 206 | 207 | 208 | -- 209 | -- Custom Decoders & Parsers (advanced) 210 | -- 211 | 212 | 213 | {-| Like [`makeTokenRequest`](#makeTokenRequest), but gives you the ability to specify custom grant 214 | type and extra fields to be set on the query. 215 | 216 | makeTokenRequest : (Result Http.Error AuthenticationSuccess -> msg) -> Authentication -> RequestParts msg 217 | makeTokenRequest = 218 | makeTokenRequestWith Password defaultAuthenticationSuccessDecoder Dict.empty 219 | 220 | -} 221 | makeTokenRequestWith : GrantType -> Json.Decoder success -> Dict String String -> (Result Http.Error success -> msg) -> Authentication -> RequestParts msg 222 | makeTokenRequestWith grantType decoder extraFields toMsg { credentials, password, scope, url, username } = 223 | let 224 | body = 225 | [ Builder.string "grant_type" (grantTypeToString grantType) 226 | , Builder.string "username" username 227 | , Builder.string "password" password 228 | ] 229 | |> urlAddExtraFields extraFields 230 | |> urlAddList "scope" scope 231 | |> Builder.toQuery 232 | |> String.dropLeft 1 233 | 234 | headers = 235 | makeHeaders credentials 236 | in 237 | makeRequest decoder toMsg url headers body 238 | 239 | 240 | {-| Json decoder for the `expiresIn` field. 241 | -} 242 | defaultExpiresInDecoder : Json.Decoder (Maybe Int) 243 | defaultExpiresInDecoder = 244 | Internal.expiresInDecoder 245 | 246 | 247 | {-| Json decoder for the `scope` field (space-separated). 248 | -} 249 | defaultScopeDecoder : Json.Decoder (List String) 250 | defaultScopeDecoder = 251 | Internal.scopeDecoder 252 | 253 | 254 | {-| Json decoder for the `scope` field (comma- or space-separated). 255 | -} 256 | lenientScopeDecoder : Json.Decoder (List String) 257 | lenientScopeDecoder = 258 | Internal.lenientScopeDecoder 259 | 260 | 261 | {-| Json decoder for the `access_token` field. 262 | -} 263 | defaultTokenDecoder : Json.Decoder Token 264 | defaultTokenDecoder = 265 | Internal.tokenDecoder 266 | 267 | 268 | {-| Json decoder for the `refresh_token` field. 269 | -} 270 | defaultRefreshTokenDecoder : Json.Decoder (Maybe Token) 271 | defaultRefreshTokenDecoder = 272 | Internal.refreshTokenDecoder 273 | 274 | 275 | {-| Json decoder for the `error` field. 276 | -} 277 | defaultErrorDecoder : Json.Decoder ErrorCode 278 | defaultErrorDecoder = 279 | Internal.errorDecoder errorCodeFromString 280 | 281 | 282 | {-| Json decoder for the `error_description` field. 283 | -} 284 | defaultErrorDescriptionDecoder : Json.Decoder (Maybe String) 285 | defaultErrorDescriptionDecoder = 286 | Internal.errorDescriptionDecoder 287 | 288 | 289 | {-| Json decoder for the `error_uri` field. 290 | -} 291 | defaultErrorUriDecoder : Json.Decoder (Maybe String) 292 | defaultErrorUriDecoder = 293 | Internal.errorUriDecoder 294 | -------------------------------------------------------------------------------- /src/OAuth/Refresh.elm: -------------------------------------------------------------------------------- 1 | module OAuth.Refresh exposing 2 | ( makeTokenRequest, Authentication, Credentials, AuthenticationSuccess, AuthenticationError, RequestParts 3 | , defaultAuthenticationSuccessDecoder, defaultAuthenticationErrorDecoder 4 | , makeTokenRequestWith, defaultExpiresInDecoder, defaultScopeDecoder, lenientScopeDecoder, defaultTokenDecoder, defaultRefreshTokenDecoder, defaultErrorDecoder, defaultErrorDescriptionDecoder, defaultErrorUriDecoder 5 | ) 6 | 7 | {-| If the authorization server issued a refresh token to the client, the 8 | client may make a refresh request to the token endpoint to obtain a new access token 9 | (and refresh token) from the authorization server. 10 | 11 | There's only one step in this process: 12 | 13 | - The client authenticates itself directly using the previously obtained refresh token 14 | 15 | After this step, the client owns a fresh access `Token` and possibly, a new refresh `Token`. Both 16 | can be used in subsequent requests. 17 | 18 | 19 | ## Authenticate 20 | 21 | @docs makeTokenRequest, Authentication, Credentials, AuthenticationSuccess, AuthenticationError, RequestParts 22 | 23 | 24 | ## JSON Decoders 25 | 26 | @docs defaultAuthenticationSuccessDecoder, defaultAuthenticationErrorDecoder 27 | 28 | 29 | ## Custom Decoders & Parsers (advanced) 30 | 31 | @docs makeTokenRequestWith, defaultExpiresInDecoder, defaultScopeDecoder, lenientScopeDecoder, defaultTokenDecoder, defaultRefreshTokenDecoder, defaultErrorDecoder, defaultErrorDescriptionDecoder, defaultErrorUriDecoder 32 | 33 | -} 34 | 35 | import Dict as Dict exposing (Dict) 36 | import Http 37 | import Internal as Internal exposing (..) 38 | import Json.Decode as Json 39 | import OAuth exposing (ErrorCode(..), GrantType(..), Token, errorCodeFromString, grantTypeToString) 40 | import Url exposing (Url) 41 | import Url.Builder as Builder 42 | 43 | 44 | {-| Request configuration for a Refresh authentication 45 | 46 | - `credentials` (_RECOMMENDED_): 47 | Credentials needed for Basic authentication, if needed by the 48 | authorization server. 49 | 50 | - `url` (_REQUIRED_): 51 | The token endpoint to contact the authorization server. 52 | 53 | - `scope` (_OPTIONAL_): 54 | The scope of the access request. 55 | 56 | - `token` (_REQUIRED_): 57 | Token endpoint of the resource provider 58 | 59 | -} 60 | type alias Authentication = 61 | { credentials : Maybe Credentials 62 | , url : Url 63 | , scope : List String 64 | , token : Token 65 | } 66 | 67 | 68 | {-| Describes a couple of client credentials used for Basic authentication 69 | 70 | { clientId = "" 71 | , secret = "" 72 | } 73 | 74 | -} 75 | type alias Credentials = 76 | { clientId : String, secret : String } 77 | 78 | 79 | {-| The response obtained as a result of an authentication (implicit or not) 80 | 81 | - `token` (_REQUIRED_): 82 | The access token issued by the authorization server. 83 | 84 | - `refreshToken` (_OPTIONAL_): 85 | The refresh token, which can be used to obtain new access tokens using the same authorization 86 | grant as described in [Section 6](https://tools.ietf.org/html/rfc6749#section-6). 87 | 88 | - `expiresIn` (_RECOMMENDED_): 89 | The lifetime in seconds of the access token. For example, the value "3600" denotes that the 90 | access token will expire in one hour from the time the response was generated. If omitted, the 91 | authorization server SHOULD provide the expiration time via other means or document the default 92 | value. 93 | 94 | - `scope` (_OPTIONAL, if identical to the scope requested; otherwise, REQUIRED_): 95 | The scope of the access token as described by [Section 3.3](https://tools.ietf.org/html/rfc6749#section-3.3). 96 | 97 | -} 98 | type alias AuthenticationSuccess = 99 | { token : Token 100 | , refreshToken : Maybe Token 101 | , expiresIn : Maybe Int 102 | , scope : List String 103 | } 104 | 105 | 106 | {-| Describes an OAuth error as a result of a request failure 107 | 108 | - `error` (_REQUIRED_): 109 | A single ASCII error code. 110 | 111 | - `errorDescription` (_OPTIONAL_) 112 | Human-readable ASCII text providing additional information, used to assist the client developer in 113 | understanding the error that occurred. Values for the `errorDescription` parameter MUST NOT 114 | include characters outside the set `%x20-21 / %x23-5B / %x5D-7E`. 115 | 116 | - `errorUri` (_OPTIONAL_): 117 | A URI identifying a human-readable web page with information about the error, used to 118 | provide the client developer with additional information about the error. Values for the 119 | `errorUri` parameter MUST conform to the URI-reference syntax and thus MUST NOT include 120 | characters outside the set `%x21 / %x23-5B / %x5D-7E`. 121 | 122 | -} 123 | type alias AuthenticationError = 124 | { error : ErrorCode 125 | , errorDescription : Maybe String 126 | , errorUri : Maybe String 127 | } 128 | 129 | 130 | {-| Parts required to build a request. This record is given to [`Http.request`](https://package.elm-lang.org/packages/elm/http/latest/Http#request) 131 | in order to create a new request and may be adjusted at will. 132 | -} 133 | type alias RequestParts a = 134 | { method : String 135 | , headers : List Http.Header 136 | , url : String 137 | , body : Http.Body 138 | , expect : Http.Expect a 139 | , timeout : Maybe Float 140 | , tracker : Maybe String 141 | } 142 | 143 | 144 | {-| Builds the request components required to refresh a token 145 | 146 | let req : Http.Request TokenResponse 147 | req = makeTokenRequest toMsg reqParts |> Http.request 148 | 149 | -} 150 | makeTokenRequest : (Result Http.Error AuthenticationSuccess -> msg) -> Authentication -> RequestParts msg 151 | makeTokenRequest = 152 | makeTokenRequestWith RefreshToken defaultAuthenticationSuccessDecoder Dict.empty 153 | 154 | 155 | 156 | -- 157 | -- Json Decoders 158 | -- 159 | 160 | 161 | {-| Json decoder for a positive response. You may provide a custom response decoder using other decoders 162 | from this module, or some of your own craft. 163 | 164 | defaultAuthenticationSuccessDecoder : Decoder AuthenticationSuccess 165 | defaultAuthenticationSuccessDecoder = 166 | D.map4 AuthenticationSuccess 167 | tokenDecoder 168 | refreshTokenDecoder 169 | expiresInDecoder 170 | scopeDecoder 171 | 172 | -} 173 | defaultAuthenticationSuccessDecoder : Json.Decoder AuthenticationSuccess 174 | defaultAuthenticationSuccessDecoder = 175 | Internal.authenticationSuccessDecoder 176 | 177 | 178 | {-| Json decoder for an errored response. 179 | 180 | case res of 181 | Err (Http.BadStatus { body }) -> 182 | case Json.decodeString OAuth.ClientCredentials.defaultAuthenticationErrorDecoder body of 183 | Ok { error, errorDescription } -> 184 | doSomething 185 | 186 | _ -> 187 | parserFailed 188 | 189 | _ -> 190 | someOtherError 191 | 192 | -} 193 | defaultAuthenticationErrorDecoder : Json.Decoder AuthenticationError 194 | defaultAuthenticationErrorDecoder = 195 | Internal.authenticationErrorDecoder defaultErrorDecoder 196 | 197 | 198 | 199 | -- 200 | -- Custom Decoders & Parsers (advanced) 201 | -- 202 | 203 | 204 | {-| Like [`makeTokenRequest`](#makeTokenRequest), but gives you the ability to specify custom grant 205 | type and extra fields to be set on the query. 206 | 207 | makeTokenRequest : (Result Http.Error AuthenticationSuccess -> msg) -> Authentication -> RequestParts msg 208 | makeTokenRequest = 209 | makeTokenRequestWith RefreshToken defaultAuthenticationSuccessDecoder Dict.empty 210 | 211 | -} 212 | makeTokenRequestWith : GrantType -> Json.Decoder success -> Dict String String -> (Result Http.Error success -> msg) -> Authentication -> RequestParts msg 213 | makeTokenRequestWith grantType decoder extraFields toMsg { credentials, scope, token, url } = 214 | let 215 | body = 216 | [ Builder.string "grant_type" (grantTypeToString grantType) 217 | , Builder.string "refresh_token" (extractTokenString token) 218 | ] 219 | |> urlAddList "scope" scope 220 | |> urlAddExtraFields extraFields 221 | |> Builder.toQuery 222 | |> String.dropLeft 1 223 | 224 | headers = 225 | makeHeaders credentials 226 | in 227 | makeRequest decoder toMsg url headers body 228 | 229 | 230 | {-| Json decoder for the `expiresIn` field. 231 | -} 232 | defaultExpiresInDecoder : Json.Decoder (Maybe Int) 233 | defaultExpiresInDecoder = 234 | Internal.expiresInDecoder 235 | 236 | 237 | {-| Json decoder for the `scope` field (space-separated). 238 | -} 239 | defaultScopeDecoder : Json.Decoder (List String) 240 | defaultScopeDecoder = 241 | Internal.scopeDecoder 242 | 243 | 244 | {-| Json decoder for the `scope` field (comma- or space-separated). 245 | -} 246 | lenientScopeDecoder : Json.Decoder (List String) 247 | lenientScopeDecoder = 248 | Internal.lenientScopeDecoder 249 | 250 | 251 | {-| Json decoder for the `access_token` field. 252 | -} 253 | defaultTokenDecoder : Json.Decoder Token 254 | defaultTokenDecoder = 255 | Internal.tokenDecoder 256 | 257 | 258 | {-| Json decoder for the `refresh_token` field. 259 | -} 260 | defaultRefreshTokenDecoder : Json.Decoder (Maybe Token) 261 | defaultRefreshTokenDecoder = 262 | Internal.refreshTokenDecoder 263 | 264 | 265 | {-| Json decoder for the `error` field 266 | -} 267 | defaultErrorDecoder : Json.Decoder ErrorCode 268 | defaultErrorDecoder = 269 | Internal.errorDecoder errorCodeFromString 270 | 271 | 272 | {-| Json decoder for the `error_description` field 273 | -} 274 | defaultErrorDescriptionDecoder : Json.Decoder (Maybe String) 275 | defaultErrorDescriptionDecoder = 276 | Internal.errorDescriptionDecoder 277 | 278 | 279 | {-| Json decoder for the `error_uri` field 280 | -} 281 | defaultErrorUriDecoder : Json.Decoder (Maybe String) 282 | defaultErrorUriDecoder = 283 | Internal.errorUriDecoder 284 | -------------------------------------------------------------------------------- /tests/Test/Parsers.elm: -------------------------------------------------------------------------------- 1 | module Test.Parsers exposing (..) 2 | 3 | import Expect exposing (Expectation) 4 | import Fuzz exposing (Fuzzer, int, list, string) 5 | import OAuth exposing (tokenFromString) 6 | import OAuth.Implicit as Implicit 7 | import Test exposing (..) 8 | import Url exposing (Protocol(..), Url) 9 | import Url.Parser.Query as Query 10 | 11 | 12 | suite : Test 13 | suite = 14 | describe "parseTokenWith" 15 | [ test "example #1" <| 16 | \_ -> 17 | let 18 | url = 19 | { fragment = 20 | Just <| 21 | String.join "&" 22 | [ "access_token=eyJ0ePPjuBg" 23 | , "token_type=bearer" 24 | , "expires_in=3600" 25 | , "state=z31j7AMHBiAHySvY8PvtcA==" 26 | ] 27 | , host = "localhost" 28 | , path = "/dashboard" 29 | , port_ = Just 4200 30 | , protocol = Http 31 | , query = Nothing 32 | } 33 | 34 | result = 35 | Implicit.parseTokenWith Implicit.defaultParsers url 36 | in 37 | case result of 38 | Implicit.Success authorization -> 39 | Expect.all 40 | [ \{ token } -> 41 | Just token 42 | |> Expect.equal (tokenFromString "Bearer=eyJ0ePPjuBg") 43 | , \{ refreshToken } -> 44 | refreshToken |> Expect.equal Nothing 45 | , \{ expiresIn } -> 46 | expiresIn 47 | |> Expect.equal (Just 3600) 48 | , \{ scope } -> 49 | scope 50 | |> Expect.equal [] 51 | , \{ state } -> 52 | state 53 | |> Expect.equal (Just "z31j7AMHBiAHySvY8PvtcA==") 54 | ] 55 | authorization 56 | 57 | _ -> 58 | Expect.fail "Expected parser to succeed" 59 | ] 60 | --------------------------------------------------------------------------------