summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/linux.yml38
-rw-r--r--.github/workflows/macos.yml21
-rw-r--r--.perltidyrc2
-rw-r--r--.pls_cache/indexbin0 -> 8316 bytes
-rw-r--r--.travis.yml14
-rw-r--r--Changes16
-rw-r--r--MANIFEST7
-rw-r--r--META.json15
-rw-r--r--META.yml12
-rw-r--r--Makefile.PL11
-rw-r--r--README.md326
-rw-r--r--cpanfile10
-rw-r--r--debian/changelog10
-rw-r--r--debian/control14
-rw-r--r--debian/patches/0001-add-salsa-as-an-OAuth2-id-provider.patch30
-rw-r--r--debian/patches/series1
-rw-r--r--lib/Mojolicious/Plugin/OAuth2.pm604
-rw-r--r--lib/Mojolicious/Plugin/OAuth2/Mock.pm267
-rw-r--r--t/auth_url.t2
-rw-r--r--t/delayed.t19
-rw-r--r--t/mocked.t26
-rw-r--r--t/mocked_blocking.t46
-rw-r--r--t/openid-connect.t195
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
new file mode 100644
index 0000000..2962189
--- /dev/null
+++ b/.pls_cache/index
Binary files differ
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
diff --git a/Changes b/Changes
index a2bb1d1..ce87af4 100644
--- a/Changes
+++ b/Changes
@@ -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
diff --git a/MANIFEST b/MANIFEST
index f5d7ba1..964391c 100644
--- a/MANIFEST
+++ b/MANIFEST
@@ -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)
diff --git a/META.json b/META.json
index 6840c32..1f30002 100644
--- a/META.json
+++ b/META.json
@@ -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"
}
diff --git a/META.yml b/META.yml
index 1b5837a..8cd7bd7 100644
--- a/META.yml
+++ b/META.yml
@@ -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',
diff --git a/README.md b/README.md
index 9cb0744..b766a5b 100644
--- a/README.md
+++ b/README.md
@@ -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/)
diff --git a/cpanfile b/cpanfile
index c3752b7..a1f01c9 100644
--- a/cpanfile
+++ b/cpanfile
@@ -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, {}) });
}
);
diff --git a/t/mocked.t b/t/mocked.t
index 57a4439..31a4c30 100644
--- a/t/mocked.t
+++ b/t/mocked.t
@@ -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');