diff options
-rw-r--r-- | .github/workflows/linux.yml | 38 | ||||
-rw-r--r-- | .github/workflows/macos.yml | 21 | ||||
-rw-r--r-- | .perltidyrc | 2 | ||||
-rw-r--r-- | .pls_cache/index | bin | 0 -> 8316 bytes | |||
-rw-r--r-- | .travis.yml | 14 | ||||
-rw-r--r-- | Changes | 16 | ||||
-rw-r--r-- | MANIFEST | 7 | ||||
-rw-r--r-- | META.json | 15 | ||||
-rw-r--r-- | META.yml | 12 | ||||
-rw-r--r-- | Makefile.PL | 11 | ||||
-rw-r--r-- | README.md | 326 | ||||
-rw-r--r-- | cpanfile | 10 | ||||
-rw-r--r-- | debian/changelog | 10 | ||||
-rw-r--r-- | debian/control | 14 | ||||
-rw-r--r-- | debian/patches/0001-add-salsa-as-an-OAuth2-id-provider.patch | 30 | ||||
-rw-r--r-- | debian/patches/series | 1 | ||||
-rw-r--r-- | lib/Mojolicious/Plugin/OAuth2.pm | 604 | ||||
-rw-r--r-- | lib/Mojolicious/Plugin/OAuth2/Mock.pm | 267 | ||||
-rw-r--r-- | t/auth_url.t | 2 | ||||
-rw-r--r-- | t/delayed.t | 19 | ||||
-rw-r--r-- | t/mocked.t | 26 | ||||
-rw-r--r-- | t/mocked_blocking.t | 46 | ||||
-rw-r--r-- | t/openid-connect.t | 195 |
23 files changed, 1132 insertions, 554 deletions
diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml new file mode 100644 index 0000000..f016665 --- /dev/null +++ b/.github/workflows/linux.yml @@ -0,0 +1,38 @@ +name: linux +on: + push: + branches: + - '*' + tags-ignore: + - '*' + pull_request: +jobs: + perl: + runs-on: ubuntu-latest + strategy: + matrix: + perl-version: + - '5.16' + - '5.18' + - '5.20' + - '5.22' + - '5.30' + - '5.32' + container: + image: perl:${{ matrix.perl-version }} + steps: + - uses: actions/checkout@v2 + - name: perl -V + run: perl -V + - name: Fix ExtUtils::MakeMaker (for Perl 5.16 and 5.18) + run: cpanm -n App::cpanminus ExtUtils::MakeMaker + - name: Install dependencies + run: | + cpanm -n --installdeps . + cpanm -n Crypt::OpenSSL::Bignum Crypt::OpenSSL::RSA IO::Socket::SSL Mojo::JWT + cpanm -n Test::Pod Test::Pod::Coverage + - name: Run tests + run: prove -l t + env: + TEST_POD: 1 + TEST_EV: 1 diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml new file mode 100644 index 0000000..125f007 --- /dev/null +++ b/.github/workflows/macos.yml @@ -0,0 +1,21 @@ +name: macos +on: + push: + branches: + - '*' + tags-ignore: + - '*' + pull_request: +jobs: + perl: + runs-on: macOS-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Perl + run: brew install perl + - name: perl -V + run: perl -V + - name: Install Dependencies + run: curl -L https://cpanmin.us | perl - --installdeps . + - name: Run Tests + run: prove -l t diff --git a/.perltidyrc b/.perltidyrc index 74e47c7..b8ccf77 100644 --- a/.perltidyrc +++ b/.perltidyrc @@ -10,3 +10,5 @@ -bt=2 # High brace tightness -sbt=2 # High square bracket tightness -isbc # Don't indent comments without leading space +-wn # Weld nested containers +-nst # Don't output to STDOUT diff --git a/.pls_cache/index b/.pls_cache/index Binary files differnew file mode 100644 index 0000000..2962189 --- /dev/null +++ b/.pls_cache/index diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 6ec0d1d..0000000 --- a/.travis.yml +++ /dev/null @@ -1,14 +0,0 @@ -language: perl -perl: - - "5.18" - - "5.16" - - "5.14" - - "5.12" - - "5.10" -env: - - "HARNESS_OPTIONS=j9 TEST_POD=1 TEST_EV=1" -install: - - "cpanm -n Test::Pod Test::Pod::Coverage" - - "cpanm -n --installdeps ." -notifications: - email: false @@ -1,8 +1,22 @@ Revision history for perl distribution Mojolicious-Plugin-OAuth2 +2.01 2021-10-28T18:29:45+0900 + - Test suite is compatible with older versions of Mojolicious + - OpenID Connect require Mojo::JWT 0.09 + +2.00 2021-10-27T19:36:44+0900 + - Removed $c->oauth2->get_token() + https://github.com/marcusramberg/Mojolicious-Plugin-OAuth2/blob/07e214eb556093de8691b145116b60ab64a4a21a/t/delayed.t#L23-L28 + - Add support for "OpenID Connect" #65 + Contributor: Roy Storey + - Add "debian_salsa" as an OAuth2 id provider #62 + Contributor: Gregor Herrmann + - Moved mock code to Mojolicious::Plugin::OAuth2::Mock + - Bumped Mojolicious version to 8.25 + 1.59 2021-02-17T08:33:17+0900 - Fix invalid "=item" in documentation. - - Compatible with Mojolicious 9.0 + - Compatible with Mojolicious 9.0 #61 Contributor: Joel Berger 1.58 2019-07-03T14:22:38+0200 @@ -1,9 +1,12 @@ +.github/workflows/linux.yml +.github/workflows/macos.yml .perltidyrc +.pls_cache/index .ship.conf -.travis.yml Changes cpanfile lib/Mojolicious/Plugin/OAuth2.pm +lib/Mojolicious/Plugin/OAuth2/Mock.pm Makefile.PL MANIFEST This list of files README @@ -15,6 +18,6 @@ t/error.t t/Helper.pm t/live.t t/mocked.t -t/mocked_blocking.t +t/openid-connect.t META.yml Module YAML meta-data (added by MakeMaker) META.json Module JSON meta-data (added by MakeMaker) @@ -1,10 +1,10 @@ { - "abstract" : "Auth against OAuth2 APIs", + "abstract" : "Auth against OAuth2 APIs including OpenID Connect", "author" : [ "Jan Henning Thorsen <jhthorsen@cpan.org>" ], "dynamic_config" : 0, - "generated_by" : "ExtUtils::MakeMaker version 7.44, CPAN::Meta::Converter version 2.150010", + "generated_by" : "ExtUtils::MakeMaker version 7.62, CPAN::Meta::Converter version 2.150010", "license" : [ "artistic_2" ], @@ -29,9 +29,14 @@ } }, "runtime" : { + "recommends" : { + "Crypt::OpenSSL::Bignum" : "0.09", + "Crypt::OpenSSL::RSA" : "0.31", + "Mojo::JWT" : "0.09" + }, "requires" : { "IO::Socket::SSL" : "1.94", - "Mojolicious" : "7.53" + "Mojolicious" : "8.25" } }, "test" : { @@ -52,10 +57,10 @@ "web" : "https://github.com/marcusramberg/Mojolicious-Plugin-OAuth2" } }, - "version" : "1.59", + "version" : "2.01", "x_contributors" : [ "Marcus Ramberg <mramberg@cpan.org>", "Jan Henning Thorsen <jhthorsen@cpan.org>" ], - "x_serialization_backend" : "JSON::PP version 4.04" + "x_serialization_backend" : "JSON::PP version 4.06" } @@ -1,5 +1,5 @@ --- -abstract: 'Auth against OAuth2 APIs' +abstract: 'Auth against OAuth2 APIs including OpenID Connect' author: - 'Jan Henning Thorsen <jhthorsen@cpan.org>' build_requires: @@ -7,7 +7,7 @@ build_requires: configure_requires: ExtUtils::MakeMaker: '0' dynamic_config: 0 -generated_by: 'ExtUtils::MakeMaker version 7.44, CPAN::Meta::Converter version 2.150010' +generated_by: 'ExtUtils::MakeMaker version 7.62, CPAN::Meta::Converter version 2.150010' license: artistic_2 meta-spec: url: http://module-build.sourceforge.net/META-spec-v1.4.html @@ -17,14 +17,18 @@ no_index: directory: - t - inc +recommends: + Crypt::OpenSSL::Bignum: '0.09' + Crypt::OpenSSL::RSA: '0.31' + Mojo::JWT: '0.09' requires: IO::Socket::SSL: '1.94' - Mojolicious: '7.53' + Mojolicious: '8.25' resources: bugtracker: https://github.com/marcusramberg/Mojolicious-Plugin-OAuth2/issues homepage: https://github.com/marcusramberg/Mojolicious-Plugin-OAuth2 repository: https://github.com/marcusramberg/Mojolicious-Plugin-OAuth2.git -version: '1.59' +version: '2.01' x_contributors: - 'Marcus Ramberg <mramberg@cpan.org>' - 'Jan Henning Thorsen <jhthorsen@cpan.org>' diff --git a/Makefile.PL b/Makefile.PL index ba61ada..1f2000d 100644 --- a/Makefile.PL +++ b/Makefile.PL @@ -17,12 +17,21 @@ my %WriteMakefileArgs = ( , PREREQ_PM => { 'IO::Socket::SSL' => '1.94', - 'Mojolicious' => '7.53' + 'Mojolicious' => '8.25' } , META_MERGE => { 'dynamic_config' => 0, 'meta-spec' => {version => 2}, + 'prereqs' => { + 'runtime' => + {'recommends' => { + 'Crypt::OpenSSL::Bignum' => '0.09', + 'Crypt::OpenSSL::RSA' => '0.31', + 'Mojo::JWT' => '0.09' +} +} + }, 'resources' => { bugtracker => {web => 'https://github.com/marcusramberg/Mojolicious-Plugin-OAuth2/issues'}, homepage => 'https://github.com/marcusramberg/Mojolicious-Plugin-OAuth2', @@ -1,164 +1,83 @@ # NAME -Mojolicious::Plugin::OAuth2 - Auth against OAuth2 APIs - -# DESCRIPTION - -This Mojolicious plugin allows you to easily authenticate against a -[OAuth2](http://oauth.net) provider. It includes configurations for a few -popular providers, but you can add your own easily as well. - -Note that OAuth2 requires https, so you need to have the optional Mojolicious -dependency required to support it. Run the command below to check if -[IO::Socket::SSL](https://metacpan.org/pod/IO%3A%3ASocket%3A%3ASSL) is installed. - - $ mojo version - -## References - -- [http://oauth.net/documentation/](http://oauth.net/documentation/) -- [http://aaronparecki.com/articles/2012/07/29/1/oauth2-simplified](http://aaronparecki.com/articles/2012/07/29/1/oauth2-simplified) -- [http://homakov.blogspot.jp/2013/03/oauth1-oauth2-oauth.html](http://homakov.blogspot.jp/2013/03/oauth1-oauth2-oauth.html) -- [http://en.wikipedia.org/wiki/OAuth#OAuth\_2.0](http://en.wikipedia.org/wiki/OAuth#OAuth_2.0) +Mojolicious::Plugin::OAuth2 - Auth against OAuth2 APIs including OpenID Connect # SYNOPSIS -## Example non-blocking application +## Example application use Mojolicious::Lite; - plugin "OAuth2" => { + plugin OAuth2 => { facebook => { - key => "some-public-app-id", + key => 'some-public-app-id', secret => $ENV{OAUTH2_FACEBOOK_SECRET}, }, }; - get "/connect" => sub { - my $c = shift; - my $get_token_args = {redirect_uri => $c->url_for("connect")->userinfo(undef)->to_abs}; + get '/connect' => sub { + my $c = shift; + my %get_token = (redirect_uri => $c->url_for('connect')->userinfo(undef)->to_abs); - $c->oauth2->get_token_p(facebook => $get_token_args)->then(sub { - return unless my $provider_res = shift; # Redirct to Facebook + return $c->oauth2->get_token_p(facebook => \%get_token)->then(sub { + # Redirected to Facebook + return unless my $provider_res = shift; + + # Token received $c->session(token => $provider_res->{access_token}); - $c->redirect_to("profile"); + $c->redirect_to('profile'); })->catch(sub { - $c->render("connect", error => shift); + $c->render('connect', error => shift); }); }; -## Custom connect button - -You can add a "connect link" to your template using the ["oauth2.auth\_url"](#oauth2-auth_url) -helper. Example template: - - Click here to log in: - <%= link_to "Connect!", $c->oauth2->auth_url("facebook", scope => "user_about_me email") %> - -## Configuration - -This plugin takes a hash as config, where the keys are provider names and the -values are configuration for each provider. Here is a complete example: - - plugin "OAuth2" => { - custom_provider => { - key => "APP_ID", - secret => "SECRET_KEY", - authorize_url => "https://provider.example.com/auth", - token_url => "https://provider.example.com/token", - }, - }; - -To make it a bit easier, [Mojolicious::Plugin::OAuth2](https://metacpan.org/pod/Mojolicious%3A%3APlugin%3A%3AOAuth2) has already -values for `authorize_url` and `token_url` for the following providers: - -- dailymotion - - Authentication for Dailymotion video site. - -- eventbrite - - Authentication for [https://www.eventbrite.com](https://www.eventbrite.com) event site. - - See also [http://developer.eventbrite.com/docs/auth/](http://developer.eventbrite.com/docs/auth/). - -- facebook - - OAuth2 for Facebook's graph API, [http://graph.facebook.com/](http://graph.facebook.com/). You can find - `key` (App ID) and `secret` (App Secret) from the app dashboard here: - [https://developers.facebook.com/apps](https://developers.facebook.com/apps). - - See also [https://developers.facebook.com/docs/reference/dialogs/oauth/](https://developers.facebook.com/docs/reference/dialogs/oauth/). - -- instagram - - OAuth2 for Instagram API. You can find `key` (Client ID) and - `secret` (Client Secret) from the app dashboard here: - [https://www.instagram.com/developer/clients/manage/](https://www.instagram.com/developer/clients/manage/). - - See also [https://www.instagram.com/developer/authentication/](https://www.instagram.com/developer/authentication/). - -- github - - Authentication with Github. - - See also [https://developer.github.com/v3/oauth/](https://developer.github.com/v3/oauth/). - -- google - - OAuth2 for Google. You can find the `key` (CLIENT ID) and `secret` - (CLIENT SECRET) from the app console here under "APIs & Auth" and - "Credentials" in the menu at [https://console.developers.google.com/project](https://console.developers.google.com/project). - - See also [https://developers.google.com/+/quickstart/](https://developers.google.com/+/quickstart/). - -- vkontakte - - OAuth2 for Vkontakte. You can find `key` (App ID) and `secret` - (Secure key) from the app dashboard here: [https://vk.com/apps?act=manage](https://vk.com/apps?act=manage). - - See also [https://vk.com/dev/authcode\_flow\_user](https://vk.com/dev/authcode_flow_user). +See ["register"](#register) for more details about the configuration this plugin takes. ## Testing -THIS API IS EXPERIMENTAL AND CAN CHANGE WITHOUT NOTICE. +Code using this plugin can perform offline testing, using the "mocked" +provider: -To enable a "mocked" OAuth2 api, you need to give the special "mocked" -provider a "key": - - plugin "OAuth2" => { mocked => {key => 42} }; - -The code above will add two new routes to your application: + $app->plugin(OAuth2 => {mocked => {key => 42}}); + $app->routes->get('/profile' => sub { + my $c = shift; -- GET /mocked/oauth/authorize + state $mocked = $ENV{TEST_MOCKED} && 'mocked'; + return $c->oauth2->get_token_p($mocked || 'facebook')->then(sub { + ... + }); + }); - This route is a web page which contains a link that takes you back to - "redirect\_uri", with a "code". The "code" default to "fake\_code", but - can be configured: +See [Mojolicious::Plugin::OAuth2::Mock](https://metacpan.org/pod/Mojolicious%3A%3APlugin%3A%3AOAuth2%3A%3AMock) for more details. - $c->app->oauth2->providers->{mocked}{return_code} = "..."; +## Connect button - The route it self can also be customized: +You can add a "connect link" to your template using the ["oauth2.auth\_url"](#oauth2-auth_url) +helper. Example template: - plugin "OAuth2" => { mocked => {authorize_url => '...'} }; + Click here to log in: + <%= link_to 'Connect!', $c->oauth2->auth_url('facebook', scope => 'user_about_me email') %> -- POST /mocked/oauth/token +# DESCRIPTION - This route is will return a "access\_token" which is available in your - ["oauth2.get\_token"](#oauth2-get_token) callback. The default is "fake\_token", but it can - be configured: +This Mojolicious plugin allows you to easily authenticate against a +[OAuth2](http://oauth.net) or [OpenID Connect](https://openid.net/connect/) +provider. It includes configurations for a few popular [providers](#register), +but you can add your own as well. - $c->app->oauth2->providers->{mocked}{return_token} = "..."; +See ["register"](#register) for a full list of bundled providers. - The route it self can also be customized: +To support "OpenID Connect", the following optional modules must be installed +manually: [Crypt::OpenSSL::Bignum](https://metacpan.org/pod/Crypt%3A%3AOpenSSL%3A%3ABignum), [Crypt::OpenSSL::RSA](https://metacpan.org/pod/Crypt%3A%3AOpenSSL%3A%3ARSA) and [Mojo::JWT](https://metacpan.org/pod/Mojo%3A%3AJWT). +The modules can be installed with [App::cpanminus](https://metacpan.org/pod/App%3A%3Acpanminus): - plugin "OAuth2" => { mocked => {token_url => '...'} }; + $ cpanm Crypt::OpenSSL::Bignum Crypt::OpenSSL::RSA Mojo::JWT # HELPERS ## oauth2.auth\_url - $url = $c->oauth2->auth_url($provider => \%args); + $url = $c->oauth2->auth_url($provider_name => \%args); Returns a [Mojo::URL](https://metacpan.org/pod/Mojo%3A%3AURL) object which contain the authorize URL. This is useful if you want to add the authorize URL as a link to your webpage @@ -196,31 +115,36 @@ but can contain: from the identity provider, this exact same string will be carried with the user, as a GET parameter called `state` in the URL that the user will return to. -## oauth2.get\_token +## oauth2.get\_refresh\_token\_p + + $promise = $c->oauth2->get_refresh_token_p($provider_name => \%args); + +When [Mojolicious::Plugin::OAuth2](https://metacpan.org/pod/Mojolicious%3A%3APlugin%3A%3AOAuth2) is being used in OpenID Connect mode this +helper allows for a token to be refreshed by specifying a `refresh_token` in +`%args`. Usage is similar to ["oauth2.get\_token\_p"](#oauth2-get_token_p). + +## oauth2.get\_token\_p - $data = $c->oauth2->get_token($provider_name => \%args); - $c = $c->oauth2->get_token($provider_name => \%args, sub { - my ($c, $err, $data) = @_; - # do stuff with $data->{access_token} if it exists. - }); + $promise = $c->oauth2->get_token_p($provider_name => \%args) + ->then(sub { my $provider_res = shift }) + ->catch(sub { my $err = shift; }); -["oauth2.get\_token"](#oauth2-get_token) is used to either fetch access token from OAuth2 provider, -handle errors or redirect to OAuth2 provider. This method can be called in either -blocking or non-blocking mode. `$err` holds a error description if something -went wrong. Blocking mode will `die($err)` instead of returning it to caller. -`$data` is a hash-ref containing the access token from the OAauth2 provider. -`$data` in blocking mode can also be `undef` if a redirect has been issued -by this module. +["oauth2.get\_token\_p"](#oauth2-get_token_p) is used to either fetch an access token from an OAuth2 +provider, handle errors or redirect to OAuth2 provider. `$err` in the +rejection handler holds a error description if something went wrong. +`$provider_res` is a hash-ref containing the access token from the OAauth2 +provider or `undef` if this plugin performed a 302 redirect to the provider's +connect website. In more detail, this method will do one of two things: -1. If called from an action on your site, it will redirect you to the -`$provider_name`'s `authorize_url`. This site will probably have some -sort of "Connect" and "Reject" button, allowing the visitor to either -connect your site with his/her profile on the OAuth2 provider's page or not. +1. When called from an action on your site, it will redirect you to the provider's +`authorize_url`. This site will probably have some sort of "Connect" and +"Reject" button, allowing the visitor to either connect your site with his/her +profile on the OAuth2 provider's page or not. 2. The OAuth2 provider will redirect the user back to your site after clicking the -"Connect" or "Reject" button. `$data` will then contain a key "access\_token" -on "Connect" and a false value (or die in blocking mode) on "Reject". +"Connect" or "Reject" button. `$provider_res` will then contain a key +"access\_token" on "Connect" and a false value on "Reject". The method takes these arguments: `$provider_name` need to match on of the provider names under ["Configuration"](#configuration) or a custom provider defined @@ -241,15 +165,28 @@ when [registering](#synopsis) the plugin. Scope to ask for credentials to. Should be a space separated list. -## oauth2.get\_token\_p +## oauth2.jwt\_decode + + $claims = $c->oauth2->jwt_decode($provider, sub { my $jwt = shift; ... }); + $claims = $c->oauth2->jwt_decode($provider); + +When [Mojolicious::Plugin::OAuth2](https://metacpan.org/pod/Mojolicious%3A%3APlugin%3A%3AOAuth2) is being used in OpenID Connect mode this +helper allows you to decode the response data encoded with the JWKS discovered +from `well_known_url` configuration. - $promise = $c->oauth2->get_token_p($provider_name => \%args); +## oauth2.logout\_url -Same as ["oauth2.get\_token"](#oauth2-get_token), but returns a [Mojo::Promise](https://metacpan.org/pod/Mojo%3A%3APromise). See ["SYNOPSIS"](#synopsis) -for example usage. + $url = $c->oauth2->logout_url($provider_name => \%args); + +When [Mojolicious::Plugin::OAuth2](https://metacpan.org/pod/Mojolicious%3A%3APlugin%3A%3AOAuth2) is being used in OpenID Connect mode this +helper creates the url to redirect to end the session. The OpenID Connect +Provider will redirect to the `post_logout_redirect_uri` provided in `%args`. +Additional keys for `%args` are `id_token_hint` and `state`. ## oauth2.providers + $hash_ref = $c->oauth2->providers; + This helper allow you to access the raw providers mapping, which looks something like this: @@ -267,13 +204,100 @@ something like this: ## providers + $hash_ref = $oauth2->providers; + Holds a hash of provider information. See ["oauth2.providers"](#oauth2-providers). # METHODS ## register -Will register this plugin in your application. See ["SYNOPSIS"](#synopsis). + $app->plugin(OAuth2 => \%provider_config); + +Will register this plugin in your application with a given `%provider_config`. +The keys in `%provider_config` are provider names and the values are +configuration for each provider. Note that the value will be merged with the +predefined providers below. + +Here is an example to add adddition information like "key" and "secret": + + $app->plugin(OAuth2 => { + custom_provider => { + key => 'APP_ID', + secret => 'SECRET_KEY', + authorize_url => 'https://provider.example.com/auth', + token_url => 'https://provider.example.com/token', + }, + github => { + key => 'APP_ID', + secret => 'SECRET_KEY', + }, + }); + +For [OpenID Connect](https://openid.net/connect/), `authorize_url` and `token_url` are configured from the +`well_known_url` so these are replaced by the `well_known_url` key. + + $app->plugin(OAuth2 => { + azure_ad => { + key => 'APP_ID', + secret => 'SECRET_KEY', + well_known_url => 'https://login.microsoftonline.com/tenant-id/v2.0/.well-known/openid-configuration', + }, + }); + +To make it a bit easier the are already some predefined providers bundled with +this plugin: + +### dailymotion + +Authentication for [https://www.dailymotion.com/](https://www.dailymotion.com/) video site. + +### debian\_salsa + +Authentication for [https://salsa.debian.org/](https://salsa.debian.org/). + +### eventbrite + +Authentication for [https://www.eventbrite.com](https://www.eventbrite.com) event site. + +See also [http://developer.eventbrite.com/docs/auth/](http://developer.eventbrite.com/docs/auth/). + +### facebook + +OAuth2 for Facebook's graph API, [http://graph.facebook.com/](http://graph.facebook.com/). You can find +`key` (App ID) and `secret` (App Secret) from the app dashboard here: +[https://developers.facebook.com/apps](https://developers.facebook.com/apps). + +See also [https://developers.facebook.com/docs/reference/dialogs/oauth/](https://developers.facebook.com/docs/reference/dialogs/oauth/). + +### instagram + +OAuth2 for Instagram API. You can find `key` (Client ID) and +`secret` (Client Secret) from the app dashboard here: +[https://www.instagram.com/developer/clients/manage/](https://www.instagram.com/developer/clients/manage/). + +See also [https://www.instagram.com/developer/authentication/](https://www.instagram.com/developer/authentication/). + +### github + +Authentication with Github. + +See also [https://developer.github.com/v3/oauth/](https://developer.github.com/v3/oauth/). + +### google + +OAuth2 for Google. You can find the `key` (CLIENT ID) and `secret` +(CLIENT SECRET) from the app console here under "APIs & Auth" and +"Credentials" in the menu at [https://console.developers.google.com/project](https://console.developers.google.com/project). + +See also [https://developers.google.com/+/quickstart/](https://developers.google.com/+/quickstart/). + +### vkontakte + +OAuth2 for Vkontakte. You can find `key` (App ID) and `secret` +(Secure key) from the app dashboard here: [https://vk.com/apps?act=manage](https://vk.com/apps?act=manage). + +See also [https://vk.com/dev/authcode\_flow\_user](https://vk.com/dev/authcode_flow_user). # AUTHOR @@ -284,3 +308,11 @@ Jan Henning Thorsen - `jhthorsen@cpan.org` # LICENSE This software is licensed under the same terms as Perl itself. + +# SEE ALSO + +- [http://oauth.net/documentation/](http://oauth.net/documentation/) +- [http://aaronparecki.com/articles/2012/07/29/1/oauth2-simplified](http://aaronparecki.com/articles/2012/07/29/1/oauth2-simplified) +- [http://homakov.blogspot.jp/2013/03/oauth1-oauth2-oauth.html](http://homakov.blogspot.jp/2013/03/oauth1-oauth2-oauth.html) +- [http://en.wikipedia.org/wiki/OAuth#OAuth\_2.0](http://en.wikipedia.org/wiki/OAuth#OAuth_2.0) +- [https://openid.net/connect/](https://openid.net/connect/) @@ -1,4 +1,8 @@ # You can install this projct with curl -L http://cpanmin.us | perl - https://github.com/marcusramberg/Mojolicious-Plugin-OAuth2/archive/master.tar.gz -requires "Mojolicious" => "7.53"; -requires "IO::Socket::SSL" => "1.94"; -test_requires "Test::More" => "0.88"; +requires 'Mojolicious' => '8.25'; +requires 'IO::Socket::SSL' => '1.94'; +test_requires 'Test::More' => '0.88'; + +recommends 'Mojo::JWT' => '0.09'; +recommends 'Crypt::OpenSSL::Bignum' => '0.09'; +recommends 'Crypt::OpenSSL::RSA' => '0.31'; diff --git a/debian/changelog b/debian/changelog index ad01387..e3b0908 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,13 @@ +libmojolicious-plugin-oauth2-perl (2.01-1) unstable; urgency=medium + + * Team upload. + * Import upstream version 2.01. + * Drop 0001-add-salsa-as-an-OAuth2-id-provider.patch, merged upstream. + * Update test and runtime dependencies. + * Declare compliance with Debian Policy 4.6.0. + + -- gregor herrmann <gregoa@debian.org> Sun, 14 Nov 2021 17:37:28 +0100 + libmojolicious-plugin-oauth2-perl (1.59-2) unstable; urgency=medium * Team upload. diff --git a/debian/control b/debian/control index a0a49fa..acd7d4c 100644 --- a/debian/control +++ b/debian/control @@ -5,10 +5,13 @@ Section: perl Testsuite: autopkgtest-pkg-perl Priority: optional Build-Depends: debhelper-compat (= 13) -Build-Depends-Indep: libio-socket-ssl-perl <!nocheck>, - libmojolicious-perl <!nocheck>, +Build-Depends-Indep: libcrypt-openssl-bignum-perl <!nocheck>, + libcrypt-openssl-rsa-perl <!nocheck>, + libio-socket-ssl-perl <!nocheck>, + libmojo-jwt-perl (>= 0.09) <!nocheck>, + libmojolicious-perl (>= 8.25) <!nocheck>, perl -Standards-Version: 4.5.1 +Standards-Version: 4.6.0 Vcs-Browser: https://salsa.debian.org/perl-team/modules/packages/libmojolicious-plugin-oauth2-perl Vcs-Git: https://salsa.debian.org/perl-team/modules/packages/libmojolicious-plugin-oauth2-perl.git Homepage: https://metacpan.org/release/Mojolicious-Plugin-OAuth2 @@ -19,7 +22,10 @@ Architecture: all Depends: ${misc:Depends}, ${perl:Depends}, libio-socket-ssl-perl, - libmojolicious-perl + libmojolicious-perl (>= 8.25) +Recommends: libcrypt-openssl-bignum-perl, + libcrypt-openssl-rsa-perl, + libmojo-jwt-perl (>= 0.09) Description: Auth against OAuth2 APIs Mojolicious::Plugin::OAuth2 allows you to easily authenticate against a OAuth2 provider. It includes configurations for a few popular providers, diff --git a/debian/patches/0001-add-salsa-as-an-OAuth2-id-provider.patch b/debian/patches/0001-add-salsa-as-an-OAuth2-id-provider.patch deleted file mode 100644 index 4d5e0a3..0000000 --- a/debian/patches/0001-add-salsa-as-an-OAuth2-id-provider.patch +++ /dev/null @@ -1,30 +0,0 @@ -From: Philip Hands <phil@hands.com> -Date: Thu, 4 Mar 2021 06:36:09 +0100 -Subject: add 'debian_salsa' as an OAuth2 id provider -Forwarded: https://github.com/marcusramberg/Mojolicious-Plugin-OAuth2/pull/62 -Bug: https://github.com/marcusramberg/Mojolicious-Plugin-OAuth2/pull/62 - ---- a/lib/Mojolicious/Plugin/OAuth2.pm -+++ b/lib/Mojolicious/Plugin/OAuth2.pm -@@ -14,6 +14,10 @@ - authorize_url => "https://api.dailymotion.com/oauth/authorize", - token_url => "https://api.dailymotion.com/oauth/token" - }, -+ debian_salsa => { -+ authorize_url => 'https://salsa.debian.org/oauth/authorize?response_type=code', -+ token_url => 'https://salsa.debian.org/oauth/token', -+ }, - eventbrite => { - authorize_url => 'https://www.eventbrite.com/oauth/authorize', - token_url => 'https://www.eventbrite.com/oauth/token', -@@ -284,6 +288,10 @@ - - Authentication for Dailymotion video site. - -+=item * debian_salsa -+ -+Authentication for L<https://salsa.debian.org/>. -+ - =item * eventbrite - - Authentication for L<https://www.eventbrite.com> event site. diff --git a/debian/patches/series b/debian/patches/series deleted file mode 100644 index e71ba90..0000000 --- a/debian/patches/series +++ /dev/null @@ -1 +0,0 @@ -0001-add-salsa-as-an-OAuth2-id-provider.patch diff --git a/lib/Mojolicious/Plugin/OAuth2.pm b/lib/Mojolicious/Plugin/OAuth2.pm index 88e397d..bab484f 100644 --- a/lib/Mojolicious/Plugin/OAuth2.pm +++ b/lib/Mojolicious/Plugin/OAuth2.pm @@ -1,18 +1,21 @@ package Mojolicious::Plugin::OAuth2; use Mojo::Base 'Mojolicious::Plugin'; +use Carp qw(croak); use Mojo::Promise; +use Mojo::URL; use Mojo::UserAgent; -use Carp 'croak'; -use strict; -our $VERSION = '1.59'; +use constant MOJO_JWT => eval 'use Mojo::JWT 0.09; use Crypt::OpenSSL::RSA; use Crypt::OpenSSL::Bignum; 1'; + +our @CARP_NOT = qw(Mojolicious::Plugin::OAuth2 Mojolicious::Renderer); +our $VERSION = '2.01'; has providers => sub { return { dailymotion => { - authorize_url => "https://api.dailymotion.com/oauth/authorize", - token_url => "https://api.dailymotion.com/oauth/token" + authorize_url => 'https://api.dailymotion.com/oauth/authorize', + token_url => 'https://api.dailymotion.com/oauth/token' }, debian_salsa => { authorize_url => 'https://salsa.debian.org/oauth/authorize?response_type=code', @@ -23,22 +26,22 @@ has providers => sub { token_url => 'https://www.eventbrite.com/oauth/token', }, facebook => { - authorize_url => "https://graph.facebook.com/oauth/authorize", - token_url => "https://graph.facebook.com/oauth/access_token", + authorize_url => 'https://graph.facebook.com/oauth/authorize', + token_url => 'https://graph.facebook.com/oauth/access_token', }, instagram => { - authorize_url => "https://api.instagram.com/oauth/authorize/?response_type=code", - token_url => "https://api.instagram.com/oauth/access_token", + authorize_url => 'https://api.instagram.com/oauth/authorize/?response_type=code', + token_url => 'https://api.instagram.com/oauth/access_token', }, github => { authorize_url => 'https://github.com/login/oauth/authorize', token_url => 'https://github.com/login/oauth/access_token', }, google => { - authorize_url => "https://accounts.google.com/o/oauth2/v2/auth?response_type=code", - token_url => "https://www.googleapis.com/oauth2/v4/token", + authorize_url => 'https://accounts.google.com/o/oauth2/v2/auth?response_type=code', + token_url => 'https://www.googleapis.com/oauth2/v4/token', }, - vkontakte => {authorize_url => "https://oauth.vk.com/authorize", token_url => "https://oauth.vk.com/access_token",}, + vkontakte => {authorize_url => 'https://oauth.vk.com/authorize', token_url => 'https://oauth.vk.com/access_token',}, mocked => {authorize_url => '/mocked/oauth/authorize', token_url => '/mocked/oauth/token', secret => 'fake_secret'}, }; }; @@ -49,46 +52,29 @@ sub register { my ($self, $app, $config) = @_; my $providers = $self->providers; - foreach my $provider (keys %$config) { - $providers->{$provider} ||= {}; - for my $key (keys %{$config->{$provider}}) { - $providers->{$provider}{$key} = $config->{$provider}{$key}; - } - } - - $app->helper('oauth2.auth_url' => sub { $self->_get_authorize_url($self->_args(@_)) }); - $app->helper('oauth2.providers' => sub { $self->providers }); - $app->helper('oauth2.get_token_p' => sub { $self->_get_token($self->_args(@_), Mojo::Promise->new) }); - $app->helper( - 'oauth2.get_token' => sub { - my $cb = ref $_[-1] eq 'CODE' ? pop : undef; - my ($c, $args) = $self->_args(@_); - - # Make sure we return Mojolicious::Controller and not Mojo::Promise - local $args->{return_controller} = 1; + $app->helper('oauth2.auth_url' => sub { $self->_call(_auth_url => @_) }); + $app->helper('oauth2.get_refresh_token_p' => sub { $self->_call(_get_refresh_token_p => @_) }); + $app->helper('oauth2.get_token_p' => sub { $self->_call(_get_token_p => @_) }); + $app->helper('oauth2.jwt_decode' => sub { $self->_call(_jwt_decode => @_) }); + $app->helper('oauth2.logout_url' => sub { $self->_call(_logout_url => @_) }); + $app->helper('oauth2.providers' => sub { $self->providers }); - # Blocking - return $self->_get_token($c, $args, undef) unless $cb; - - # Non-blocking - my $p = Mojo::Promise->new; - $p->then(sub { $c->$cb('', shift) })->catch(sub { $c->$cb(shift, undef) }); - return $self->_get_token($c, $args, $p); - } - ); - - $self->_mock_interface($app) if $providers->{mocked}{key}; + $self->_config_to_providers($config); + $self->_apply_mock($providers->{mocked}) if $providers->{mocked}{key}; + $self->_warmup_openid($app); } -sub _args { - my ($self, $c, $provider) = (shift, shift, shift); - my $args = @_ % 2 ? shift : {@_}; - $args->{provider} = $provider || 'unknown'; - croak "Invalid OAuth2 provider: $args->{provider}" unless $self->providers->{$args->{provider}}; - return $c, $args; +sub _apply_mock { + my ($self, $provider_args) = @_; + + require Mojolicious::Plugin::OAuth2::Mock; + require Mojolicious; + my $app = $self->_ua->server->app || Mojolicious->new; + Mojolicious::Plugin::OAuth2::Mock->apply_to($app, $provider_args); + $self->_ua->server->app($app); } -sub _get_authorize_url { +sub _auth_url { my ($self, $c, $args) = @_; my $provider_args = $self->providers->{$args->{provider}}; my $authorize_url; @@ -98,50 +84,100 @@ sub _get_authorize_url { $authorize_url = Mojo::URL->new($provider_args->{authorize_url}); $authorize_url->host($args->{host}) if exists $args->{host}; $authorize_url->query->append(client_id => $provider_args->{key}, redirect_uri => $args->{redirect_uri}); - $authorize_url->query->append(scope => $args->{scope}) if defined $args->{scope}; - $authorize_url->query->append(state => $args->{state}) if defined $args->{state}; + $authorize_url->query->append(scope => $args->{scope}) if defined $args->{scope}; + $authorize_url->query->append(state => $args->{state}) if defined $args->{state}; $authorize_url->query($args->{authorize_query}) if exists $args->{authorize_query}; $authorize_url; } -sub _get_token { - my ($self, $c, $args, $p) = @_; +sub _call { + my ($self, $method, $c, $provider) = (shift, shift, shift, shift); + my $args = @_ % 2 ? shift : {@_}; + $args->{provider} = $provider || 'unknown'; + croak "Invalid provider: $args->{provider}" unless $self->providers->{$args->{provider}}; + return $self->$method($c, $args); +} - # Handle error response from provider callback URL - if (my $err = $c->param('error_description') || $c->param('error')) { - die $err unless $p; # die on blocking - $p->reject($err); - return $args->{return_controller} ? $c : $p; +sub _config_to_providers { + my ($self, $config) = @_; + + for my $provider (keys %$config) { + my $p = $self->providers->{$provider} ||= {}; + for my $key (keys %{$config->{$provider}}) { + $p->{$key} = $config->{$provider}{$key}; + } } +} + +sub _get_refresh_token_p { + my ($self, $c, $args) = @_; + + # TODO: Handle error response from oidc provider callback URL, if possible + my $err = $c->param('error_description') || $c->param('error'); + return Mojo::Promise->reject($err) if $err; + + my $provider_args = $self->providers->{$args->{provider}}; + my $params = { + client_id => $provider_args->{key}, + client_secret => $provider_args->{secret}, + grant_type => 'refresh_token', + refresh_token => $args->{refresh_token}, + scope => $provider_args->{scope}, + }; + + my $token_url = Mojo::URL->new($provider_args->{token_url}); + $token_url->host($args->{host}) if exists $args->{host}; + + return $self->_ua->post_p($token_url, form => $params)->then(sub { $self->_parse_provider_response(@_) }); +} + +sub _get_token_p { + my ($self, $c, $args) = @_; + + # Handle error response from provider callback URL + my $err = $c->param('error_description') || $c->param('error'); + return Mojo::Promise->reject($err) if $err; # No error or code response from provider callback URL unless ($c->param('code')) { - $c->redirect_to($self->_get_authorize_url($c, $args)) if $args->{redirect} // 1; - return $p ? $p->resolve(undef) : undef; + $c->redirect_to($self->_auth_url($c, $args)) if $args->{redirect} // 1; + return Mojo::Promise->resolve(undef); } # Handle "code" from provider callback my $provider_args = $self->providers->{$args->{provider}}; my $params = { - client_secret => $provider_args->{secret}, client_id => $provider_args->{key}, + client_secret => $provider_args->{secret}, code => scalar($c->param('code')), grant_type => 'authorization_code', redirect_uri => $args->{redirect_uri} || $c->url_for->to_abs->to_string, }; + $params->{state} = $c->param('state') if $c->param('state'); + my $token_url = Mojo::URL->new($provider_args->{token_url}); $token_url->host($args->{host}) if exists $args->{host}; - $token_url = $token_url->to_abs; - if ($p) { - $self->_ua->post_p($token_url, form => $params)->then(sub { $p->resolve($self->_parse_provider_response(@_)) }) - ->catch(sub { $p->reject(@_) }); - return $args->{return_controller} ? $c : $p; - } - else { - return $self->_parse_provider_response($self->_ua->post($token_url, form => $params)); - } + return $self->_ua->post_p($token_url, form => $params)->then(sub { $self->_parse_provider_response(@_) }); +} + +sub _jwt_decode { + my $peek = ref $_[-1] eq 'CODE' && pop; + my ($self, $c, $args) = @_; + croak 'Provider does not have "jwt" defined.' unless my $jwt = $self->providers->{$args->{provider}}{jwt}; + return $jwt->decode($args->{data}, $peek); +} + +sub _logout_url { + my ($self, $c, $args) = @_; + return Mojo::URL->new($self->providers->{$args->{provider}}{end_session_url})->tap( + query => { + post_logout_redirect_uri => $args->{post_logout_redirect_uri}, + id_token_hint => $args->{id_token_hint}, + state => $args->{state} + } + ); } sub _parse_provider_response { @@ -149,245 +185,138 @@ sub _parse_provider_response { my $code = $tx->res->code || 'No response'; # Will cause the promise to be rejected - die sprintf '%s == %s', $tx->req->url, $tx->error->{message} // $code if $code ne '200'; - + return Mojo::Promise->reject(sprintf '%s == %s', $tx->req->url, $tx->error->{message} // $code) if $code ne '200'; return $tx->res->headers->content_type =~ m!^(application/json|text/javascript)(;\s*charset=\S+)?$! ? $tx->res->json : Mojo::Parameters->new($tx->res->body)->to_hash; } -sub _mock_interface { - my ($self, $app) = @_; - my $provider_args = $self->providers->{mocked}; +sub _warmup_openid { + my ($self, $app) = (shift, shift); - $self->_ua->server->app($app); + my ($providers, @p) = ($self->providers); + for my $provider (values %$providers) { + next unless $provider->{well_known_url}; + $app->log->debug("Fetching OpenID configuration from $provider->{well_known_url}"); + push @p, $self->_warmup_openid_provider_p($app, $provider); + } - $provider_args->{return_code} ||= 'fake_code'; - $provider_args->{return_token} ||= 'fake_token'; - - $app->routes->get( - $provider_args->{authorize_url} => sub { - my $c = shift; - if ($c->param('client_id') and $c->param('redirect_uri')) { - my $url = Mojo::URL->new($c->param('redirect_uri')); - $url->query->append(code => $provider_args->{return_code}); - $c->render(text => $c->tag('a', href => $url, sub {'Connect'})); - } - else { - $c->render(text => "Invalid request\n", status => 400); - } - } - ); + return @p && Mojo::Promise->all(@p)->wait; +} - $app->routes->post( - $provider_args->{token_url} => sub { - my $c = shift; - if ($c->param('client_secret') and $c->param('redirect_uri') and $c->param('code')) { - my $qp = Mojo::Parameters->new( - access_token => $provider_args->{return_token}, - expires_in => 3600, - refresh_token => Mojo::Util::md5_sum(rand), - scope => $provider_args->{scopes} || 'some list of scopes', - token_type => 'bearer', - ); - $c->render(text => $qp->to_string); - } - else { - $c->render(status => 404, text => 'FAIL OVERFLOW'); - } - } - ); +sub _warmup_openid_provider_p { + my ($self, $app, $provider) = @_; + + return $self->_ua->get_p($provider->{well_known_url})->then(sub { + my $tx = shift; + my $res = $tx->result->json; + $provider->{authorize_url} = $res->{authorization_endpoint}; + $provider->{end_session_url} = $res->{end_session_endpoint}; + $provider->{issuer} = $res->{issuer}; + $provider->{token_url} = $res->{token_endpoint}; + $provider->{userinfo_url} = $res->{userinfo_endpoint}; + $provider->{scope} //= 'openid'; + + return $self->_ua->get_p($res->{jwks_uri}); + })->then(sub { + my $tx = shift; + $provider->{jwt} = Mojo::JWT->new->add_jwkset($tx->result->json); + return $provider; + })->catch(sub { + my $err = shift; + $app->log->error("[OAuth2] Failed to warm up $provider->{well_known_url}: $err"); + }); } 1; =head1 NAME -Mojolicious::Plugin::OAuth2 - Auth against OAuth2 APIs - -=head1 DESCRIPTION - -This Mojolicious plugin allows you to easily authenticate against a -L<OAuth2|http://oauth.net> provider. It includes configurations for a few -popular providers, but you can add your own easily as well. - -Note that OAuth2 requires https, so you need to have the optional Mojolicious -dependency required to support it. Run the command below to check if -L<IO::Socket::SSL> is installed. - - $ mojo version - -=head2 References - -=over 4 - -=item * L<http://oauth.net/documentation/> - -=item * L<http://aaronparecki.com/articles/2012/07/29/1/oauth2-simplified> - -=item * L<http://homakov.blogspot.jp/2013/03/oauth1-oauth2-oauth.html> - -=item * L<http://en.wikipedia.org/wiki/OAuth#OAuth_2.0> - -=back +Mojolicious::Plugin::OAuth2 - Auth against OAuth2 APIs including OpenID Connect =head1 SYNOPSIS -=head2 Example non-blocking application +=head2 Example application use Mojolicious::Lite; - plugin "OAuth2" => { + plugin OAuth2 => { facebook => { - key => "some-public-app-id", + key => 'some-public-app-id', secret => $ENV{OAUTH2_FACEBOOK_SECRET}, }, }; - get "/connect" => sub { - my $c = shift; - my $get_token_args = {redirect_uri => $c->url_for("connect")->userinfo(undef)->to_abs}; + get '/connect' => sub { + my $c = shift; + my %get_token = (redirect_uri => $c->url_for('connect')->userinfo(undef)->to_abs); + + return $c->oauth2->get_token_p(facebook => \%get_token)->then(sub { + # Redirected to Facebook + return unless my $provider_res = shift; - $c->oauth2->get_token_p(facebook => $get_token_args)->then(sub { - return unless my $provider_res = shift; # Redirct to Facebook + # Token received $c->session(token => $provider_res->{access_token}); - $c->redirect_to("profile"); + $c->redirect_to('profile'); })->catch(sub { - $c->render("connect", error => shift); + $c->render('connect', error => shift); }); }; -=head2 Custom connect button - -You can add a "connect link" to your template using the L</oauth2.auth_url> -helper. Example template: - - Click here to log in: - <%= link_to "Connect!", $c->oauth2->auth_url("facebook", scope => "user_about_me email") %> - -=head2 Configuration - -This plugin takes a hash as config, where the keys are provider names and the -values are configuration for each provider. Here is a complete example: - - plugin "OAuth2" => { - custom_provider => { - key => "APP_ID", - secret => "SECRET_KEY", - authorize_url => "https://provider.example.com/auth", - token_url => "https://provider.example.com/token", - }, - }; - -To make it a bit easier, L<Mojolicious::Plugin::OAuth2> has already -values for C<authorize_url> and C<token_url> for the following providers: - -=over 4 - -=item * dailymotion - -Authentication for Dailymotion video site. - -=item * debian_salsa - -Authentication for L<https://salsa.debian.org/>. - -=item * eventbrite - -Authentication for L<https://www.eventbrite.com> event site. - -See also L<http://developer.eventbrite.com/docs/auth/>. - -=item * facebook - -OAuth2 for Facebook's graph API, L<http://graph.facebook.com/>. You can find -C<key> (App ID) and C<secret> (App Secret) from the app dashboard here: -L<https://developers.facebook.com/apps>. - -See also L<https://developers.facebook.com/docs/reference/dialogs/oauth/>. - -=item * instagram - -OAuth2 for Instagram API. You can find C<key> (Client ID) and -C<secret> (Client Secret) from the app dashboard here: -L<https://www.instagram.com/developer/clients/manage/>. - -See also L<https://www.instagram.com/developer/authentication/>. - -=item * github - -Authentication with Github. - -See also L<https://developer.github.com/v3/oauth/>. - -=item * google - -OAuth2 for Google. You can find the C<key> (CLIENT ID) and C<secret> -(CLIENT SECRET) from the app console here under "APIs & Auth" and -"Credentials" in the menu at L<https://console.developers.google.com/project>. - -See also L<https://developers.google.com/+/quickstart/>. - -=item * vkontakte - -OAuth2 for Vkontakte. You can find C<key> (App ID) and C<secret> -(Secure key) from the app dashboard here: L<https://vk.com/apps?act=manage>. - -See also L<https://vk.com/dev/authcode_flow_user>. - -=back +See L</register> for more details about the configuration this plugin takes. =head2 Testing -THIS API IS EXPERIMENTAL AND CAN CHANGE WITHOUT NOTICE. - -To enable a "mocked" OAuth2 api, you need to give the special "mocked" -provider a "key": - - plugin "OAuth2" => { mocked => {key => 42} }; - -The code above will add two new routes to your application: +Code using this plugin can perform offline testing, using the "mocked" +provider: -=over 4 - -=item * GET /mocked/oauth/authorize + $app->plugin(OAuth2 => {mocked => {key => 42}}); + $app->routes->get('/profile' => sub { + my $c = shift; -This route is a web page which contains a link that takes you back to -"redirect_uri", with a "code". The "code" default to "fake_code", but -can be configured: + state $mocked = $ENV{TEST_MOCKED} && 'mocked'; + return $c->oauth2->get_token_p($mocked || 'facebook')->then(sub { + ... + }); + }); - $c->app->oauth2->providers->{mocked}{return_code} = "..."; +See L<Mojolicious::Plugin::OAuth2::Mock> for more details. -The route it self can also be customized: +=head2 Connect button - plugin "OAuth2" => { mocked => {authorize_url => '...'} }; +You can add a "connect link" to your template using the L</oauth2.auth_url> +helper. Example template: -=item * POST /mocked/oauth/token + Click here to log in: + <%= link_to 'Connect!', $c->oauth2->auth_url('facebook', scope => 'user_about_me email') %> -This route is will return a "access_token" which is available in your -L</oauth2.get_token> callback. The default is "fake_token", but it can -be configured: +=head1 DESCRIPTION - $c->app->oauth2->providers->{mocked}{return_token} = "..."; +This Mojolicious plugin allows you to easily authenticate against a +L<OAuth2|http://oauth.net> or L<OpenID Connect|https://openid.net/connect/> +provider. It includes configurations for a few popular L<providers|/register>, +but you can add your own as well. -The route it self can also be customized: +See L</register> for a full list of bundled providers. - plugin "OAuth2" => { mocked => {token_url => '...'} }; +To support "OpenID Connect", the following optional modules must be installed +manually: L<Crypt::OpenSSL::Bignum>, L<Crypt::OpenSSL::RSA> and L<Mojo::JWT>. +The modules can be installed with L<App::cpanminus>: -=back + $ cpanm Crypt::OpenSSL::Bignum Crypt::OpenSSL::RSA Mojo::JWT =head1 HELPERS =head2 oauth2.auth_url - $url = $c->oauth2->auth_url($provider => \%args); + $url = $c->oauth2->auth_url($provider_name => \%args); Returns a L<Mojo::URL> object which contain the authorize URL. This is useful if you want to add the authorize URL as a link to your webpage instead of doing a redirect like L</oauth2.get_token> does. C<%args> is optional, but can contain: -=over 4 +=over 2 =item * host @@ -422,38 +351,43 @@ as a GET parameter called C<state> in the URL that the user will return to. =back -=head2 oauth2.get_token +=head2 oauth2.get_refresh_token_p - $data = $c->oauth2->get_token($provider_name => \%args); - $c = $c->oauth2->get_token($provider_name => \%args, sub { - my ($c, $err, $data) = @_; - # do stuff with $data->{access_token} if it exists. - }); + $promise = $c->oauth2->get_refresh_token_p($provider_name => \%args); -L</oauth2.get_token> is used to either fetch access token from OAuth2 provider, -handle errors or redirect to OAuth2 provider. This method can be called in either -blocking or non-blocking mode. C<$err> holds a error description if something -went wrong. Blocking mode will C<die($err)> instead of returning it to caller. -C<$data> is a hash-ref containing the access token from the OAauth2 provider. -C<$data> in blocking mode can also be C<undef> if a redirect has been issued -by this module. +When L<Mojolicious::Plugin::OAuth2> is being used in OpenID Connect mode this +helper allows for a token to be refreshed by specifying a C<refresh_token> in +C<%args>. Usage is similar to L</"oauth2.get_token_p">. + +=head2 oauth2.get_token_p + + $promise = $c->oauth2->get_token_p($provider_name => \%args) + ->then(sub { my $provider_res = shift }) + ->catch(sub { my $err = shift; }); + +L</oauth2.get_token_p> is used to either fetch an access token from an OAuth2 +provider, handle errors or redirect to OAuth2 provider. C<$err> in the +rejection handler holds a error description if something went wrong. +C<$provider_res> is a hash-ref containing the access token from the OAauth2 +provider or C<undef> if this plugin performed a 302 redirect to the provider's +connect website. In more detail, this method will do one of two things: -=over 4 +=over 2 =item 1. -If called from an action on your site, it will redirect you to the -C<$provider_name>'s C<authorize_url>. This site will probably have some -sort of "Connect" and "Reject" button, allowing the visitor to either -connect your site with his/her profile on the OAuth2 provider's page or not. +When called from an action on your site, it will redirect you to the provider's +C<authorize_url>. This site will probably have some sort of "Connect" and +"Reject" button, allowing the visitor to either connect your site with his/her +profile on the OAuth2 provider's page or not. =item 2. The OAuth2 provider will redirect the user back to your site after clicking the -"Connect" or "Reject" button. C<$data> will then contain a key "access_token" -on "Connect" and a false value (or die in blocking mode) on "Reject". +"Connect" or "Reject" button. C<$provider_res> will then contain a key +"access_token" on "Connect" and a false value on "Reject". =back @@ -463,7 +397,7 @@ when L<registering|/SYNOPSIS> the plugin. C<%args> can have: -=over 4 +=over 2 =item * host @@ -480,15 +414,28 @@ Scope to ask for credentials to. Should be a space separated list. =back -=head2 oauth2.get_token_p +=head2 oauth2.jwt_decode - $promise = $c->oauth2->get_token_p($provider_name => \%args); + $claims = $c->oauth2->jwt_decode($provider, sub { my $jwt = shift; ... }); + $claims = $c->oauth2->jwt_decode($provider); -Same as L</oauth2.get_token>, but returns a L<Mojo::Promise>. See L</SYNOPSIS> -for example usage. +When L<Mojolicious::Plugin::OAuth2> is being used in OpenID Connect mode this +helper allows you to decode the response data encoded with the JWKS discovered +from C<well_known_url> configuration. + +=head2 oauth2.logout_url + + $url = $c->oauth2->logout_url($provider_name => \%args); + +When L<Mojolicious::Plugin::OAuth2> is being used in OpenID Connect mode this +helper creates the url to redirect to end the session. The OpenID Connect +Provider will redirect to the C<post_logout_redirect_uri> provided in C<%args>. +Additional keys for C<%args> are C<id_token_hint> and C<state>. =head2 oauth2.providers + $hash_ref = $c->oauth2->providers; + This helper allow you to access the raw providers mapping, which looks something like this: @@ -506,13 +453,100 @@ something like this: =head2 providers + $hash_ref = $oauth2->providers; + Holds a hash of provider information. See L</oauth2.providers>. =head1 METHODS =head2 register -Will register this plugin in your application. See L</SYNOPSIS>. + $app->plugin(OAuth2 => \%provider_config); + +Will register this plugin in your application with a given C<%provider_config>. +The keys in C<%provider_config> are provider names and the values are +configuration for each provider. Note that the value will be merged with the +predefined providers below. + +Here is an example to add adddition information like "key" and "secret": + + $app->plugin(OAuth2 => { + custom_provider => { + key => 'APP_ID', + secret => 'SECRET_KEY', + authorize_url => 'https://provider.example.com/auth', + token_url => 'https://provider.example.com/token', + }, + github => { + key => 'APP_ID', + secret => 'SECRET_KEY', + }, + }); + +For L<OpenID Connect|https://openid.net/connect/>, C<authorize_url> and C<token_url> are configured from the +C<well_known_url> so these are replaced by the C<well_known_url> key. + + $app->plugin(OAuth2 => { + azure_ad => { + key => 'APP_ID', + secret => 'SECRET_KEY', + well_known_url => 'https://login.microsoftonline.com/tenant-id/v2.0/.well-known/openid-configuration', + }, + }); + +To make it a bit easier the are already some predefined providers bundled with +this plugin: + +=head3 dailymotion + +Authentication for L<https://www.dailymotion.com/> video site. + +=head3 debian_salsa + +Authentication for L<https://salsa.debian.org/>. + +=head3 eventbrite + +Authentication for L<https://www.eventbrite.com> event site. + +See also L<http://developer.eventbrite.com/docs/auth/>. + +=head3 facebook + +OAuth2 for Facebook's graph API, L<http://graph.facebook.com/>. You can find +C<key> (App ID) and C<secret> (App Secret) from the app dashboard here: +L<https://developers.facebook.com/apps>. + +See also L<https://developers.facebook.com/docs/reference/dialogs/oauth/>. + +=head3 instagram + +OAuth2 for Instagram API. You can find C<key> (Client ID) and +C<secret> (Client Secret) from the app dashboard here: +L<https://www.instagram.com/developer/clients/manage/>. + +See also L<https://www.instagram.com/developer/authentication/>. + +=head3 github + +Authentication with Github. + +See also L<https://developer.github.com/v3/oauth/>. + +=head3 google + +OAuth2 for Google. You can find the C<key> (CLIENT ID) and C<secret> +(CLIENT SECRET) from the app console here under "APIs & Auth" and +"Credentials" in the menu at L<https://console.developers.google.com/project>. + +See also L<https://developers.google.com/+/quickstart/>. + +=head3 vkontakte + +OAuth2 for Vkontakte. You can find C<key> (App ID) and C<secret> +(Secure key) from the app dashboard here: L<https://vk.com/apps?act=manage>. + +See also L<https://vk.com/dev/authcode_flow_user>. =head1 AUTHOR @@ -524,4 +558,20 @@ Jan Henning Thorsen - C<jhthorsen@cpan.org> This software is licensed under the same terms as Perl itself. +=head1 SEE ALSO + +=over 2 + +=item * L<http://oauth.net/documentation/> + +=item * L<http://aaronparecki.com/articles/2012/07/29/1/oauth2-simplified> + +=item * L<http://homakov.blogspot.jp/2013/03/oauth1-oauth2-oauth.html> + +=item * L<http://en.wikipedia.org/wiki/OAuth#OAuth_2.0> + +=item * L<https://openid.net/connect/> + +=back + =cut diff --git a/lib/Mojolicious/Plugin/OAuth2/Mock.pm b/lib/Mojolicious/Plugin/OAuth2/Mock.pm new file mode 100644 index 0000000..4f71c38 --- /dev/null +++ b/lib/Mojolicious/Plugin/OAuth2/Mock.pm @@ -0,0 +1,267 @@ +package Mojolicious::Plugin::OAuth2::Mock; +use Mojo::Base -base; + +require Mojolicious::Plugin::OAuth2; + +use constant DEBUG => $ENV{MOJO_OAUTH2_DEBUG} || 0; + +has provider => sub { + return { + authorization_endpoint_url => '/mocked/oauth2/authorize', + end_session_endpoint_url => '/mocked/oauth2/logout', + issuer_url => '/mocked/oauth2/v2.0', + jwks_url => '/mocked/oauth2/keys', + return_code => 'fake_code', + return_token => 'fake_token', + token_endpoint_url => '/mocked/oauth2/token', + }; +}; + +has _rsa => sub { require Crypt::OpenSSL::RSA; Crypt::OpenSSL::RSA->generate_key(2048) }; + +sub apply_to { + my $self = ref $_[0] ? shift : shift->SUPER::new; + my ($app, $provider) = @_; + + map { $self->provider->{$_} = $provider->{$_} } keys %$provider if $provider; + push @{$app->renderer->classes}, __PACKAGE__; + + # Add mocked routes for "authorize", "token", ... + for my $k (keys %{$self->provider}) { + next unless $k =~ m!^([a-z].+)_url$!; + my $method = "_action_$1"; + my $url = $self->provider->{$k}; + warn "[Oauth2::Mock] $url => $method()\n" if DEBUG; + $app->routes->any($url => sub { $self->$method(@_) }); + } +} + +sub _action_authorization_endpoint { + my ($self, $c) = @_; + + if ($c->param('response_mode') eq 'form_post') { + return $c->render( + template => 'oauth2/mock/form_post', + format => 'html', + code => "authorize-code", + redirect_uri => $c->param('redirect_uri'), + state => $c->param('state') + ); + } + + # $c->param('response_mode') eq 'query' + my $url = Mojo::URL->new($c->param('redirect_uri')); + $url->query({code => 'authorize-code', state => $c->param('state')}); + return $c->redirect_to($url); +} + +sub _action_authorize { + my ($self, $c) = @_; + + if ($c->param('client_id') and $c->param('redirect_uri')) { + my $url = Mojo::URL->new($c->param('redirect_uri')); + $url->query->append(code => $self->provider->{return_code}); + $c->render(text => $c->tag('a', href => $url, sub {'Connect'})); + } + else { + $c->render(text => "Invalid request\n", status => 400); + } +} + +sub _action_end_session_endpoint { + my ($self, $c) = @_; + my $rp_url = Mojo::URL->new($c->param('post_logout_redirect_uri')) + ->query({id_token_hint => $c->param('id_token_hint'), state => $c->param('state')}); + $c->redirect_to($rp_url); +} + +sub _action_issuer { + my ($self, $c) = @_; +} + +sub _action_jwks { + my ($self, $c) = @_; + + my ($n, $e) = $self->_rsa->get_key_parameters; + my $x5c = $self->_rsa->get_public_key_string; + $x5c =~ s/\n/\\n/g; + + require MIME::Base64; + return $c->render( + template => 'oauth2/mock/keys', + format => 'json', + n => MIME::Base64::encode_base64url($n->to_bin), + e => MIME::Base64::encode_base64url($e->to_bin), + x5c => $x5c, + issuer => $c->url_for($self->provider->{issuer_url})->to_abs, + ); +} + +sub _action_token { + my ($self, $c) = @_; + + return $c->render(text => 'FAIL OVERFLOW', status => 404) + unless 3 == grep { $c->param($_) } qw(client_secret redirect_uri code); + + $c->render( + text => Mojo::Parameters->new( + access_token => $self->provider->{return_token}, + expires_in => 3600, + refresh_token => Mojo::Util::md5_sum(rand), + scope => $self->provider->{scopes} || 'some list of scopes', + token_type => 'bearer', + )->to_string + ); +} + +sub _action_token_endpoint { + my ($self, $c) = @_; + return $c->render(json => {error => 'invalid_request'}, status => 500) + unless (($c->param('client_secret') and $c->param('redirect_uri') and $c->param('code')) + || ($c->param('grant_type') eq 'refresh_token' and $c->param('refresh_token'))); + + my $claims = { + aud => $c->param('client_id'), + email => 'foo.bar@example.com', + iss => $c->url_for($self->provider->{issuer_url})->to_abs, + name => 'foo bar', + preferred_username => 'foo.bar@example.com', + sub => 'foo.bar' + }; + + require Mojo::JWT; + my $id_token = Mojo::JWT->new( + algorithm => 'RS256', + secret => $self->_rsa->get_private_key_string, + set_iat => 1, + claims => $claims, + header => {kid => 'TEST_SIGNING_KEY'} + ); + + return $c->render( + template => 'oauth2/mock/token', + format => 'json', + id_token => $id_token->expires(Mojo::JWT->now + 3600)->encode, + refresh_token => $c->param('refresh_token') // 'refresh-token', + ); +} + +sub _action_well_known { + my ($self, $c) = @_; + my $provider = $self->provider; + my $req_url = $c->req->url->to_abs; + my $to_abs = sub { $req_url->path(Mojo::URL->new(shift)->path)->to_abs }; + + $c->render( + template => 'oauth2/mock/configuration', + format => 'json', + authorization_endpoint => $to_abs->($provider->{authorization_endpoint_url}), + end_session_endpoint => $to_abs->($provider->{end_session_endpoint_url}), + issuer => $to_abs->($provider->{issuer_url}), + jwks_uri => $to_abs->($provider->{jwks_url}), + token_endpoint => $to_abs->($provider->{token_endpoint_url}), + ); +} + +1; + +=encoding utf8 + +=head1 NAME + +Mojolicious::Plugin::OAuth2::Mock - Mock an Oauth2 and/or OpenID Connect provider + +=head1 SYNOPSIS + + use Mojolicious::Plugin::OAuth2::Mock; + use Mojolicious; + + my $app = Mojolicious->new; + Mojolicious::Plugin::OAuth2::Mock->apply_to($app); + +=head1 DESCRIPTION + +L<Mojolicious::Plugin::OAuth2::Mock> is an EXPERIMENTAL module to make it +easier to test your L<Mojolicious::Plugin::OAuth2> based code. + +=head1 METHODS + +=head2 apply_to + + Mojolicious::Plugin::OAuth2::Mock->apply_to($app, \%provider_args); + $mock->apply_to($app, \%provider_args); + +Used to add mocked routes to a L<Mojolicious> application, based on all the +keys in C<%provider_args> that end with "_url". Example: + + + * authorize_url => /mocked/oauth/authorize + * authorization_endpoint_url => /mocked/oauth2/authorize + * end_session_endpoint_url => /mocked/oauth2/logout + * issuer_url => /mocked/oauth2/v2.0 + * jwks_url => /mocked/oauth2/keys + * token_url => /mocked/oauth/token + * token_endpoint_url => /mocked/oauth2/token + +=head1 SEE ALSO + +L<Mojolicious::Plugin::OAuth2>. + +=cut + +__DATA__ +@@ oauth2/mock/configuration.json.ep +{ + "authorization_endpoint":"<%= $authorization_endpoint %>", + "claims_supported":["sub","iss","aud","exp","iat","auth_time","acr","nonce","name","ver","at_hash","c_hash","email"], + "end_session_endpoint":"<%= $end_session_endpoint %>", + "id_token_signing_alg_values_supported":["RS256"], + "issuer":"<%= $issuer %>", + "jwks_uri":"<%= $jwks_uri %>", + "request_uri_parameter_supported":0, + "response_modes_supported":["query","fragment","form_post"], + "response_types_supported":["code","id_token","code id_token","id_token token"], + "scopes_supported":["openid","profile","email","offline_access"], + "subject_types_supported":["pairwise"], + "token_endpoint":"<%= $token_endpoint %>", + "token_endpoint_auth_methods_supported":["client_secret_post","private_key_jwt","client_secret_basic"] +} +@@ oauth2/mock/keys.json.ep +{ + "keys":[{ + "e":"<%= $e %>", + "issuer":"<%= $issuer %>", + "kid":"TEST_SIGNING_KEY", + "kty":"RSA", + "n":"<%= $n %>", + "use":"sig", + "x5c":"<%= $x5c %>", + "x5t":"TEST_SIGNING_KEY" + }] +} +@@ oauth2/mock/token.json.ep + { + "access_token":"access", + "expires_in":3599, + "ext_expires_in":3599, + "id_token":"<%= $id_token %>", + "refresh_token":"<%= $refresh_token %>", + "scope":"openid", + "token_type":"Bearer" +} +@@ oauth2/mock/form_post.html.ep +<html><head><title>In progress...</title></head> +<body> + <form method="POST" name="hiddenform" action="<%= $redirect_uri %>"> + <input type="hidden" name="code" value="<%= $code %>"/> + <input type="hidden" name="state" value="<%= $state %>"/> + <noscript> + <p>Script is disabled. Click Submit to continue.</p> + <input type="submit" value="Submit"/> + </noscript> + </form> + <script language="javascript"> + document.forms[0].submit(); + </script> +</body> +</html> diff --git a/t/auth_url.t b/t/auth_url.t index 234cf90..6b0fc42 100644 --- a/t/auth_url.t +++ b/t/auth_url.t @@ -11,7 +11,7 @@ get '/test123', sub { $_[0]->render(text => $_[0]->oauth2->auth_url('facebook', my $t = Test::Mojo->new; eval { $t->app->oauth2->auth_url }; -like $@, qr{Invalid OAuth2 provider}, 'provider_id is required'; +like $@, qr{Invalid provider}, 'provider_id is required'; $t->get_ok('/test123')->status_is(200); my $url = Mojo::URL->new($t->tx->res->body); diff --git a/t/delayed.t b/t/delayed.t index 83f562d..9c35389 100644 --- a/t/delayed.t +++ b/t/delayed.t @@ -10,11 +10,20 @@ $app->routes->get( '/oauth-delayed' => sub { my $c = shift; - $c->oauth2->get_token(test => sub { - my (undef, $err, $provider_res) = @_; - return $c->render(text => $err) unless $provider_res; - return $c->render(text => "Token $provider_res->{access_token}"); - }); + $c->oauth2->get_token( + test => sub { + my (undef, $err, $provider_res) = @_; + return $c->render(text => $err) unless $provider_res; + return $c->render(text => "Token $provider_res->{access_token}"); + } + ); + } +); + +$app->helper( + 'oauth2.get_token' => sub { + my ($c, $args, $cb) = @_; + $c->oauth2->get_token_p($args)->then(sub { $c->$cb('', shift) }, sub { $c->$cb(shift, {}) }); } ); @@ -10,23 +10,23 @@ use Test::More; get '/no-redirect' => sub { my $c = shift; - $c->oauth2->get_token_p('mocked', {redirect => 0})->then( - sub { - return $c->render(text => 'No token') unless my $provider_res = shift; # Redirect - return $c->render(text => "Token $provider_res->{access_token}"); - } - )->catch(sub { $c->reply->exception(shift) }); + return $c->oauth2->get_token_p('mocked', {redirect => 0})->then(sub { + return $c->render(text => 'No token') unless my $provider_res = shift; # Redirect + return $c->render(text => "Token $provider_res->{access_token}"); + })->catch(sub { + return $c->render(text => shift, status => 500); + }); }; get '/profile' => sub { my $c = shift; - $c->oauth2->get_token_p('mocked')->then( - sub { - return unless my $provider_res = shift; # Redirect - return $c->render(text => "Token $provider_res->{access_token}"); - } - )->catch(sub { $c->reply->exception(shift) }); + return $c->oauth2->get_token_p('mocked')->then(sub { + return unless my $provider_res = shift; # Redirect + return $c->render(text => "Token $provider_res->{access_token}"); + })->catch(sub { + return $c->render(text => shift, status => 500); + }); }; } @@ -43,7 +43,7 @@ is($res->path, $callback_url->path, 'Returns to the right place' is($res->query->param('code'), 'fake_code', 'Includes fake code'); $t->get_ok($res)->status_is(200)->content_is('Token fake_token'); -$t->get_ok('/profile?error=access_denied')->status_is(500)->content_like(qr{>access_denied<}); +$t->get_ok('/profile?error=access_denied')->status_is(500)->content_is('access_denied'); $t->get_ok('/no-redirect')->status_is(200)->content_like(qr{No token}); diff --git a/t/mocked_blocking.t b/t/mocked_blocking.t deleted file mode 100644 index 88b2762..0000000 --- a/t/mocked_blocking.t +++ /dev/null @@ -1,46 +0,0 @@ -use Mojo::Base -strict; -use Mojolicious; -use Test::Mojo; -use Test::More; - -{ - use Mojolicious::Lite; - plugin OAuth2 => {mocked => {key => '42'}}; - get '/test123' => sub { - my $c = shift; - - my $data = eval { $c->oauth2->get_token('mocked') }; - if (my $e = $@) { - if ($e =~ /^access_denied/) { - return $c->render(text => $c->param('error'), status => 500); - } - else { - die $e; - } - } - elsif ($data) { - return $c->render(text => "Token $data->{access_token}"); - } - else { - return; - } - }; -} - -my $t = Test::Mojo->new; - -$t->get_ok('/test123')->status_is(302); # ->content_like(qr/bar/); -my $location = Mojo::URL->new($t->tx->res->headers->location); -my $callback_url = Mojo::URL->new($location->query->param('redirect_uri')); -is($location->query->param('client_id'), '42', 'got client_id'); - -$t->get_ok($location)->status_is(200)->element_exists('a'); -my $res = Mojo::URL->new($t->tx->res->dom->at('a')->{href}); -is($res->path, $callback_url->path, 'Returns to the right place'); -is($res->query->param('code'), 'fake_code', 'Includes fake code'); - -$t->get_ok($res)->status_is(200)->content_is('Token fake_token'); -$t->get_ok('/test123?error=access_denied')->status_is(500)->content_is('access_denied'); - -done_testing; - diff --git a/t/openid-connect.t b/t/openid-connect.t new file mode 100644 index 0000000..3cec985 --- /dev/null +++ b/t/openid-connect.t @@ -0,0 +1,195 @@ +use Mojo::Base -strict; +use Test::More; +use Test::Mojo; +use MIME::Base64 qw(encode_base64url); +use Mojo::JSON qw(decode_json encode_json); +use Mojo::URL; +use Mojolicious::Plugin::OAuth2; + +plan skip_all => "Mojo::JWT, Crypt::OpenSSL::RSA and Crypt::OpenSSL::Bignum required for openid tests" + unless Mojolicious::Plugin::OAuth2::MOJO_JWT; + +use Mojolicious::Lite; + +plugin OAuth2 => {mocked => + {key => 'c0e71b99-2c66-42e7-8589-6502153a7e3', well_known_url => '/mocked/oauth2/.well-known/configuration'}}; + +get '/' => sub { shift->render('index') }; + +any '/connect' => sub { + my $c = shift; + $c->render_later; + + my $get_token_args = { + redirect_uri => $c->req->url->to_abs, + authorize_query => { + response_mode => $ENV{'OAUTH2_MOCK_RESPONSE_MODE'} // 'form_post', + response_type => 'code', + state => $c->param('oauth2.state') // 'test' + } + }; + + $c->oauth2->get_token_p(mocked => $get_token_args)->then(sub { + return unless my $provider_res = shift; # Redirect to IdP + $c->session(token => $provider_res->{access_token}, refresh_token => $provider_res->{refresh_token}); + my $user = $c->oauth2->jwt_decode(mocked => data => $provider_res->{id_token}); + $c->signed_cookie(id_token => $provider_res->{id_token}); + return $c->redirect_to($c->param('state')) if $c->param('state') ne 'test'; + $c->render(json => $user); + })->catch(sub { + $c->render(text => "Error $_[0]", status => 500); + }); +}; + +# exercise end_session_endpoint +get '/end_session' => sub { + my $c = shift; + my $home = $c->req->url->base->clone->tap(path => '/'); + + # require id_token to calculate logout_url + return $c->redirect_to($home) unless my $id_token = $c->signed_cookie('id_token'); + my $end_session_url = $c->oauth2->logout_url( + mocked => {post_logout_redirect_uri => $c->req->url->to_abs, id_token_hint => $id_token, state => time}); + + return $c->redirect_to($end_session_url) unless $c->param('id_token_hint') and my $state = $c->param('state'); + $c->signed_cookie('id_token' => $id_token, {expires => time - 1}); + delete $c->session->{$_} for (qw(token refresh_token)); + return $c->redirect_to($home); +}; + +# refresh access token using refresh_token +get '/refresh' => sub { + my $c = shift; + $c->render_later; + $c->oauth2->get_refresh_token_p(mocked => {refresh_token => $c->session('refresh_token') . '+'})->then(sub { + my $res = shift; + $c->session(refresh_token => $res->{refresh_token}); + $c->render(json => $res); + })->catch(sub { $c->render(text => "Error $_[0]", status => 500); }); +}; + +group { + under '/protect' => sub { + my $c = shift; + Mojo::IOLoop->timer( + 0.1 => sub { + $c->redirect_to($c->url_for('connect')->query({'oauth2.state' => $c->req->url})); + } + ); + return undef unless $c->session('token'); + return 1; + }; + + get '/next' => sub { shift->render(text => 'ok') }; +}; + +my $t = Test::Mojo->new; + +subtest 'warmup of provider data' => sub { + my $provider_conf = $t->app->oauth2->providers->{mocked}; + + is $provider_conf->{scope}, 'openid', 'scope'; + is $provider_conf->{userinfo_url}, undef, 'userinfo_url'; + ok $provider_conf->{jwt}, 'resolved from configuration'; + ok +Mojo::URL->new($provider_conf->{$_})->scheme, $_ for qw(authorize_url end_session_url issuer token_url); +}; + +subtest 'Authorize and obtain token - form_post response_mode' => sub { + $t->get_ok('/connect')->status_is(302); + my $location = Mojo::URL->new($t->tx->res->headers->location); + is $location->query->param('scope'), 'openid', 'scope set'; + is $location->query->param('response_mode'), 'form_post', 'response mode set'; + + my ($action, $form); + $t->get_ok($location)->status_is(200)->tap(sub { + my $dom = shift->tx->res->dom; + $action = $dom->at('form')->attr('action'); + $form + = {code => $dom->at('input[name=code]')->attr('value'), state => $dom->at('input[name=state]')->attr('value')}; + ok +Mojo::URL->new($action)->is_abs, 'absolute url'; + }); + $t->post_ok($action, form => $form)->status_is(200)->json_is('/aud' => 'c0e71b99-2c66-42e7-8589-6502153a7e3') + ->json_is('/email' => 'foo.bar@example.com') + ->json_is('/iss' => $t->app->oauth2->providers->{mocked}{issuer}, 'OIDC valid (MUST)') + ->json_is('/name' => 'foo bar')->json_is('/preferred_username' => 'foo.bar@example.com') + ->json_is('/sub' => 'foo.bar')->json_has('/iat')->json_has('/exp'); +}; + +subtest 'Refresh token' => sub { + $t->get_ok('/refresh')->status_is(200)->json_is('/refresh_token', 'refresh-token+'); + $t->get_ok('/refresh')->status_is(200)->json_is('/refresh_token', 'refresh-token++'); + $t->get_ok('/refresh?error=bad')->status_is(500)->content_is('Error bad'); +}; + +subtest 'Authorize and obtain token - query response_mode' => sub { + local $ENV{OAUTH2_MOCK_RESPONSE_MODE} = 'query'; + local $t->app->oauth2->providers->{mocked}{scope} = 'openid email profile'; + $t->get_ok('/connect')->status_is(302); + my $location = Mojo::URL->new($t->tx->res->headers->location); + is $location->query->param('scope'), 'openid email profile', 'scope set'; + is $location->query->param('response_mode'), 'query', 'response mode set'; + is $location->query->param('state'), 'test', 'state propagates'; + + my ($action, $form); + $t->get_ok($location)->status_is(302); + $location = Mojo::URL->new($t->tx->res->headers->location); + is $location->path, '/connect', 'redirect_uri'; + is $location->query->param('code'), 'authorize-code', 'code set'; + is $location->query->param('state'), 'test', 'state returned'; + $t->get_ok("$location")->status_is(200)->json_is('/aud' => 'c0e71b99-2c66-42e7-8589-6502153a7e3') + ->json_is('/email' => 'foo.bar@example.com') + ->json_is('/iss' => $t->app->oauth2->providers->{mocked}{issuer}, 'OIDC valid (MUST)') + ->json_is('/name' => 'foo bar')->json_is('/preferred_username' => 'foo.bar@example.com') + ->json_is('/sub' => 'foo.bar')->json_has('/iat')->json_has('/exp'); +}; + +subtest 'Logout' => sub { + my $end_session_url = $t->ua->server->url->clone->tap(path => '/end_session'); + + # obtain signed cookie from user agent + my $c = $t->app->build_controller->tap(sub { $_->tx->req->cookies(@{$t->ua->cookie_jar->all}) }); + my $id_token = $c->signed_cookie('id_token'); + ok $id_token, 'Have a current id token'; + + $t->get_ok($end_session_url)->status_is(302); + my $op_location = Mojo::URL->new($t->tx->res->headers->location); + is $op_location->path, '/mocked/oauth2/logout', 'correct'; + is $op_location->query->param('id_token_hint'), $id_token, 'correct id token'; + is $op_location->query->param('post_logout_redirect_uri'), $end_session_url, 'post_logout_redirect_uri set'; + is $op_location->query->param('state'), time, 'state set'; + + $t->get_ok($op_location)->status_is(302); + my $rp_location = Mojo::URL->new($t->tx->res->headers->location); + is $rp_location->path, '/end_session', 'correct'; + is $rp_location->query->param('id_token_hint'), $id_token, 'correct id token'; + is $rp_location->query->param('state'), time, 'state set'; + + $t->get_ok($rp_location)->status_is(302); + my $logged_out = Mojo::URL->new($t->tx->res->headers->location); + is $logged_out->path, '/', 'home'; + my @cookies = grep { $_->name eq 'id_token' } @{$t->ua->cookie_jar->find($end_session_url) || []}; + is_deeply \@cookies, [], 'removed'; + + $t->get_ok($end_session_url)->status_is(302); + $logged_out = Mojo::URL->new($t->tx->res->headers->location); + is $logged_out->path, '/', 'home'; +}; + +subtest 'Redirects with under' => sub { + local $ENV{OAUTH2_MOCK_RESPONSE_MODE} = 'query'; + my $max = $t->ua->max_redirects; + my $url = $t->ua->server->url->clone->tap(path => '/protect/next'); + $t->reset_session->ua->max_redirects($max + 5); + $t->get_ok('/protect/next')->status_is(200)->content_is('ok'); + is_deeply [map { $_->name } grep { $_->name eq 'id_token' } @{$t->ua->cookie_jar->find($url) || []}], ['id_token'], + 'set cookie'; + is_deeply [map { $_->req->url->path } @{$t->tx->redirects}], + [qw(/protect/next /connect /mocked/oauth2/authorize /connect)], 'login chain'; + $t->ua->max_redirects($max); +}; + +done_testing; + +__DATA__ +@@ index.html.ep +%= link_to 'Connect', $c->url_for('connect'); |