├── README.pod ├── TODO ├── t ├── 000_prerequisites.t ├── 003_changes.t ├── 025_no_config.t ├── 011_secrets.t ├── 001_compiles_pod.t ├── 010_basic.t ├── 060_jwt_with_revoke.t ├── 100_implicit_grant_basic.t ├── 120_client_credentials_grant_basic.t ├── 125_client_credentials_body.t ├── 080_password_grant_basic.t ├── 040_full_fat_app.t ├── 140_gh_16_client_credentials_force_bad_client.t ├── 150_authorization_request_helper.t ├── 090_password_grant_overrides.t ├── 160_gh20_oauth_return.t ├── 110_implicit_grant_overrides.t ├── 130_client_credentials_grant_overrides.t ├── 180_error_description.t ├── 170_expiry_ttl_callback.t ├── 035_login_confirm_error.t ├── 070_overrides_hash_args.t ├── 050_jwt.t ├── 030_expiry_config.t ├── 015_overrides.t ├── 051_jwt_refresh.t └── AllTests.pm ├── .gitignore ├── .travis.yml ├── MANIFEST ├── Makefile.PL ├── README.md ├── Changes └── lib └── Mojolicious └── Plugin └── OAuth2 └── Server.pm /README.pod: -------------------------------------------------------------------------------- 1 | lib/Mojolicious/Plugin/OAuth2/Server.pm -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | - signing access tokens 2 | - configure to only function as Auth Server or Resource Server 3 | -------------------------------------------------------------------------------- /t/000_prerequisites.t: -------------------------------------------------------------------------------- 1 | #!perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Test::More; 7 | 8 | pass( "No prerequisites" ); 9 | 10 | done_testing(); 11 | -------------------------------------------------------------------------------- /t/003_changes.t: -------------------------------------------------------------------------------- 1 | #!perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Test::More; 7 | 8 | eval 'use Test::CPAN::Changes'; 9 | 10 | plan skip_all => 'Test::CPAN::Changes required for this test' if $@; 11 | 12 | changes_ok(); 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *META.* 2 | *~ 3 | MANIFEST.* 4 | MYMETA.json 5 | MYMETA.yml 6 | Makefile 7 | Makefile.old 8 | Mojolicious-Plugin-OAuth2-Server*.gz 9 | blib 10 | cover_db 11 | pm_to_blib 12 | examples/data/ 13 | 14 | # editing 15 | *.vim 16 | vim.session* 17 | .vscode 18 | .vstags 19 | .idea 20 | \#*\# 21 | *~ 22 | *.swp 23 | 24 | # OS X nonsense 25 | *.DS_Store 26 | -------------------------------------------------------------------------------- /t/025_no_config.t: -------------------------------------------------------------------------------- 1 | #!perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Mojolicious::Lite; 7 | use Test::More; 8 | use Test::Exception; 9 | 10 | throws_ok( 11 | sub { plugin 'OAuth2::Server' => {}; }, 12 | qr/requires either clients or overrides/, 13 | 'plugin with no config croaks', 14 | ); 15 | 16 | done_testing(); 17 | 18 | # vim: ts=2:sw=2:et 19 | -------------------------------------------------------------------------------- /t/011_secrets.t: -------------------------------------------------------------------------------- 1 | #!perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Mojolicious::Lite; 7 | use Test::More; 8 | use Test::Mojo; 9 | use Encode qw( encode ); 10 | use Mojo::Util qw( b64_encode ); 11 | 12 | # Based on VSCHAR definition in RFC 6749 13 | my $secret = encode('UTF-8', join('', map { chr } 0x20 .. 0x7e)); 14 | 15 | MOJO_APP: { 16 | # plugin configuration 17 | plugin 'OAuth2::Server' => { 18 | clients => { 19 | boo => { 20 | client_secret => $secret, 21 | scopes => {}, 22 | }, 23 | }, 24 | }; 25 | }; 26 | 27 | my $t = Test::Mojo->new; 28 | $t->post_ok( 29 | "/oauth/access_token", 30 | { Authorization => "Basic @{[ b64_encode(qq{boo:$secret}, '') ]}" }, 31 | form => { grant_type => 'client_credentials' } 32 | )->status_is(200); 33 | 34 | done_testing; 35 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: perl 2 | perl: 3 | - "5.24" 4 | - "5.22" 5 | - "5.20" 6 | - "5.18" 7 | - "5.16" 8 | - "5.14" 9 | - "5.12" 10 | - "5.10" 11 | 12 | before_install: 13 | - git clone git://github.com/haarg/perl-travis-helper 14 | - source perl-travis-helper/init 15 | - build-perl 16 | - perl -V 17 | - build-dist 18 | - cd $BUILD_DIR 19 | 20 | install: 21 | - export RELEASE_TESTING=1 AUTOMATED_TESTING=1 AUTHOR_TESTING=1 HARNESS_OPTIONS=j1:c HARNESS_TIMER=1 22 | - cpanm --quiet --notest Devel::Cover::Report::Coveralls 23 | - cpanm --quiet --notest --installdeps . 24 | 25 | script: 26 | - PERL5OPT=-MDevel::Cover=-ignore,"t/",+ignore,"prove",-coverage,statement,branch,condition,path,subroutine prove -lrs t 27 | - cover 28 | 29 | after_success: 30 | - cover -report coveralls 31 | 32 | notifications: 33 | email: false 34 | -------------------------------------------------------------------------------- /MANIFEST: -------------------------------------------------------------------------------- 1 | Changes 2 | Makefile.PL 3 | README.md 4 | README.pod 5 | lib/Mojolicious/Plugin/OAuth2/Server.pm 6 | MANIFEST 7 | t/000_prerequisites.t 8 | t/001_compiles_pod.t 9 | t/003_changes.t 10 | t/010_basic.t 11 | t/015_overrides.t 12 | t/025_no_config.t 13 | t/030_expiry_config.t 14 | t/035_login_confirm_error.t 15 | t/040_full_fat_app.t 16 | t/050_jwt.t 17 | t/051_jwt_refresh.t 18 | t/060_jwt_with_revoke.t 19 | t/070_overrides_hash_args.t 20 | t/080_password_grant_basic.t 21 | t/090_password_grant_overrides.t 22 | t/100_implicit_grant_basic.t 23 | t/110_implicit_grant_overrides.t 24 | t/120_client_credentials_grant_basic.t 25 | t/125_client_credentials_body.t 26 | t/130_client_credentials_grant_overrides.t 27 | t/140_gh_16_client_credentials_force_bad_client.t 28 | t/150_authorization_request_helper.t 29 | t/160_gh20_oauth_return.t 30 | t/170_expiry_ttl_callback.t 31 | t/180_error_description.t 32 | t/AllTests.pm 33 | -------------------------------------------------------------------------------- /t/001_compiles_pod.t: -------------------------------------------------------------------------------- 1 | #!perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Test::More; 7 | use File::Find; 8 | 9 | if(($ENV{HARNESS_PERL_SWITCHES} || '') =~ /Devel::Cover/) { 10 | plan skip_all => 'HARNESS_PERL_SWITCHES =~ /Devel::Cover/'; 11 | } 12 | if(!eval 'use Test::Pod; 1') { 13 | *Test::Pod::pod_file_ok = sub { SKIP: { skip "pod_file_ok(@_) (Test::Pod is required)", 1 } }; 14 | } 15 | if(!eval 'use Test::Pod::Coverage; 1') { 16 | *Test::Pod::Coverage::pod_coverage_ok = sub { SKIP: { skip "pod_coverage_ok(@_) (Test::Pod::Coverage is required)", 1 } }; 17 | } 18 | 19 | my @files; 20 | 21 | find( 22 | { 23 | wanted => sub { /\.pm$/ and push @files, $File::Find::name }, 24 | no_chdir => 1 25 | }, 26 | -e 'blib' ? 'blib' : 'lib', 27 | ); 28 | 29 | plan tests => @files * 3; 30 | 31 | for my $file (@files) { 32 | my $module = $file; $module =~ s,\.pm$,,; $module =~ s,.*/?lib/,,; $module =~ s,/,::,g; 33 | ok eval "use $module; 1", "use $module" or diag $@; 34 | Test::Pod::pod_file_ok($file); 35 | Test::Pod::Coverage::pod_coverage_ok($module,{ 36 | also_private => [ qr/^[A-Z]+$/ ], 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /t/010_basic.t: -------------------------------------------------------------------------------- 1 | #!perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Mojolicious::Lite; 7 | use Test::More; 8 | use FindBin qw/ $Bin /; 9 | use lib $Bin; 10 | use AllTests; 11 | 12 | MOJO_APP: { 13 | # plugin configuration 14 | plugin 'OAuth2::Server' => { 15 | clients => { 16 | 1 => { 17 | # client secret implies authorization code grant 18 | client_secret => 'boo', 19 | scopes => { 20 | eat => 1, 21 | drink => 0, 22 | sleep => 1, 23 | }, 24 | }, 25 | }, 26 | }; 27 | 28 | group { 29 | # /api - must be authorized 30 | under '/api' => sub { 31 | my ( $c ) = @_; 32 | return 1 if $c->oauth && $c->oauth->{client_id}; 33 | $c->render( status => 401, text => 'Unauthorized' ); 34 | return undef; 35 | }; 36 | 37 | get '/eat' => sub { shift->render( text => "food"); }; 38 | }; 39 | 40 | # /sleep - must be authorized and have sleep scope 41 | get '/api/sleep' => sub { 42 | my ( $c ) = @_; 43 | $c->oauth( 'sleep' ) 44 | || return $c->render( status => 401, text => 'You cannot sleep' ); 45 | 46 | $c->render( text => "bed" ); 47 | }; 48 | }; 49 | 50 | AllTests::run({}); 51 | 52 | done_testing(); 53 | 54 | # vim: ts=2:sw=2:et 55 | -------------------------------------------------------------------------------- /t/060_jwt_with_revoke.t: -------------------------------------------------------------------------------- 1 | #!perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Mojolicious::Lite; 7 | use Test::More; 8 | use FindBin qw/ $Bin /; 9 | use lib $Bin; 10 | use AllTests; 11 | 12 | MOJO_APP: { 13 | # plugin configuration 14 | plugin 'OAuth2::Server' => { 15 | jwt_secret => 'prince edward island potatoes', 16 | clients => { 17 | 1 => { 18 | client_secret => 'boo', 19 | scopes => { 20 | eat => 1, 21 | drink => 0, 22 | sleep => 1, 23 | }, 24 | }, 25 | }, 26 | }; 27 | 28 | group { 29 | # /api - must be authorized 30 | under '/api' => sub { 31 | my ( $c ) = @_; 32 | return 1 if $c->oauth && $c->oauth->{client_id}; 33 | $c->render( status => 401, text => 'Unauthorized' ); 34 | return undef; 35 | }; 36 | 37 | get '/eat' => sub { shift->render( text => "food"); }; 38 | }; 39 | 40 | # /sleep - must be authorized and have sleep scope 41 | get '/api/sleep' => sub { 42 | my ( $c ) = @_; 43 | $c->oauth( 'sleep' ) 44 | || return $c->render( status => 401, text => 'You cannot sleep' ); 45 | 46 | $c->render( text => "bed" ); 47 | }; 48 | }; 49 | 50 | AllTests::run({ 51 | skip_revoke_tests => 1, 52 | }); 53 | 54 | done_testing(); 55 | 56 | # vim: ts=2:sw=2:et 57 | -------------------------------------------------------------------------------- /Makefile.PL: -------------------------------------------------------------------------------- 1 | #!perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use ExtUtils::MakeMaker; 7 | 8 | WriteMakefile( 9 | NAME => 'Mojolicious::Plugin::OAuth2::Server', 10 | ABSTRACT_FROM => 'lib/Mojolicious/Plugin/OAuth2/Server.pm', 11 | VERSION_FROM => 'lib/Mojolicious/Plugin/OAuth2/Server.pm', 12 | AUTHOR => 'Lee Johnson ', 13 | LICENSE => 'perl', 14 | PREREQ_PM => { 15 | 'Mojolicious' => '7.76', 16 | 'Net::OAuth2::AuthorizationServer' => '0.26', 17 | 'Carp' => 0, 18 | }, 19 | BUILD_REQUIRES => { 20 | 'FindBin' => 0, 21 | 'File::Find' => 0, 22 | 'Test::More' => 0, 23 | 'Test::Deep' => 0.113, 24 | 'Test::Mojo' => 0, 25 | 'Test::Exception' => 0.32, 26 | 'Mojo::JWT' => 0.08, 27 | }, 28 | META_MERGE => { 29 | requires => { 30 | perl => '5.016' 31 | }, 32 | resources => { 33 | license => 'http://dev.perl.org/licenses/', 34 | homepage => 'https://metacpan.org/module/Mojolicious::Plugin::OAuth2::Server', 35 | bugtracker => 'https://github.com/Humanstate/mojolicious-plugin-oauth2-server/issues', 36 | repository => 'https://github.com/Humanstate/mojolicious-plugin-oauth2-server' 37 | }, 38 | }, 39 | test => { 40 | TESTS => 't/*.t', 41 | }, 42 | ); 43 | 44 | # vim: ts=4:sw=4:et 45 | -------------------------------------------------------------------------------- /t/100_implicit_grant_basic.t: -------------------------------------------------------------------------------- 1 | #!perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Mojolicious::Lite; 7 | use Test::More; 8 | use FindBin qw/ $Bin /; 9 | use lib $Bin; 10 | use AllTests; 11 | 12 | MOJO_APP: { 13 | # plugin configuration 14 | plugin 'OAuth2::Server' => { 15 | jwt_secret => 'foo', 16 | clients => { 17 | 1 => { 18 | redirect_uri => 'https://client/cb', 19 | scopes => { 20 | eat => 1, 21 | drink => 0, 22 | sleep => 1, 23 | }, 24 | }, 25 | }, 26 | }; 27 | 28 | group { 29 | # /api - must be authorized 30 | under '/api' => sub { 31 | my ( $c ) = @_; 32 | return 1 if $c->oauth && $c->oauth->{client_id}; 33 | $c->render( status => 401, text => 'Unauthorized' ); 34 | return undef; 35 | }; 36 | 37 | get '/eat' => sub { shift->render( text => "food"); }; 38 | }; 39 | 40 | # /sleep - must be authorized and have sleep scope 41 | get '/api/sleep' => sub { 42 | my ( $c ) = @_; 43 | $c->oauth( 'sleep' ) 44 | || return $c->render( status => 401, text => 'You cannot sleep' ); 45 | 46 | $c->render( text => "bed" ); 47 | }; 48 | }; 49 | 50 | AllTests::run({ 51 | grant_type => 'token', 52 | skip_revoke_tests => 1, # there is no auth code 53 | }); 54 | 55 | done_testing(); 56 | 57 | # vim: ts=2:sw=2:et 58 | -------------------------------------------------------------------------------- /t/120_client_credentials_grant_basic.t: -------------------------------------------------------------------------------- 1 | #!perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Mojolicious::Lite; 7 | use Test::More; 8 | use FindBin qw/ $Bin /; 9 | use lib $Bin; 10 | use AllTests; 11 | 12 | MOJO_APP: { 13 | # plugin configuration 14 | plugin 'OAuth2::Server' => { 15 | jwt_secret => 'foo', 16 | clients => { 17 | 1 => { 18 | client_secret => 'boo', 19 | scopes => { 20 | eat => 1, 21 | drink => 0, 22 | sleep => 1, 23 | }, 24 | }, 25 | }, 26 | }; 27 | 28 | group { 29 | # /api - must be authorized 30 | under '/api' => sub { 31 | my ( $c ) = @_; 32 | return 1 if $c->oauth && $c->oauth->{client_id}; 33 | $c->render( status => 401, text => 'Unauthorized' ); 34 | return undef; 35 | }; 36 | 37 | get '/eat' => sub { shift->render( text => "food"); }; 38 | }; 39 | 40 | # /sleep - must be authorized and have sleep scope 41 | get '/api/sleep' => sub { 42 | my ( $c ) = @_; 43 | $c->oauth( 'sleep' ) 44 | || return $c->render( status => 401, text => 'You cannot sleep' ); 45 | 46 | $c->render( text => "bed" ); 47 | }; 48 | }; 49 | 50 | AllTests::run({ 51 | grant_type => 'client_credentials', 52 | skip_revoke_tests => 1, # there is no auth code 53 | }); 54 | 55 | done_testing(); 56 | 57 | # vim: ts=2:sw=2:et 58 | -------------------------------------------------------------------------------- /t/125_client_credentials_body.t: -------------------------------------------------------------------------------- 1 | #!perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Mojolicious::Lite; 7 | use Test::More; 8 | use FindBin qw/ $Bin /; 9 | use lib $Bin; 10 | use AllTests; 11 | 12 | MOJO_APP: { 13 | # plugin configuration 14 | plugin 'OAuth2::Server' => { 15 | jwt_secret => 'foo', 16 | clients => { 17 | 1 => { 18 | client_secret => 'boo', 19 | scopes => { 20 | eat => 1, 21 | drink => 0, 22 | sleep => 1, 23 | }, 24 | }, 25 | }, 26 | }; 27 | 28 | group { 29 | # /api - must be authorized 30 | under '/api' => sub { 31 | my ( $c ) = @_; 32 | return 1 if $c->oauth && $c->oauth->{client_id}; 33 | $c->render( status => 401, text => 'Unauthorized' ); 34 | return undef; 35 | }; 36 | 37 | get '/eat' => sub { shift->render( text => "food"); }; 38 | }; 39 | 40 | # /sleep - must be authorized and have sleep scope 41 | get '/api/sleep' => sub { 42 | my ( $c ) = @_; 43 | $c->oauth( 'sleep' ) 44 | || return $c->render( status => 401, text => 'You cannot sleep' ); 45 | 46 | $c->render( text => "bed" ); 47 | }; 48 | }; 49 | 50 | AllTests::run({ 51 | grant_type => 'client_credentials_body', 52 | skip_revoke_tests => 1, # there is no auth code 53 | }); 54 | 55 | done_testing(); 56 | 57 | # vim: ts=2:sw=2:et 58 | -------------------------------------------------------------------------------- /t/080_password_grant_basic.t: -------------------------------------------------------------------------------- 1 | #!perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Mojolicious::Lite; 7 | use Test::More; 8 | use FindBin qw/ $Bin /; 9 | use lib $Bin; 10 | use AllTests; 11 | 12 | MOJO_APP: { 13 | # plugin configuration 14 | plugin 'OAuth2::Server' => { 15 | jwt_secret => 'foo', 16 | clients => { 17 | 1 => { 18 | client_secret => 'boo', 19 | scopes => { 20 | eat => 1, 21 | drink => 0, 22 | sleep => 1, 23 | }, 24 | }, 25 | }, 26 | users => { 27 | bob => 'hey_ho!', 28 | } 29 | }; 30 | 31 | group { 32 | # /api - must be authorized 33 | under '/api' => sub { 34 | my ( $c ) = @_; 35 | return 1 if $c->oauth && $c->oauth->{client_id}; 36 | $c->render( status => 401, text => 'Unauthorized' ); 37 | return undef; 38 | }; 39 | 40 | get '/eat' => sub { shift->render( text => "food"); }; 41 | }; 42 | 43 | # /sleep - must be authorized and have sleep scope 44 | get '/api/sleep' => sub { 45 | my ( $c ) = @_; 46 | $c->oauth( 'sleep' ) 47 | || return $c->render( status => 401, text => 'You cannot sleep' ); 48 | 49 | $c->render( text => "bed" ); 50 | }; 51 | }; 52 | 53 | AllTests::run({ 54 | grant_type => 'password', 55 | skip_revoke_tests => 1, # there is no auth code 56 | }); 57 | 58 | done_testing(); 59 | 60 | # vim: ts=2:sw=2:et 61 | -------------------------------------------------------------------------------- /t/040_full_fat_app.t: -------------------------------------------------------------------------------- 1 | #!perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | package FullFatOAuth; 7 | 8 | use Mojo::Base qw( Mojolicious ); 9 | 10 | sub startup { 11 | my ( $self ) = @_; 12 | 13 | $self->plugin( 14 | 'OAuth2::Server' => { 15 | 'verify_client' => sub { return ( 1 ) }, 16 | 'login_resource_owner' => sub { 17 | my ( %args ) = @_; 18 | my $c = $args{mojo_controller}; 19 | my $uri = join( '?',$c->url_for('current'),$c->url_with->query ); 20 | $c->flash( 'redirect_after_login' => $uri ); 21 | $c->redirect_to( '/oauth/login' ); 22 | return 0; 23 | }, 24 | } 25 | ); 26 | 27 | $self->routes->any('/oauth/login') 28 | ->to('Public#login'); 29 | } 30 | 31 | package FullFatOAuth::Public; 32 | 33 | use Mojo::Base 'Mojolicious::Controller'; 34 | 35 | sub login { 36 | my ( $self ) = @_; 37 | $self->render( text => 'login: ' . $self->flash( 'redirect_after_login' ) ); 38 | } 39 | 40 | 41 | package main; 42 | 43 | use strict; 44 | use warnings; 45 | 46 | use Test::Mojo; 47 | use Test::More; 48 | use Mojolicious::Commands; 49 | 50 | my $t = Test::Mojo->new( 'FullFatOAuth' ); 51 | $t->ua->max_redirects( 2 ); 52 | 53 | note( "flash in plugin accesible to controller" ); 54 | $t->get_ok( '/oauth/authorize?client_id=1&response_type=code&redirect_uri=foo' ) 55 | ->status_is( 200 ) 56 | ->content_is( 'login: /oauth/authorize?client_id=1&response_type=code&redirect_uri=foo' ) 57 | ; 58 | 59 | done_testing(); 60 | -------------------------------------------------------------------------------- /t/140_gh_16_client_credentials_force_bad_client.t: -------------------------------------------------------------------------------- 1 | #!perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Mojolicious::Lite; 7 | use Test::More; 8 | use FindBin qw/ $Bin /; 9 | use Mojo::Util qw/ b64_encode /; 10 | use lib $Bin; 11 | use AllTests; 12 | 13 | my $verify_client_sub = sub { 14 | return ( 0,'unauthorized_client' ); 15 | }; 16 | 17 | my $token_route = '/o/token'; 18 | 19 | MOJO_APP: { 20 | # plugin configuration 21 | plugin 'OAuth2::Server' => { 22 | args_as_hash => 0, 23 | access_token_route => $token_route, 24 | verify_client => $verify_client_sub, 25 | jwt_secret => 'eio', 26 | jwt_claims => sub { return ( iss => "https://localhost:5001" ) }, 27 | }; 28 | 29 | group { 30 | # /api - must be authorized 31 | under '/api' => sub { 32 | my ( $c ) = @_; 33 | return 1 if $c->oauth && $c->oauth->{client_id}; 34 | $c->render( status => 401, text => 'Unauthorized' ); 35 | return undef; 36 | }; 37 | 38 | get '/eat' => sub { shift->render( text => "food"); }; 39 | }; 40 | 41 | # /sleep - must be authorized and have sleep scope 42 | get '/api/sleep' => sub { 43 | my ( $c ) = @_; 44 | $c->oauth( 'sleep' ) 45 | || return $c->render( status => 401, text => 'You cannot sleep' ); 46 | 47 | $c->render( text => "bed" ); 48 | }; 49 | }; 50 | 51 | my $t = AllTests::run({ 52 | access_token_route => $token_route, 53 | grant_type => 'client_credentials', 54 | skip_revoke_tests => 1, # there is no auth code 55 | no_200_responses => 1, 56 | }); 57 | 58 | 59 | # posting 60 | $t->post_ok( 61 | $token_route => { 62 | Authorization => ( "Basic " . b64_encode( join( ':',2,'wrong' ),'' ) ) 63 | } => form => { 64 | grant_type => 'client_credentials', 65 | } 66 | ) 67 | ->status_isnt( 200 ) 68 | ; 69 | 70 | is( 71 | $t->tx->res->json->{error}, 72 | 'invalid_request', 73 | 'trying to get token gives error' 74 | ); 75 | 76 | done_testing(); 77 | 78 | # vim: ts=2:sw=2:et 79 | -------------------------------------------------------------------------------- /t/150_authorization_request_helper.t: -------------------------------------------------------------------------------- 1 | #!perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Mojolicious::Lite; 7 | use Mojo::Util qw/ b64_encode url_unescape /; 8 | use Test::More; 9 | use Test::Mojo; 10 | use Mojo::URL; 11 | use Mojo::JWT; 12 | 13 | MOJO_APP: { 14 | # plugin configuration 15 | plugin 'OAuth2::Server' => { 16 | args_as_hash => 0, 17 | authorize_route => '/o/auth', 18 | verify_client => sub { return ( 1,undef ) }, 19 | jwt_secret => 'WEEEE_SECRET', 20 | }; 21 | 22 | get '/foo' => sub { 23 | my ( $c ) = @_; 24 | 25 | my $redirect_uri = $c->oauth2_auth_request({ 26 | client_id => 'Foo', 27 | redirect_uri => 'foo://wee', 28 | response_type => 'token', 29 | user_id => 'LEEJO', 30 | }); 31 | 32 | $c->render( text => $redirect_uri ); 33 | }; 34 | 35 | group { 36 | # /api - must be authorized 37 | under '/api' => sub { 38 | my ( $c ) = @_; 39 | return 1 if $c->oauth && $c->oauth->{client_id}; 40 | $c->render( status => 401, text => 'Unauthorized' ); 41 | return undef; 42 | }; 43 | 44 | get '/eat' => sub { shift->render( text => "food"); }; 45 | }; 46 | 47 | # /sleep - must be authorized and have sleep scope 48 | get '/api/sleep' => sub { 49 | my ( $c ) = @_; 50 | $c->oauth( 'sleep' ) 51 | || return $c->render( status => 401, text => 'You cannot sleep' ); 52 | 53 | $c->render( text => "bed" ); 54 | }; 55 | }; 56 | 57 | my $t = Test::Mojo->new; 58 | $t->get_ok( '/foo' ) 59 | ->status_is( 200 ) 60 | ->content_like( qr!^foo://wee#access_token=([^&]*)&token_type=bearer&expires_in=3600$! ) 61 | ; 62 | 63 | my $url = Mojo::URL->new( $t->tx->res->content->get_body_chunk ); 64 | 65 | my $fragment = $url->fragment; 66 | ok( my ( $access_token ) = ( $fragment =~ qr/access_token=([^&]*)/ ),'includes token' ); 67 | $access_token = url_unescape( $access_token ); 68 | 69 | my $json = Mojo::JWT->new( secret => 'WEEEE_SECRET' ) 70 | ->decode( $access_token ); 71 | 72 | is( $json->{user_id},'LEEJO','user_id passed through to access token' ); 73 | 74 | note( "don't use access token to access route" ); 75 | $t->get_ok('/api/eat')->status_is( 401 ); 76 | $t->get_ok('/api/sleep')->status_is( 401 ); 77 | 78 | note( "use access token to access route" ); 79 | 80 | $t->ua->on(start => sub { 81 | my ( $ua,$tx ) = @_; 82 | $tx->req->headers->header( 'Authorization' => "Bearer $access_token" ); 83 | }); 84 | 85 | $t->get_ok('/api/eat')->status_is( 200 ); 86 | $t->get_ok('/api/sleep')->status_is( 401 ); 87 | 88 | done_testing(); 89 | 90 | # vim: ts=2:sw=2:et 91 | -------------------------------------------------------------------------------- /t/090_password_grant_overrides.t: -------------------------------------------------------------------------------- 1 | #!perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Mojolicious::Lite; 7 | use Test::More; 8 | use FindBin qw/ $Bin /; 9 | use lib $Bin; 10 | use AllTests; 11 | 12 | my $verify_user_password_sub = sub { 13 | my ( %args ) = @_; 14 | 15 | my ( $client_id,$client_secret,$username,$password,$scopes ) 16 | = @args{qw/ client_id client_secret username password scopes /}; 17 | 18 | return ( 0,'unauthorized_client' ) 19 | if ( $client_id ne '1' || $client_secret ne 'boo' ); 20 | 21 | return ( $client_id,undef,$username,$scopes ); 22 | }; 23 | 24 | my $VALID_ACCESS_TOKEN; 25 | my $VALID_REFRESH_TOKEN; 26 | 27 | my $store_access_token_sub = sub { 28 | my ( %args ) = @_; 29 | 30 | $VALID_ACCESS_TOKEN = $args{access_token}; 31 | $VALID_REFRESH_TOKEN = $args{refresh_token}; 32 | 33 | # again, store stuff in the database 34 | return; 35 | }; 36 | 37 | my $verify_access_token_sub = sub { 38 | my ( %args ) = @_; 39 | 40 | # and here we should check the access code is valid, not expired, and the 41 | # passed scopes are allowed for the access token 42 | return 1 if $args{is_refresh_token} and $args{access_token} eq $VALID_REFRESH_TOKEN; 43 | return 0 if grep { $_ eq 'sleep' } @{ $args{scopes} // [] }; 44 | 45 | # this will only ever allow one access token - for the purposes of testing 46 | # that when a refresh token is used the previous access token is revoked 47 | return 0 if $args{access_token} ne $VALID_ACCESS_TOKEN; 48 | 49 | my $client_id = 1; 50 | 51 | return { client_id => $client_id }; 52 | }; 53 | 54 | MOJO_APP: { 55 | # plugin configuration 56 | plugin 'OAuth2::Server' => { 57 | args_as_hash => 1, 58 | authorize_route => '/o/auth', 59 | access_token_route => '/o/token', 60 | verify_user_password => $verify_user_password_sub, 61 | verify_client => sub {}, # no-op as using password grant 62 | store_access_token => $store_access_token_sub, 63 | verify_access_token => $verify_access_token_sub, 64 | }; 65 | 66 | group { 67 | # /api - must be authorized 68 | under '/api' => sub { 69 | my ( $c ) = @_; 70 | return 1 if $c->oauth && $c->oauth->{client_id}; 71 | $c->render( status => 401, text => 'Unauthorized' ); 72 | return undef; 73 | }; 74 | 75 | get '/eat' => sub { shift->render( text => "food"); }; 76 | }; 77 | 78 | # /sleep - must be authorized and have sleep scope 79 | get '/api/sleep' => sub { 80 | my ( $c ) = @_; 81 | $c->oauth( 'sleep' ) 82 | || return $c->render( status => 401, text => 'You cannot sleep' ); 83 | 84 | $c->render( text => "bed" ); 85 | }; 86 | }; 87 | 88 | AllTests::run({ 89 | authorize_route => '/o/auth', 90 | access_token_route => '/o/token', 91 | grant_type => 'password', 92 | skip_revoke_tests => 1, # there is no auth code 93 | }); 94 | 95 | done_testing(); 96 | 97 | # vim: ts=2:sw=2:et 98 | -------------------------------------------------------------------------------- /t/160_gh20_oauth_return.t: -------------------------------------------------------------------------------- 1 | #!perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | package OAuthCheckReturn; 7 | 8 | use Mojo::Base qw( Mojolicious ); 9 | use Data::Dumper; 10 | 11 | my %auth_config = ( 12 | clients => { 13 | 'contributor' => { 14 | client_secret => 'clientsecret', 15 | scopes => { 16 | 'can_write' => 1, 17 | }, 18 | }, 19 | }, 20 | users => { 21 | test_user => 'test_password', 22 | }, 23 | jwt_secret => 'jwtsecret', 24 | ); 25 | 26 | sub startup { 27 | my $self = shift; 28 | 29 | # Router 30 | my $r = $self->routes; 31 | 32 | # Auth 33 | my $private = $r->under('/private' => sub { 34 | my $c = shift; 35 | 36 | my $log = $c->app->log; 37 | my $path = $c->req->url->path; 38 | 39 | $log->info( "REQ.HEADERS:\n" . $c->req->headers->to_string ); 40 | 41 | if ( my $oauth_details = $c->oauth ) { 42 | $log->info( "OAUTH: " . Dumper( $oauth_details ) ); 43 | return 1; 44 | } 45 | else { 46 | # not authenticated 47 | $log->info( "NOT_AUTHENTICATED: path=$path" ); 48 | $c->render( status => 401, json => { message => "Sorry, the endpoint '$path' requires authorization" } ); 49 | } 50 | 51 | return; 52 | } 53 | ); 54 | 55 | $private->get('authenticated_as' => sub { 56 | my $c = shift; 57 | my $oauth_details = $c->oauth || 58 | return $c->render( status => 401, json => { message => "no auth" } ); 59 | $c->render( json => { message => "Yay!", username => $oauth_details->{user_id} } ); 60 | }); 61 | 62 | $self->plugin("OAuth2::Server" => \%auth_config ); 63 | } 64 | 65 | package main; 66 | 67 | use strict; 68 | use warnings; 69 | 70 | use Test::Mojo; 71 | use Test::More; 72 | use Mojolicious::Commands; 73 | 74 | my $t = Test::Mojo->new( 'OAuthCheckReturn' ); 75 | 76 | my %token_params = ( 77 | client_id => 'contributor', 78 | client_secret => 'clientsecret', 79 | grant_type => 'password', 80 | scope => ['can_write'], 81 | ); 82 | 83 | # not authorized 84 | $t->get_ok('/private/authenticated_as') 85 | ->status_is( 401 ) 86 | ->json_is( { message => "Sorry, the endpoint '/private/authenticated_as' requires authorization" } ); 87 | 88 | my %params = ( %token_params, username => 'test_user', password => 'test_password' ); 89 | 90 | # get auth token 91 | $t->post_ok('/oauth/access_token', form => \%params) 92 | ->status_is( 200 ); 93 | my $access_token = $t->tx->res->json->{access_token} // ''; 94 | 95 | # set auth token 96 | $t->ua->on(start => sub { 97 | my ($ua, $tx) = @_; 98 | $tx->req->headers->header( 'Authorization' => "Bearer $access_token" ); 99 | }); 100 | 101 | # auth should work 102 | # [Thu Mar 29 18:52:01 2018] [error] Can't use string ("contributor") as a HASH ref while "strict refs" in use at t/160_gh20_oauth_return.t line 50. 103 | $t->get_ok('/private/authenticated_as') 104 | ->status_is( 200 ) 105 | ->json_is( { message => 'Yay!', username => 'test_user' } ); 106 | 107 | done_testing(); 108 | -------------------------------------------------------------------------------- /t/110_implicit_grant_overrides.t: -------------------------------------------------------------------------------- 1 | #!perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Mojolicious::Lite; 7 | use Test::More; 8 | use FindBin qw/ $Bin /; 9 | use lib $Bin; 10 | use AllTests; 11 | 12 | my $VALID_ACCESS_TOKEN; 13 | 14 | my $verify_client_sub = sub { 15 | my ( %args ) = @_; 16 | 17 | my ( $c,$client_id,$scopes_ref,$redirect_uri,$response_type ) 18 | = @args{ qw/ mojo_controller client_id scopes redirect_uri response_type / }; 19 | 20 | ok( $c,'have a mojo_controller' ); 21 | 22 | # in reality we would check a config file / the database to confirm the 23 | # client_id and client_secret match and that the scopes are valid 24 | return ( 0,'invalid_scope' ) if grep { $_ eq 'cry' } @{ $scopes_ref // [] }; 25 | return ( 0,'access_denied' ) if grep { $_ eq 'drink' } @{ $scopes_ref // [] }; 26 | return ( 0,'unauthorized_client' ) if $client_id ne '1'; 27 | 28 | # all good 29 | return ( 1,undef ); 30 | }; 31 | 32 | 33 | my $store_access_token_sub = sub { 34 | my ( %args ) = @_; 35 | $VALID_ACCESS_TOKEN = $args{access_token}; 36 | 37 | # again, store stuff in the database 38 | return; 39 | }; 40 | 41 | my $verify_access_token_sub = sub { 42 | my ( %args ) = @_; 43 | 44 | my ( $c,$access_token,$scopes,$is_refresh_token ) 45 | = @args{qw/ mojo_controller access_token scopes is_refresh_token /}; 46 | 47 | ok( $c,'have a mojo_controller' ); 48 | 49 | # and here we should check the access code is valid, not expired, and the 50 | # passed scopes are allowed for the access token 51 | return 0 if grep { $_ eq 'sleep' } @{ $scopes // [] }; 52 | 53 | # this will only ever allow one access token - for the purposes of testing 54 | # that when a refresh token is used the previous access token is revoked 55 | return 0 if $access_token ne $VALID_ACCESS_TOKEN; 56 | 57 | my $client_id = 1; 58 | 59 | return { client_id => $client_id }; 60 | }; 61 | 62 | MOJO_APP: { 63 | # plugin configuration 64 | plugin 'OAuth2::Server' => { 65 | args_as_hash => 0, 66 | authorize_route => '/o/auth', 67 | verify_client => $verify_client_sub, 68 | store_access_token => $store_access_token_sub, 69 | verify_access_token => $verify_access_token_sub, 70 | }; 71 | 72 | group { 73 | # /api - must be authorized 74 | under '/api' => sub { 75 | my ( $c ) = @_; 76 | return 1 if $c->oauth && $c->oauth->{client_id}; 77 | $c->render( status => 401, text => 'Unauthorized' ); 78 | return undef; 79 | }; 80 | 81 | get '/eat' => sub { shift->render( text => "food"); }; 82 | }; 83 | 84 | # /sleep - must be authorized and have sleep scope 85 | get '/api/sleep' => sub { 86 | my ( $c ) = @_; 87 | $c->oauth( 'sleep' ) 88 | || return $c->render( status => 401, text => 'You cannot sleep' ); 89 | 90 | $c->render( text => "bed" ); 91 | }; 92 | }; 93 | 94 | AllTests::run({ 95 | authorize_route => '/o/auth', 96 | grant_type => 'token', 97 | skip_revoke_tests => 1, # there is no auth code 98 | }); 99 | 100 | done_testing(); 101 | 102 | # vim: ts=2:sw=2:et 103 | -------------------------------------------------------------------------------- /t/130_client_credentials_grant_overrides.t: -------------------------------------------------------------------------------- 1 | #!perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Mojolicious::Lite; 7 | use Test::More; 8 | use FindBin qw/ $Bin /; 9 | use lib $Bin; 10 | use AllTests; 11 | 12 | my $VALID_ACCESS_TOKEN; 13 | 14 | my $verify_client_sub = sub { 15 | my ( %args ) = @_; 16 | 17 | my ( $c,$client_id,$client_secret,$scopes_ref,$redirect_uri,$response_type ) 18 | = @args{ qw/ mojo_controller client_id client_secret scopes redirect_uri response_type / }; 19 | 20 | ok( $c,'have a mojo_controller' ); 21 | 22 | # in reality we would check a config file / the database to confirm the 23 | # client_id and client_secret match and that the scopes are valid 24 | return ( 0,'invalid_scope' ) if grep { $_ eq 'cry' } @{ $scopes_ref // [] }; 25 | return ( 0,'access_denied' ) if grep { $_ eq 'drink' } @{ $scopes_ref // [] }; 26 | return ( 0,'unauthorized_client' ) if $client_id ne '1'; 27 | return ( 0,'unauthorized_client' ) if $client_secret ne 'boo'; 28 | 29 | # all good 30 | return ( 1,undef ); 31 | }; 32 | 33 | 34 | my $store_access_token_sub = sub { 35 | my ( %args ) = @_; 36 | $VALID_ACCESS_TOKEN = $args{access_token}; 37 | 38 | # again, store stuff in the database 39 | return; 40 | }; 41 | 42 | my $verify_access_token_sub = sub { 43 | my ( %args ) = @_; 44 | 45 | my ( $c,$access_token,$scopes_ref,$is_refresh_token ) 46 | = @args{qw/ mojo_controller access_token scopes is_refresh_token /}; 47 | 48 | ok( $c,'have a mojo_controller' ); 49 | 50 | # and here we should check the access code is valid, not expired, and the 51 | # passed scopes are allowed for the access token 52 | if ( @{ $scopes_ref // [] } ) { 53 | return 0 if grep { $_ eq 'sleep' } @{ $scopes_ref // [] }; 54 | } 55 | 56 | # this will only ever allow one access token - for the purposes of testing 57 | # that when a refresh token is used the previous access token is revoked 58 | return 0 if $access_token ne $VALID_ACCESS_TOKEN; 59 | 60 | my $client_id = 1; 61 | 62 | return { client_id => $client_id }; 63 | }; 64 | 65 | MOJO_APP: { 66 | # plugin configuration 67 | plugin 'OAuth2::Server' => { 68 | args_as_hash => 0, 69 | access_token_route => '/o/token', 70 | verify_client => $verify_client_sub, 71 | store_access_token => $store_access_token_sub, 72 | verify_access_token => $verify_access_token_sub, 73 | }; 74 | 75 | group { 76 | # /api - must be authorized 77 | under '/api' => sub { 78 | my ( $c ) = @_; 79 | return 1 if $c->oauth && $c->oauth->{client_id}; 80 | $c->render( status => 401, text => 'Unauthorized' ); 81 | return undef; 82 | }; 83 | 84 | get '/eat' => sub { shift->render( text => "food"); }; 85 | }; 86 | 87 | # /sleep - must be authorized and have sleep scope 88 | get '/api/sleep' => sub { 89 | my ( $c ) = @_; 90 | $c->oauth( 'sleep' ) 91 | || return $c->render( status => 401, text => 'You cannot sleep' ); 92 | 93 | $c->render( text => "bed" ); 94 | }; 95 | }; 96 | 97 | AllTests::run({ 98 | access_token_route => '/o/token', 99 | grant_type => 'client_credentials', 100 | skip_revoke_tests => 1, # there is no auth code 101 | }); 102 | 103 | done_testing(); 104 | 105 | # vim: ts=2:sw=2:et 106 | -------------------------------------------------------------------------------- /t/180_error_description.t: -------------------------------------------------------------------------------- 1 | #!perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Mojo::URL; 7 | use Mojolicious::Lite; 8 | use Test::More; 9 | use Test::Mojo; 10 | use Test::Deep; 11 | 12 | 13 | my $verify_client_sub = sub { 14 | my ( %args ) = @_; 15 | 16 | my ( $c,$client_id,$scopes_ref,$redirect_uri,$response_type ) 17 | = @args{ qw/ mojo_controller client_id scopes redirect_uri response_type / }; 18 | 19 | return ( 0,'unauthorized_client','unknown client error message' ) if $client_id ne 'TestClient'; 20 | return ( 1,undef ); # all good 21 | }; 22 | 23 | my $verify_auth_code_sub = sub { 24 | my ( %args ) = @_; 25 | 26 | my ( $c,$client_id,$client_secret,$auth_code,$uri ) 27 | = @args{qw/ mojo_controller client_id client_secret auth_code redirect_uri / }; 28 | 29 | return ( 0,'something_whatever',undef,undef,'something whatever error message' ); 30 | }; 31 | 32 | MOJO_APP: { 33 | # plugin configuration 34 | plugin 'OAuth2::Server' => { 35 | auth_code_ttl => 3600, 36 | access_token_ttl => 3600, 37 | verify_client => $verify_client_sub, 38 | verify_auth_code => $verify_auth_code_sub, 39 | clients => { 40 | TestClient => { 41 | client_secret => 'boo', 42 | }, 43 | }, 44 | }; 45 | 46 | group { 47 | # /api - must be authorized 48 | under '/api' => sub { 49 | my ( $c ) = @_; 50 | return 1 if $c->oauth && $c->oauth->{client_id}; 51 | $c->render( status => 401, text => 'Unauthorized' ); 52 | return undef; 53 | }; 54 | }; 55 | }; 56 | 57 | my $t = Test::Mojo->new; 58 | my $auth_route = '/oauth/authorize'; 59 | my $token_route = '/oauth/access_token'; 60 | 61 | my $auth_code; # used later for access token checks 62 | my %valid_auth_params = ( 63 | client_id => 'TestClient', 64 | client_secret => 'boo', 65 | response_type => 'code', 66 | redirect_uri => 'https://client/cb', 67 | state => 'queasy', 68 | ); 69 | 70 | subtest 'custom error_description on invalid request' => sub { 71 | 72 | subtest 'valid client' => sub { 73 | 74 | $t->get_ok( $auth_route => form => \%valid_auth_params ) 75 | ->status_is( 302 ) 76 | ; 77 | 78 | my $location = Mojo::URL->new( $t->tx->res->headers->location ); 79 | is( $location->path,'/cb','redirect to right place' ); 80 | ok( $auth_code = $location->query->param( 'code' ),'includes code' ); 81 | is( $location->query->param( 'state' ),'queasy','includes state' ); 82 | 83 | }; 84 | 85 | subtest 'invalid client' => sub { 86 | 87 | my %invalid_auth_params = ( 88 | %valid_auth_params, 89 | client_id => 'UNKNOWN', 90 | ); 91 | 92 | $t->get_ok( $auth_route => form => \%invalid_auth_params ) 93 | ->status_is( 302 ) 94 | ; 95 | 96 | my $location = Mojo::URL->new( $t->tx->res->headers->location ); 97 | 98 | is( $location->path,'/cb','redirect to right place' ); 99 | ok( my $error = $location->query->param( 'error' ),'includes error' ); 100 | ok( my $error_description = $location->query->param( 'error_description' ),'includes error_description' ); 101 | 102 | is $error, 'unauthorized_client', 'got expected error'; 103 | is $error_description, 'unknown client error message', 'got expected error description'; 104 | 105 | }; 106 | 107 | }; 108 | 109 | subtest 'custom error_description in token JSON response' => sub { 110 | 111 | my %valid_token_params = ( 112 | client_id => 'TestClient', 113 | client_secret => 'boo', 114 | grant_type => 'authorization_code', 115 | code => $auth_code, 116 | redirect_uri => $valid_auth_params{redirect_uri}, 117 | ); 118 | 119 | $t->post_ok( $token_route => form => \%valid_token_params ) 120 | ->status_is( 400 ) 121 | ; 122 | 123 | cmp_deeply( 124 | $t->tx->res->json, 125 | { 126 | 'error' => 'something_whatever', 127 | 'error_description' => 'something whatever error message', 128 | }, 129 | 'got expected error description' 130 | ); 131 | 132 | }; 133 | 134 | done_testing(); 135 | 136 | # vim: ts=2:sw=2:et 137 | -------------------------------------------------------------------------------- /t/170_expiry_ttl_callback.t: -------------------------------------------------------------------------------- 1 | #!perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Mojo::URL; 7 | use Mojolicious::Lite; 8 | use Test::More; 9 | use Test::Mojo; 10 | use Test::Deep; 11 | 12 | my $TTL_callback = sub { 13 | my ( %args ) = @_; 14 | 15 | return { 16 | TestClient => 99999, 17 | }->{ $args{client_id} // '' } // 12345; 18 | }; 19 | 20 | MOJO_APP: { 21 | # plugin configuration 22 | plugin 'OAuth2::Server' => { 23 | auth_code_ttl => 3600, 24 | access_token_ttl => $TTL_callback, 25 | clients => { 26 | TestClient => { 27 | client_secret => 'boo', 28 | }, 29 | TTLDefaultClient => { 30 | client_secret => 'banana', 31 | }, 32 | }, 33 | }; 34 | 35 | group { 36 | # /api - must be authorized 37 | under '/api' => sub { 38 | my ( $c ) = @_; 39 | return 1 if $c->oauth && $c->oauth->{client_id}; 40 | $c->render( status => 401, text => 'Unauthorized' ); 41 | return undef; 42 | }; 43 | }; 44 | }; 45 | 46 | my $t = Test::Mojo->new; 47 | my $auth_route = '/oauth/authorize'; 48 | my $token_route = '/oauth/access_token'; 49 | 50 | note('see t/030_expiry_config.t for token expiry tests, we only verify access token callback here'); 51 | 52 | subtest 'TestClient: custom TTL' => sub { 53 | 54 | my %valid_auth_params = ( 55 | client_id => 'TestClient', 56 | client_secret => 'boo', 57 | response_type => 'code', 58 | redirect_uri => 'https://client/cb', 59 | state => 'queasy', 60 | ); 61 | 62 | $t->get_ok( $auth_route => form => \%valid_auth_params ) 63 | ->status_is( 302 ) 64 | ; 65 | 66 | my $location = Mojo::URL->new( $t->tx->res->headers->location ); 67 | is( $location->path,'/cb','redirect to right place' ); 68 | ok( my $auth_code = $location->query->param( 'code' ),'includes code' ); 69 | is( $location->query->param( 'state' ),'queasy','includes state' ); 70 | 71 | my %valid_token_params = ( 72 | client_id => 'TestClient', 73 | client_secret => 'boo', 74 | grant_type => 'authorization_code', 75 | code => $auth_code, 76 | redirect_uri => $valid_auth_params{redirect_uri}, 77 | ); 78 | 79 | $t->post_ok( $token_route => form => \%valid_token_params ) 80 | ->status_is( 200 ) 81 | ->header_is( 'Cache-Control' => 'no-store' ) 82 | ->header_is( 'Pragma' => 'no-cache' ) 83 | ; 84 | 85 | cmp_deeply( 86 | $t->tx->res->json, 87 | { 88 | scopes => ignore(), 89 | access_token => re( '^.+$' ), 90 | token_type => 'Bearer', 91 | expires_in => 99999, 92 | refresh_token => re( '^.+$' ), 93 | }, 94 | 'json_is_deeply' 95 | ); 96 | 97 | }; 98 | 99 | subtest 'TTLDefaultClient: default TTL' => sub { 100 | 101 | my %valid_auth_params = ( 102 | client_id => 'TTLDefaultClient', 103 | client_secret => 'banana', 104 | response_type => 'code', 105 | redirect_uri => 'https://client/cb', 106 | state => 'queasy', 107 | ); 108 | 109 | $t->get_ok( $auth_route => form => \%valid_auth_params ) 110 | ->status_is( 302 ) 111 | ; 112 | 113 | my $location = Mojo::URL->new( $t->tx->res->headers->location ); 114 | is( $location->path,'/cb','redirect to right place' ); 115 | ok( my $auth_code = $location->query->param( 'code' ),'includes code' ); 116 | is( $location->query->param( 'state' ),'queasy','includes state' ); 117 | 118 | my %valid_token_params = ( 119 | client_id => 'TTLDefaultClient', 120 | client_secret => 'banana', 121 | grant_type => 'authorization_code', 122 | code => $auth_code, 123 | redirect_uri => $valid_auth_params{redirect_uri}, 124 | ); 125 | 126 | $t->post_ok( $token_route => form => \%valid_token_params ) 127 | ->status_is( 200 ) 128 | ->header_is( 'Cache-Control' => 'no-store' ) 129 | ->header_is( 'Pragma' => 'no-cache' ) 130 | ; 131 | 132 | cmp_deeply( 133 | $t->tx->res->json, 134 | { 135 | scopes => ignore(), 136 | access_token => re( '^.+$' ), 137 | token_type => 'Bearer', 138 | expires_in => 12345, 139 | refresh_token => re( '^.+$' ), 140 | }, 141 | 'json_is_deeply' 142 | ); 143 | 144 | }; 145 | 146 | done_testing(); 147 | 148 | # vim: ts=2:sw=2:et 149 | -------------------------------------------------------------------------------- /t/035_login_confirm_error.t: -------------------------------------------------------------------------------- 1 | #!perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Mojo::URL; 7 | use Mojolicious::Lite; 8 | use Test::More; 9 | use Test::Mojo; 10 | use Test::Deep; 11 | 12 | # there's quite a bit that's invalid in this test as we 13 | # are covering the edge cases and error checking (so do 14 | # not take it as an example of real usage) 15 | 16 | my $CONFIRMED_SCOPES; 17 | my $LOGGED_IN = 0; 18 | 19 | MOJO_APP: { 20 | # plugin configuration 21 | plugin 'OAuth2::Server' => { 22 | verify_client => sub { return ( 1 ) }, 23 | login_resource_owner => sub { 24 | my ( %args ) = @_; 25 | 26 | my $c = $args{mojo_controller}; 27 | if ( ! $LOGGED_IN++ ) { 28 | $c->redirect_to( '/oauth/login' ); 29 | return; 30 | } else { 31 | return $LOGGED_IN; 32 | } 33 | }, 34 | confirm_by_resource_owner => sub { 35 | my ( %args ) = @_; 36 | 37 | my ( $c,$client_id,$scopes_ref,$redirect_uri,$response_type ) 38 | = @args{ qw/ mojo_controller client_id scopes redirect_uri response_type / }; 39 | 40 | if ( ! defined $CONFIRMED_SCOPES ) { 41 | $c->redirect_to( '/oauth/confirm_scopes' ); 42 | # access is not required to be set by resource owner 43 | $CONFIRMED_SCOPES = 0; 44 | return; 45 | } elsif ( ! $CONFIRMED_SCOPES++ ) { 46 | # resource owner denies access 47 | return 0; 48 | } else { 49 | # resource owner allows access 50 | return 1; 51 | } 52 | }, 53 | clients => { 54 | 1 => { 55 | client_secret => 'boo', 56 | }, 57 | }, 58 | }; 59 | 60 | get '/oauth/login' => sub { return shift->render( text => "Login!" ) }; 61 | get '/oauth/confirm_scopes' => sub { return shift->render( text => "Allow?" ) }; 62 | get '/cb' => sub { 63 | my ( $c ) = @_; 64 | if ( my $error = $c->param( 'error' ) ) { 65 | return $c->render( text => $error ); 66 | } else { 67 | return $c->render( text => 'Callback' ); 68 | } 69 | }; 70 | }; 71 | 72 | my $t = Test::Mojo->new; 73 | $t->ua->max_redirects( 2 ); 74 | 75 | my $auth_route = '/oauth/authorize'; 76 | my $token_route = '/oauth/access_token'; 77 | 78 | my %valid_auth_params = ( 79 | client_id => 1, 80 | client_secret => 'boo', 81 | response_type => 'code', 82 | redirect_uri => '/cb', 83 | ); 84 | 85 | note( "not logged in" ); 86 | $t->get_ok( $auth_route => form => \%valid_auth_params ) 87 | ->status_is( 200 ) 88 | ->content_is( "Login!" ) 89 | ; 90 | 91 | note( "logged in (confirm scopes)" ); 92 | $t->get_ok( $auth_route => form => \%valid_auth_params ) 93 | ->status_is( 200 ) 94 | ->content_is( "Allow?" ) 95 | ; 96 | 97 | $t->ua->max_redirects( 0 ); 98 | 99 | note( "logged in (deny scopes)" ); 100 | $t->get_ok( $auth_route => form => \%valid_auth_params ) 101 | ->status_is( 302 ) 102 | ; 103 | 104 | my $expected_error = 'access_denied'; 105 | my $location = Mojo::URL->new( $t->tx->res->headers->location ); 106 | is( $location->path,'/cb','redirect to right place' ); 107 | ok( ! $location->query->param( 'code' ),'no code' ); 108 | is( $location->query->param( 'error' ),$expected_error,'expected error' ); 109 | 110 | $t->ua->max_redirects( 1 ); 111 | 112 | note( "logged in (already confirmed scopes)" ); 113 | $t->get_ok( $auth_route => form => \%valid_auth_params ) 114 | ->status_is( 200 ) 115 | ->content_is( 'Callback' ) 116 | ; 117 | 118 | note( "none existing auth code" ); 119 | 120 | my %valid_token_params = ( 121 | client_id => 1, 122 | client_secret => 'boo', 123 | grant_type => 'authorization_code', 124 | code => "invalid auth code", 125 | redirect_uri => "/bad", 126 | ); 127 | 128 | $t->post_ok( $token_route => form => \%valid_token_params ) 129 | ->status_is( 400 ) 130 | ->header_is( 'Cache-Control' => 'no-store' ) 131 | ->header_is( 'Pragma' => 'no-cache' ) 132 | ; 133 | 134 | cmp_deeply( 135 | $t->tx->res->json, 136 | { 137 | error => "invalid_grant", 138 | }, 139 | 'json_is_deeply' 140 | ); 141 | 142 | done_testing(); 143 | 144 | # vim: ts=2:sw=2:et 145 | -------------------------------------------------------------------------------- /t/070_overrides_hash_args.t: -------------------------------------------------------------------------------- 1 | #!perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Mojolicious::Lite; 7 | use Test::More; 8 | use FindBin qw/ $Bin /; 9 | use lib $Bin; 10 | use AllTests; 11 | 12 | my $verify_client_sub = sub { 13 | my ( %args ) = @_; 14 | 15 | # in reality we would check a config file / the database to confirm the 16 | # client_id and client_secret match and that the scopes are valid 17 | return ( 0,'invalid_scope' ) if grep { $_ eq 'cry' } @{ $args{scopes} // [] }; 18 | return ( 0,'access_denied' ) if grep { $_ eq 'drink' } @{ $args{scopes} // [] }; 19 | return ( 0,'unauthorized_client' ) if $args{client_id} ne '1'; 20 | return ( 0,'unauthorized_client' ) if $args{response_type} ne 'code'; 21 | 22 | # all good 23 | return ( 1,undef,$args{scopes} ); 24 | }; 25 | 26 | my $store_auth_code_sub = sub { 27 | my ( %args ) = @_; 28 | 29 | # in reality would store stuff in the database here (or perhaps a 30 | # correctly scoped hash, but the database is where it should be so 31 | # we have persistence across restarts and such) 32 | return; 33 | }; 34 | 35 | my %VERIFIED_AUTH_CODES; 36 | my $ACCESS_REVOKED = 0; 37 | 38 | my $verify_auth_code_sub = sub { 39 | my ( %args ) = @_; 40 | 41 | return ( 0,'invalid_grant' ) if $args{client_id} ne '1'; 42 | return ( 0,'invalid_grant' ) if $args{client_secret} ne 'boo'; 43 | 44 | my $error = undef; 45 | my $scope = { 46 | eat => 1, 47 | sleep => 0, 48 | }; 49 | 50 | if ( $VERIFIED_AUTH_CODES{$args{auth_code}} ) { 51 | # the auth code has been used before - we must revoke the auth code 52 | # and access tokens - this would be done in the database, but for 53 | # testing here i'm just setting a simple flag 54 | $ACCESS_REVOKED++; 55 | return ( 0,'invalid_grant' ); 56 | } 57 | 58 | $VERIFIED_AUTH_CODES{$args{auth_code}} = 1; 59 | 60 | # and here we would check the database, check the auth code hasn't 61 | # expired, and so on 62 | return ( $args{client_id},$error,$scope ); 63 | }; 64 | 65 | my $VALID_ACCESS_TOKEN; 66 | my $VALID_REFRESH_TOKEN; 67 | 68 | my $store_access_token_sub = sub { 69 | my ( %args ) = @_; 70 | 71 | $VALID_ACCESS_TOKEN = $args{access_token}; 72 | $VALID_REFRESH_TOKEN = $args{refresh_token}; 73 | 74 | # again, store stuff in the database 75 | return; 76 | }; 77 | 78 | my $verify_access_token_sub = sub { 79 | my ( %args ) = @_; 80 | 81 | # and here we should check the access code is valid, not expired, and the 82 | # passed scopes are allowed for the access token 83 | return 1 if $args{is_refresh_token} and $args{access_token} eq $VALID_REFRESH_TOKEN; 84 | return 0 if $ACCESS_REVOKED; 85 | return 0 if grep { $_ eq 'sleep' } @{ $args{scopes} // [] }; 86 | 87 | # this will only ever allow one access token - for the purposes of testing 88 | # that when a refresh token is used the previous access token is revoked 89 | return 0 if $args{access_token} ne $VALID_ACCESS_TOKEN; 90 | 91 | my $client_id = 1; 92 | 93 | return { client_id => $client_id }; 94 | }; 95 | 96 | MOJO_APP: { 97 | # plugin configuration 98 | plugin 'OAuth2::Server' => { 99 | args_as_hash => 1, 100 | authorize_route => '/o/auth', 101 | access_token_route => '/o/token', 102 | verify_client => $verify_client_sub, 103 | store_auth_code => $store_auth_code_sub, 104 | verify_auth_code => $verify_auth_code_sub, 105 | store_access_token => $store_access_token_sub, 106 | verify_access_token => $verify_access_token_sub, 107 | }; 108 | 109 | group { 110 | # /api - must be authorized 111 | under '/api' => sub { 112 | my ( $c ) = @_; 113 | return 1 if $c->oauth && $c->oauth->{client_id}; 114 | $c->render( status => 401, text => 'Unauthorized' ); 115 | return undef; 116 | }; 117 | 118 | get '/eat' => sub { shift->render( text => "food"); }; 119 | }; 120 | 121 | # /sleep - must be authorized and have sleep scope 122 | get '/api/sleep' => sub { 123 | my ( $c ) = @_; 124 | $c->oauth( 'sleep' ) 125 | || return $c->render( status => 401, text => 'You cannot sleep' ); 126 | 127 | $c->render( text => "bed" ); 128 | }; 129 | }; 130 | 131 | AllTests::run({ 132 | authorize_route => '/o/auth', 133 | access_token_route => '/o/token', 134 | }); 135 | 136 | done_testing(); 137 | 138 | # vim: ts=2:sw=2:et 139 | -------------------------------------------------------------------------------- /t/050_jwt.t: -------------------------------------------------------------------------------- 1 | #!perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Mojolicious::Lite; 7 | use Mojo::JWT; 8 | use Test::More; 9 | use Test::Deep; 10 | use FindBin qw/ $Bin /; 11 | use lib $Bin; 12 | use AllTests; 13 | 14 | my $jwt_secret = 'nova scotia scova notia'; 15 | 16 | MOJO_APP: { 17 | # plugin configuration 18 | plugin 'OAuth2::Server' => { 19 | jwt_secret => $jwt_secret, 20 | jwt_claims => sub { 21 | my ( $args ) = @_; 22 | 23 | return ( 24 | iss => "some iss", 25 | sub => "not the passed user_id", 26 | ); 27 | }, 28 | clients => { 29 | 1 => { 30 | client_secret => 'boo', 31 | scopes => { 32 | eat => 1, 33 | drink => 0, 34 | sleep => 1, 35 | }, 36 | }, 37 | }, 38 | }; 39 | 40 | group { 41 | # /api - must be authorized 42 | under '/api' => sub { 43 | my ( $c ) = @_; 44 | return 1 if $c->oauth && $c->oauth->{client_id}; 45 | $c->render( status => 401, text => 'Unauthorized' ); 46 | return undef; 47 | }; 48 | 49 | get '/eat' => sub { shift->render( text => "food"); }; 50 | }; 51 | 52 | # /sleep - must be authorized and have sleep scope 53 | get '/api/sleep' => sub { 54 | my ( $c ) = @_; 55 | $c->oauth( 'sleep' ) 56 | || return $c->render( status => 401, text => 'You cannot sleep' ); 57 | 58 | $c->render( text => "bed" ); 59 | }; 60 | }; 61 | 62 | my %valid_auth_params = ( 63 | client_id => 1, 64 | client_secret => 'boo', 65 | response_type => 'code', 66 | redirect_uri => 'https://client/cb', 67 | scope => 'eat sleep', 68 | state => 'queasy', 69 | ); 70 | 71 | my $t = Test::Mojo->new; 72 | 73 | $t->get_ok( '/oauth/authorize' => form => \%valid_auth_params ) 74 | ->status_is( 302 ) 75 | ; 76 | 77 | my $location = Mojo::URL->new( $t->tx->res->headers->location ); 78 | is( $location->path,'/cb','redirect to right place' ); 79 | ok( my $auth_code = $location->query->param( 'code' ),'includes code' ); 80 | 81 | my $decoded_auth_code = Mojo::JWT->new( secret => $jwt_secret )->decode( $auth_code ); 82 | 83 | cmp_deeply( 84 | $decoded_auth_code, 85 | { 86 | 'type' => 'auth', 87 | 'aud' => 'https://client/cb', 88 | 'client' => '1', 89 | 'user_id' => undef, 90 | 'exp' => re( '^\d{10}$' ), 91 | 'iat' => re( '^\d{10}$' ), 92 | 'jti' => re( '^.{32}$' ), 93 | 'iss' => "some iss", 94 | 'sub' => "not the passed user_id", 95 | 'scopes' => [ 96 | 'eat', 97 | 'sleep' 98 | ] 99 | }, 100 | 'decoded JWT (auth code)', 101 | ); 102 | 103 | my %valid_token_params = ( 104 | client_id => 1, 105 | client_secret => 'boo', 106 | grant_type => 'authorization_code', 107 | code => $auth_code, 108 | redirect_uri => $valid_auth_params{redirect_uri}, 109 | ); 110 | 111 | $t->post_ok( '/oauth/access_token'=> form => \%valid_token_params ) 112 | ->status_is( 200 ) 113 | ->header_is( 'Cache-Control' => 'no-store' ) 114 | ->header_is( 'Pragma' => 'no-cache' ) 115 | ; 116 | 117 | my $res = $t->tx->res->json; 118 | 119 | cmp_deeply( 120 | $res, 121 | { 122 | scopes => ignore(), 123 | access_token => re( '^.+$' ), 124 | token_type => 'Bearer', 125 | expires_in => '3600', 126 | refresh_token => re( '^.+$' ), 127 | }, 128 | 'json_is_deeply' 129 | ); 130 | 131 | my $decoded_access_token = Mojo::JWT->new( secret => $jwt_secret ) 132 | ->decode( $res->{access_token} ); 133 | my $decoded_refresh_token = Mojo::JWT->new( secret => $jwt_secret ) 134 | ->decode( $res->{refresh_token} ); 135 | 136 | cmp_deeply( 137 | $decoded_access_token, 138 | { 139 | 'type' => 'access', 140 | 'aud' => undef, 141 | 'client' => '1', 142 | 'user_id' => undef, 143 | 'exp' => re( '^\d{10}$' ), 144 | 'iat' => re( '^\d{10}$' ), 145 | 'jti' => re( '^.{32}$' ), 146 | 'iss' => "some iss", 147 | 'sub' => "not the passed user_id", 148 | 'scopes' => [ 149 | 'eat', 150 | 'sleep', 151 | ] 152 | }, 153 | 'decoded JWT (access token)', 154 | ); 155 | 156 | cmp_deeply( 157 | $decoded_refresh_token, 158 | { 159 | 'type' => 'refresh', 160 | 'aud' => undef, 161 | 'client' => '1', 162 | 'user_id' => undef, 163 | 'iat' => re( '^\d{10}$' ), 164 | 'jti' => re( '^.{32}$' ), 165 | 'iss' => "some iss", 166 | 'sub' => "not the passed user_id", 167 | 'scopes' => [ 168 | 'eat', 169 | 'sleep', 170 | ] 171 | }, 172 | 'decoded JWT (refresh token)', 173 | ); 174 | 175 | done_testing(); 176 | 177 | # vim: ts=2:sw=2:et 178 | -------------------------------------------------------------------------------- /t/030_expiry_config.t: -------------------------------------------------------------------------------- 1 | #!perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Mojo::URL; 7 | use Mojolicious::Lite; 8 | use Test::More; 9 | use Test::Mojo; 10 | use Test::Deep; 11 | 12 | my $TTL = 3; 13 | 14 | MOJO_APP: { 15 | # plugin configuration 16 | plugin 'OAuth2::Server' => { 17 | auth_code_ttl => $TTL, 18 | access_token_ttl => $TTL, 19 | clients => { 20 | 1 => { 21 | client_secret => 'boo', 22 | scopes => { 23 | eat => 1, 24 | drink => 0, 25 | sleep => 1, 26 | }, 27 | }, 28 | }, 29 | }; 30 | 31 | group { 32 | # /api - must be authorized 33 | under '/api' => sub { 34 | my ( $c ) = @_; 35 | return 1 if $c->oauth && $c->oauth->{client_id}; 36 | $c->render( status => 401, text => 'Unauthorized' ); 37 | return undef; 38 | }; 39 | 40 | get '/eat' => sub { shift->render( text => "food"); }; 41 | }; 42 | 43 | # /sleep - must be authorized and have sleep scope 44 | get '/api/sleep' => sub { 45 | my ( $c ) = @_; 46 | $c->oauth( 'sleep' ) 47 | || return $c->render( status => 401, text => 'You cannot sleep' ); 48 | 49 | $c->render( text => "bed" ); 50 | }; 51 | }; 52 | 53 | my $t = Test::Mojo->new; 54 | my $auth_route = '/oauth/authorize'; 55 | my $token_route = '/oauth/access_token'; 56 | 57 | my %valid_auth_params = ( 58 | client_id => 1, 59 | client_secret => 'boo', 60 | response_type => 'code', 61 | redirect_uri => 'https://client/cb', 62 | scope => 'eat', 63 | state => 'queasy', 64 | ); 65 | 66 | $t->get_ok( $auth_route => form => \%valid_auth_params ) 67 | ->status_is( 302 ) 68 | ; 69 | 70 | my $location = Mojo::URL->new( $t->tx->res->headers->location ); 71 | is( $location->path,'/cb','redirect to right place' ); 72 | ok( my $auth_code = $location->query->param( 'code' ),'includes code' ); 73 | is( $location->query->param( 'state' ),'queasy','includes state' ); 74 | 75 | my %valid_token_params = ( 76 | client_id => 1, 77 | client_secret => 'boo', 78 | grant_type => 'authorization_code', 79 | code => $auth_code, 80 | redirect_uri => $valid_auth_params{redirect_uri}, 81 | ); 82 | 83 | $t->post_ok( $token_route => form => \%valid_token_params ) 84 | ->status_is( 200 ) 85 | ->header_is( 'Cache-Control' => 'no-store' ) 86 | ->header_is( 'Pragma' => 'no-cache' ) 87 | ; 88 | 89 | cmp_deeply( 90 | $t->tx->res->json, 91 | { 92 | scopes => ignore(), 93 | access_token => re( '^.+$' ), 94 | token_type => 'Bearer', 95 | expires_in => $TTL, 96 | refresh_token => re( '^.+$' ), 97 | }, 98 | 'json_is_deeply' 99 | ); 100 | 101 | my $access_token = $t->tx->res->json->{access_token}; 102 | my $refresh_token = $t->tx->res->json->{refresh_token}; 103 | 104 | $t->ua->on(start => sub { 105 | my ( $ua,$tx ) = @_; 106 | $tx->req->headers->header( 'Authorization' => "Bearer $access_token" ); 107 | }); 108 | 109 | $t->get_ok('/api/eat')->status_is( 200 ); 110 | $t->get_ok('/api/sleep')->status_is( 401 ); 111 | 112 | sleep( $TTL + 1 ); 113 | 114 | note( "access token expired" ); 115 | $t->get_ok('/api/eat')->status_is( 401 ); 116 | $t->get_ok('/api/sleep')->status_is( 401 ); 117 | 118 | note( "refresh token does not expire" ); 119 | 120 | $t->post_ok( $token_route => form => { 121 | %valid_token_params, 122 | grant_type => 'refresh_token', 123 | refresh_token => $refresh_token, 124 | } ) 125 | ->status_is( 200 ) 126 | ->header_is( 'Cache-Control' => 'no-store' ) 127 | ->header_is( 'Pragma' => 'no-cache' ) 128 | ; 129 | 130 | cmp_deeply( 131 | $t->tx->res->json, 132 | { 133 | access_token => re( '^.+$' ), 134 | token_type => 'Bearer', 135 | expires_in => $TTL, 136 | refresh_token => re( '^.+$' ), 137 | }, 138 | 'json_is_deeply' 139 | ); 140 | 141 | $access_token = $t->tx->res->json->{access_token}; 142 | my $new_refresh_token = $t->tx->res->json->{refresh_token}; 143 | 144 | note( "previous refresh token revoked after using it" ); 145 | 146 | $t->post_ok( $token_route => form => { 147 | %valid_token_params, 148 | grant_type => 'refresh_token', 149 | refresh_token => $refresh_token, 150 | } ) 151 | ->status_is( 400 ) 152 | ->header_is( 'Cache-Control' => 'no-store' ) 153 | ->header_is( 'Pragma' => 'no-cache' ) 154 | ; 155 | 156 | note( "new auth code request" ); 157 | $t->get_ok( $auth_route => form => \%valid_auth_params ) 158 | ->status_is( 302 ) 159 | ; 160 | 161 | $location = Mojo::URL->new( $t->tx->res->headers->location ); 162 | is( $location->path,'/cb','redirect to right place' ); 163 | ok( $auth_code = $location->query->param( 'code' ),'includes code' ); 164 | is( $location->query->param( 'state' ),'queasy','includes state' ); 165 | 166 | note( "auth code expires" ); 167 | sleep( $TTL + 1 ); 168 | 169 | $t->post_ok( $token_route => form => \%valid_token_params ) 170 | ->status_is( 400 ) 171 | ->header_is( 'Cache-Control' => 'no-store' ) 172 | ->header_is( 'Pragma' => 'no-cache' ) 173 | ; 174 | 175 | done_testing(); 176 | 177 | # vim: ts=2:sw=2:et 178 | -------------------------------------------------------------------------------- /t/015_overrides.t: -------------------------------------------------------------------------------- 1 | #!perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Mojolicious::Lite; 7 | use Test::More; 8 | use FindBin qw/ $Bin /; 9 | use lib $Bin; 10 | use AllTests; 11 | 12 | my $verify_client_sub = sub { 13 | my ( %args ) = @_; 14 | 15 | my ( $c,$client_id,$scopes_ref,$redirect_uri,$response_type ) 16 | = @args{ qw/ mojo_controller client_id scopes redirect_uri response_type / }; 17 | 18 | ok( $c,'have a mojo_controller' ); 19 | 20 | # in reality we would check a config file / the database to confirm the 21 | # client_id and client_secret match and that the scopes are valid 22 | return ( 0,'invalid_scope' ) if grep { $_ eq 'cry' } @{ $scopes_ref // [] }; 23 | return ( 0,'access_denied' ) if grep { $_ eq 'drink' } @{ $scopes_ref // [] }; 24 | return ( 0,'unauthorized_client' ) if $client_id ne '1'; 25 | return ( 0,'unauthorized_client' ) if $response_type ne 'code'; 26 | 27 | # all good 28 | return ( 1,undef ); 29 | }; 30 | 31 | my $store_auth_code_sub = sub { 32 | my ( %args ) = @_; 33 | 34 | my ( $c,$auth_code,$client_id,$expires_in,$url,$scopes_ref ) 35 | = @args{qw/ mojo_controller auth_code client_id expires_in redirect_uri scopes / }; 36 | 37 | ok( $c,'have a mojo_controller' ); 38 | 39 | # in reality would store stuff in the database here (or perhaps a 40 | # correctly scoped hash, but the database is where it should be so 41 | # we have persistence across restarts and such) 42 | return; 43 | }; 44 | 45 | my %VERIFIED_AUTH_CODES; 46 | my $ACCESS_REVOKED = 0; 47 | 48 | my $verify_auth_code_sub = sub { 49 | my ( %args ) = @_; 50 | 51 | my ( $c,$client_id,$client_secret,$auth_code,$url ) 52 | = @args{qw/ mojo_controller client_id client_secret auth_code redirect_uri / }; 53 | 54 | ok( $c,'have a mojo_controller' ); 55 | 56 | return ( 0,'invalid_grant' ) if $client_id ne '1'; 57 | return ( 0,'invalid_grant' ) if $client_secret ne 'boo'; 58 | 59 | my $error = undef; 60 | my $scope = { 61 | eat => 1, 62 | sleep => 0, 63 | }; 64 | 65 | if ( $VERIFIED_AUTH_CODES{$auth_code} ) { 66 | # the auth code has been used before - we must revoke the auth code 67 | # and access tokens - this would be done in the database, but for 68 | # testing here i'm just setting a simple flag 69 | $ACCESS_REVOKED++; 70 | return ( 0,'invalid_grant' ); 71 | } 72 | 73 | $VERIFIED_AUTH_CODES{$auth_code} = 1; 74 | 75 | # and here we would check the database, check the auth code hasn't 76 | # expired, and so on 77 | return ( $client_id,$error,$scope ); 78 | }; 79 | 80 | my $VALID_ACCESS_TOKEN; 81 | my $VALID_REFRESH_TOKEN; 82 | 83 | my $store_access_token_sub = sub { 84 | my ( %args ) = @_; 85 | 86 | my ( 87 | $c,$client_id,$auth_code,$access_token,$refresh_token, 88 | $expires_in,$scope,$old_refresh_token 89 | ) = @args{qw/ 90 | mojo_controller client_id auth_code access_token 91 | refresh_token expires_in scopes old_refresh_token 92 | / }; 93 | 94 | ok( $c,'have a mojo_controller' ); 95 | 96 | $VALID_ACCESS_TOKEN = $access_token; 97 | $VALID_REFRESH_TOKEN = $refresh_token; 98 | 99 | # again, store stuff in the database 100 | return; 101 | }; 102 | 103 | my $verify_access_token_sub = sub { 104 | my ( %args ) = @_; 105 | 106 | my ( $c,$access_token,$scopes_ref,$is_refresh_token ) 107 | = @args{qw/ mojo_controller access_token scopes is_refresh_token /}; 108 | 109 | ok( $c,'have a mojo_controller' ); 110 | 111 | # and here we should check the access code is valid, not expired, and the 112 | # passed scopes are allowed for the access token 113 | return 1 if $is_refresh_token and $access_token eq $VALID_REFRESH_TOKEN; 114 | return 0 if $ACCESS_REVOKED; 115 | return 0 if grep { $_ eq 'sleep' } @{ $scopes_ref // [] }; 116 | 117 | # this will only ever allow one access token - for the purposes of testing 118 | # that when a refresh token is used the previous access token is revoked 119 | return 0 if $access_token ne $VALID_ACCESS_TOKEN; 120 | 121 | my $client_id = 1; 122 | 123 | return { client_id => $client_id }; 124 | }; 125 | 126 | MOJO_APP: { 127 | # plugin configuration 128 | plugin 'OAuth2::Server' => { 129 | authorize_route => '/o/auth', 130 | access_token_route => '/o/token', 131 | verify_client => $verify_client_sub, 132 | store_auth_code => $store_auth_code_sub, 133 | verify_auth_code => $verify_auth_code_sub, 134 | store_access_token => $store_access_token_sub, 135 | verify_access_token => $verify_access_token_sub, 136 | }; 137 | 138 | group { 139 | # /api - must be authorized 140 | under '/api' => sub { 141 | my ( $c ) = @_; 142 | return 1 if $c->oauth && $c->oauth->{client_id}; 143 | $c->render( status => 401, text => 'Unauthorized' ); 144 | return undef; 145 | }; 146 | 147 | get '/eat' => sub { shift->render( text => "food"); }; 148 | }; 149 | 150 | # /sleep - must be authorized and have sleep scope 151 | get '/api/sleep' => sub { 152 | my ( $c ) = @_; 153 | $c->oauth( 'sleep' ) 154 | || return $c->render( status => 401, text => 'You cannot sleep' ); 155 | 156 | $c->render( text => "bed" ); 157 | }; 158 | }; 159 | 160 | AllTests::run({ 161 | authorize_route => '/o/auth', 162 | access_token_route => '/o/token', 163 | }); 164 | 165 | done_testing(); 166 | 167 | # vim: ts=2:sw=2:et 168 | -------------------------------------------------------------------------------- /t/051_jwt_refresh.t: -------------------------------------------------------------------------------- 1 | #!perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Mojolicious::Lite; 7 | use Mojo::JWT; 8 | use Test::More; 9 | use Test::Deep; 10 | use FindBin qw/ $Bin /; 11 | use lib $Bin; 12 | use AllTests; 13 | 14 | my $jwt_secret = 'nova scotia scova notia'; 15 | 16 | MOJO_APP: { 17 | # plugin configuration 18 | plugin 'OAuth2::Server' => { 19 | jwt_secret => $jwt_secret, 20 | jwt_claims => sub { 21 | my ( $args ) = @_; 22 | 23 | return ( 24 | iss => "some iss", 25 | sub => "not the passed user_id", 26 | ); 27 | }, 28 | clients => { 29 | frey => { 30 | client_secret => 'walder', 31 | scopes => { 32 | betrayal => 1, 33 | }, 34 | }, 35 | }, 36 | }; 37 | }; 38 | 39 | my %valid_auth_params = ( 40 | client_id => 'frey', 41 | client_secret => 'walder', 42 | response_type => 'code', 43 | redirect_uri => 'https://client/cb', 44 | scope => 'betrayal', 45 | state => 'red', 46 | ); 47 | 48 | my $t = Test::Mojo->new; 49 | 50 | $t->get_ok( '/oauth/authorize' => form => \%valid_auth_params ) 51 | ->status_is( 302 ) 52 | ; 53 | 54 | my $location = Mojo::URL->new( $t->tx->res->headers->location ); 55 | is( $location->path,'/cb','redirect to right place' ); 56 | ok( my $auth_code = $location->query->param( 'code' ),'includes code' ); 57 | 58 | my $decoded_auth_code = Mojo::JWT->new( secret => $jwt_secret )->decode( $auth_code ); 59 | 60 | cmp_deeply( 61 | $decoded_auth_code, 62 | { 63 | 'type' => 'auth', 64 | 'aud' => 'https://client/cb', 65 | 'client' => 'frey', 66 | 'user_id' => undef, 67 | 'exp' => re( '^\d{10}$' ), 68 | 'iat' => re( '^\d{10}$' ), 69 | 'jti' => re( '^.{32}$' ), 70 | 'iss' => "some iss", 71 | 'sub' => "not the passed user_id", 72 | 'scopes' => [ 73 | 'betrayal', 74 | ] 75 | }, 76 | 'decoded JWT (auth code)', 77 | ); 78 | 79 | my %valid_token_params = ( 80 | client_id => 'frey', 81 | client_secret => 'walder', 82 | grant_type => 'authorization_code', 83 | code => $auth_code, 84 | redirect_uri => $valid_auth_params{redirect_uri}, 85 | ); 86 | 87 | $t->post_ok( '/oauth/access_token'=> form => \%valid_token_params ) 88 | ->status_is( 200 ) 89 | ->header_is( 'Cache-Control' => 'no-store' ) 90 | ->header_is( 'Pragma' => 'no-cache' ) 91 | ; 92 | my $res = $t->tx->res->json; 93 | 94 | cmp_deeply( 95 | $res, 96 | { 97 | scopes => ignore(), 98 | access_token => re( '^.+$' ), 99 | token_type => 'Bearer', 100 | expires_in => '3600', 101 | refresh_token => re( '^.+$' ), 102 | }, 103 | 'json_is_deeply' 104 | ); 105 | 106 | my $decoded_access_token = Mojo::JWT->new( secret => $jwt_secret ) 107 | ->decode( $res->{access_token} ); 108 | my $decoded_refresh_token = Mojo::JWT->new( secret => $jwt_secret ) 109 | ->decode( $res->{refresh_token} ); 110 | 111 | cmp_deeply( 112 | $decoded_access_token, 113 | { 114 | 'type' => 'access', 115 | 'aud' => undef, 116 | 'client' => 'frey', 117 | 'user_id' => undef, 118 | 'exp' => re( '^\d{10}$' ), 119 | 'iat' => re( '^\d{10}$' ), 120 | 'jti' => re( '^.{32}$' ), 121 | 'iss' => "some iss", 122 | 'sub' => "not the passed user_id", 123 | 'scopes' => [ 124 | 'betrayal' 125 | ] 126 | }, 127 | 'decoded JWT (access token)', 128 | ); 129 | 130 | cmp_deeply( 131 | $decoded_refresh_token, 132 | { 133 | 'type' => 'refresh', 134 | 'aud' => undef, 135 | 'client' => 'frey', 136 | 'user_id' => undef, 137 | 'iat' => re( '^\d{10}$' ), 138 | 'jti' => re( '^.{32}$' ), 139 | 'iss' => "some iss", 140 | 'sub' => "not the passed user_id", 141 | 'scopes' => [ 142 | 'betrayal' 143 | ] 144 | }, 145 | 'decoded JWT (refresh token)', 146 | ); 147 | 148 | my %refresh_token_params = ( 149 | client_id => 'frey', 150 | client_secret => 'walder', 151 | grant_type => 'refresh_token', 152 | refresh_token => $res->{refresh_token}, 153 | ); 154 | 155 | $t->post_ok( '/oauth/access_token'=> form => \%refresh_token_params ) 156 | ->status_is( 200 ) 157 | ->header_is( 'Cache-Control' => 'no-store' ) 158 | ->header_is( 'Pragma' => 'no-cache' ) 159 | ; 160 | $res = $t->tx->res->json; 161 | cmp_deeply( 162 | $res, 163 | { 164 | access_token => re( '^.+$' ), 165 | token_type => 'Bearer', 166 | expires_in => '3600', 167 | refresh_token => re( '^.+$' ), 168 | }, 169 | 'json_is_deeply' 170 | ); 171 | 172 | $decoded_access_token = Mojo::JWT->new( secret => $jwt_secret ) 173 | ->decode( $res->{access_token} ); 174 | $decoded_refresh_token = Mojo::JWT->new( secret => $jwt_secret ) 175 | ->decode( $res->{refresh_token} ); 176 | 177 | cmp_deeply( 178 | $decoded_access_token, 179 | { 180 | 'type' => 'access', 181 | 'aud' => undef, 182 | 'client' => 'frey', 183 | 'user_id' => undef, 184 | 'exp' => re( '^\d{10}$' ), 185 | 'iat' => re( '^\d{10}$' ), 186 | 'jti' => re( '^.{32}$' ), 187 | 'iss' => "some iss", 188 | 'sub' => "not the passed user_id", 189 | 'scopes' => [ 190 | 'betrayal' 191 | ] 192 | }, 193 | 'decoded JWT (access token)', 194 | ); 195 | 196 | cmp_deeply( 197 | $decoded_refresh_token, 198 | { 199 | 'type' => 'refresh', 200 | 'aud' => undef, 201 | 'client' => 'frey', 202 | 'user_id' => undef, 203 | 'iat' => re( '^\d{10}$' ), 204 | 'jti' => re( '^.{32}$' ), 205 | 'iss' => "some iss", 206 | 'sub' => "not the passed user_id", 207 | 'scopes' => [ 208 | 'betrayal' 209 | ] 210 | }, 211 | 'decoded JWT (refresh token)', 212 | ); 213 | 214 | done_testing(); 215 | 216 | # vim: ts=2:sw=2:et 217 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NAME 2 | 3 | Mojolicious::Plugin::OAuth2::Server - Easier implementation of an OAuth2 4 | Authorization Server / Resource Server with Mojolicious 5 | 6 |
7 | 8 | Build Status 9 | Coverage Status 10 |
11 | 12 | # VERSION 13 | 14 | 0.52 15 | 16 | # SYNOPSIS 17 | 18 | use Mojolicious::Lite; 19 | 20 | plugin 'OAuth2::Server' => { 21 | ... # see SYNOPSIS in Net::OAuth2::AuthorizationServer::Manual 22 | }; 23 | 24 | group { 25 | # /api - must be authorized 26 | under '/api' => sub { 27 | my ( $c ) = @_; 28 | 29 | return 1 if $c->oauth; # must be authorized via oauth 30 | 31 | $c->render( status => 401, text => 'Unauthorized' ); 32 | return undef; 33 | }; 34 | 35 | any '/annoy_friends' => sub { shift->render( text => "Annoyed Friends" ); }; 36 | any '/post_image' => sub { shift->render( text => "Posted Image" ); }; 37 | }; 38 | 39 | any '/track_location' => sub { 40 | my ( $c ) = @_; 41 | 42 | my $oauth_details = $c->oauth( 'track_location' ) 43 | || return $c->render( status => 401, text => 'You cannot track location' ); 44 | 45 | $c->render( text => "Target acquired: @{[$oauth_details->{user_id}]}" ); 46 | }; 47 | 48 | app->start; 49 | 50 | Or full fat app: 51 | 52 | use Mojo::Base 'Mojolicious'; 53 | 54 | ... 55 | 56 | sub startup { 57 | my $self = shift; 58 | 59 | ... 60 | 61 | $self->plugin( 'OAuth2::Server' => $oauth2_auth_code_grant_config ); 62 | } 63 | 64 | Then in your controller: 65 | 66 | sub my_route_name { 67 | my ( $c ) = @_; 68 | 69 | if ( my $oauth_details = $c->oauth( qw/required scopes/ ) ) { 70 | ... # do something, user_id, client_id, etc, available in $oauth_details 71 | } else { 72 | return $c->render( status => 401, text => 'Unauthorized' ); 73 | } 74 | 75 | ... 76 | } 77 | 78 | # DESCRIPTION 79 | 80 | This plugin implements the various OAuth2 grant types flow as described at 81 | [http://tools.ietf.org/html/rfc6749](http://tools.ietf.org/html/rfc6749). It is a complete implementation of 82 | RFC6749, with the exception of the "Extension Grants" as the description of 83 | that grant type is rather hand-wavy. 84 | 85 | The bulk of the functionality is implemented in the [Net::OAuth2::AuthorizationServer](https://metacpan.org/pod/Net%3A%3AOAuth2%3A%3AAuthorizationServer) 86 | distribution, you should see that for more comprehensive documentation and 87 | examples of usage. 88 | 89 | The examples here use the "Authorization Code Grant" flow as that is considered 90 | the most secure and most complete form of OAuth2. 91 | 92 | # METHODS 93 | 94 | ## register 95 | 96 | Registers the plugin with your app - note that you must pass callbacks for 97 | certain functions that the plugin expects to call if you are not using the 98 | plugin in its simplest form. 99 | 100 | $self->register($app, \%config); 101 | 102 | Registering the plugin will call the [Net::OAuth2::AuthorizationServer](https://metacpan.org/pod/Net%3A%3AOAuth2%3A%3AAuthorizationServer) 103 | and create a `auth_code_grant` that can be accessed using the defined 104 | `authorize_route` and `access_token_route`. The arguments passed to the 105 | plugin are passed straight through to the `auth_code_grant` method in 106 | the [Net::OAuth2::AuthorizationServer](https://metacpan.org/pod/Net%3A%3AOAuth2%3A%3AAuthorizationServer) module. 107 | 108 | ## oauth 109 | 110 | Checks if there is a valid Authorization: Bearer header with a valid access 111 | token and if the access token has the requisite scopes. The scopes are optional: 112 | 113 | unless ( my $oauth_details = $c->oauth( @scopes ) ) { 114 | return $c->render( status => 401, text => 'Unauthorized' ); 115 | } 116 | 117 | This calls the [Net::OAuth2::AuthorizationServer::AuthorizationCodeGrant](https://metacpan.org/pod/Net%3A%3AOAuth2%3A%3AAuthorizationServer%3A%3AAuthorizationCodeGrant) 118 | module (`verify_token_and_scope` method) to validate the access/refresh token. 119 | 120 | ## oauth2\_auth\_request 121 | 122 | This is a helper to allow you get get the redirect URI instead of directing 123 | a user to the authorize\_route - it requires the details of the client: 124 | 125 | my $redirect_uri = $c->oauth2_auth_request({ 126 | client_id => $client_id, 127 | redirect_uri => 'https://foo', 128 | response_type => 'token', 129 | scope => 'list,of,scopes', 130 | state => 'foo=bar&baz=boz', 131 | }); 132 | 133 | if ( $redirect_uri ) { 134 | # do something with $redirect_uri 135 | } else { 136 | # something didn't work, e.g. bad client, scopes, etc 137 | } 138 | 139 | You can use this helper instead of directing a user to the authorize\_route if 140 | you need to do something more involved with the redirect\_uri rather than 141 | having the plugin direct to the user to the resulting redirect uri 142 | 143 | # SEE ALSO 144 | 145 | [Net::OAuth2::AuthorizationServer](https://metacpan.org/pod/Net%3A%3AOAuth2%3A%3AAuthorizationServer) - The dist that handles the bulk of the 146 | functionality used by this plugin 147 | 148 | # AUTHOR & CONTRIBUTORS 149 | 150 | Lee Johnson - `leejo@cpan.org` 151 | 152 | With contributions from: 153 | 154 | Nick Logan `nlogan@gmail.com` 155 | 156 | Pierre VIGIER `pierre.vigier@gmail.com` 157 | 158 | Renee `reb@perl-services.de` 159 | 160 | # LICENSE 161 | 162 | This library is free software; you can redistribute it and/or modify it under 163 | the same terms as Perl itself. If you would like to contribute documentation 164 | or file a bug report then please raise an issue / pull request: 165 | 166 | https://github.com/Humanstate/mojolicious-plugin-oauth2-server 167 | -------------------------------------------------------------------------------- /Changes: -------------------------------------------------------------------------------- 1 | Revision history for Mojolicious-Plugin-OAuth2-Server 2 | 3 | 0.52 2025-05-20 4 | - Allow all valid characters in client credentials (GH #29) 5 | 6 | 0.51 2022-08-15 7 | - Bump min perl version to 5.16 (as per core Mojolicious) 8 | 9 | 0.50 2022-08-08 10 | - Allow client_id / client_secret in body params (GH #28) 11 | 12 | 0.49 2022-06-02 13 | - Fix tests broken by Mojolicious multiple render fixes (GH #27) 14 | 15 | 0.48 2021-06-15 16 | - Accept error_description from auth methods (GH #26) 17 | 18 | 0.47 2020-07-20 19 | - Add t/170_expiry_ttl_callback.t for Net::OAuth2::AuthorizationServer v0.26 changes 20 | 21 | 0.46 2020-06-18 22 | - Return scope list from access token request, as per RFC6749 (GH #22) 23 | 24 | 0.45 2020-06-18 25 | - Fix missing mojo_controller passed to ->verify_client (GH #24) 26 | 27 | 0.44 2019-04-27 28 | - Fix make sure user_id is returned in AuthorizationCodeGrant defaults 29 | 30 | 0.42 2018-05-01 31 | - Prevent double encoding of URL in oauth2_auth_request helper 32 | 33 | - Note that this requires a much more recent version of Mojolicious 34 | so the requirement has been bumped to v7.76 35 | 36 | 0.41 2018-04-16 37 | - Handle inconsistencies between various grant types and the return 38 | data from ->verify_token_and_scope sometimes returning a hash ref 39 | and sometimes returning a string - now they always return a hash 40 | ref in the case of a successful authentication (GH #20) 41 | 42 | - Note that this may be a BREAKING CHANGE if you are using password 43 | grant in your app 44 | 45 | - Requires v0.17 of Net::OAuth2::AuthorizationServer for these changes 46 | 47 | 0.40 2018-02-02 48 | - Add oauth2_auth_request helper to get at redirect URI that results 49 | from the authorization process 50 | 51 | 0.39 2018-09-01 52 | - Add failing test case for return values when refresh token is 53 | a JWT (GH #17, thanks to pierre-vigier) 54 | - Fix above issue by bumping requirement on N::O::AuthorizationServer 55 | to v0.16 56 | 57 | 0.38 2017-06-01 58 | - Fix combination of verify_client and jwt_secret causing tokens 59 | to be generated when verify_client return 0 for client_credentials 60 | grant 61 | 62 | 0.37 2017-05-12 63 | - Add support for jwt_claims callback in config 64 | (see jwt_claims_cb in Net::OAuth2::AuthorizationServer) 65 | 66 | 0.36 2017-05-03 67 | - Fix bug in test for version 0.34 due to requiring url_decode 68 | of access token in implicit grant flow 69 | 70 | 0.35 2017-03-06 71 | - Fix bug in test for previous version 72 | 73 | 0.34 2017-03-06 74 | - Fix implicit grant should return query params in a fragment 75 | 76 | 0.33 2017-03-03 77 | - Fix tests for recent version of Net::OAuth2::AuthorizationServer 78 | 79 | 0.32 2016-11-01 80 | - Remove args_as_hash, this is now the default 81 | 82 | 0.31 2016-10-01 83 | - Deprecate args_as_hash, will become the standard as from the next version 84 | 85 | 0.30 2016-09-16 86 | - Add "Client Credentials Grant" flow (response_type = "client_credentials" 87 | in call to access_token) 88 | - Improve test coverage for Implicit Grant 89 | - Fix setting of scopes for JWT in Implicit Grant revealed by improved test 90 | coverage 91 | 92 | 0.29 2016-08-31 93 | - Fix Implicit Grant needs to be able to support login_resource_owner and 94 | confirm_by_resource_owner callbacks 95 | 96 | 0.28 2016-08-31 97 | - Fix don't allow clients defined with a client_secret to use the Implicit 98 | Grant Flow (handled in Net::OAuth2::AuthorizationServer, but add tests here) 99 | 100 | 0.27 2016-08-31 101 | - Add "Implicit Grant" flow (response_type = "token" in call to authorize) 102 | 103 | 0.26 2016-05-12 104 | - Transfer repo from G3S to Humanstate 105 | 106 | 0.25 2016-04-17 107 | - Add "Resource Owner Password Credentials Grant" 108 | 109 | 0.24 2016-04-11 110 | - split out bulk of code into Net::OAuth2::AuthorizationServer 111 | - ditto for examples and bulk of the perldoc 112 | - plugin is fully backwards compatible, although you may want to 113 | move to using the args_as_hash option for cleaner callbacks 114 | see docs for Net::OAuth2::AuthorizationServer::AuthorizationCodeGrant 115 | 116 | 0.23 2015-10-21 117 | - move github repo to Humanstate org 118 | 119 | 0.22 2015-06-25 120 | - auth codes, access tokens, and refresh tokens returned can now be 121 | JWTs (implemented via Mojo::JWT). this allows validation without 122 | database lookup *should you want to do that* 123 | 124 | - this allows the "simple" usage of the plugin to be persistent and 125 | multi process compat by supplying a jwt_secret - although you lose 126 | the automatic token revoking capabilities of the module when doing 127 | this. see the examples, tests, and perldoc for more information 128 | 129 | 0.11 2015-03-19 130 | - update examples/oauth2_client.pl to work with latest version of 131 | Mojolicious::Plugin::OAuth2. point to examples/ in the perldoc 132 | - tweaks to perldoc to highlight that this is an implementation of 133 | the "Authorization Code Grant" flow 134 | 135 | 0.10 2015-03-17 136 | - change token_type to be Bearer rather than bearer as this maps better 137 | for use in the Authorization header 138 | 139 | 0.09 2015-03-16 140 | - fix refresh_token check to prevent it being used as an access token. 141 | this adds an extra argument ($is_refresh_token) to the method that 142 | is called to _verify_access_token 143 | 144 | 0.08 2015-02-12 145 | - stipulate CryptX in the Makefile.PL rather than Crypt::PRNG, as the 146 | latter doesn't have a VERSION number so causes dependency check to 147 | fail (thanks to reneeb for the report/fix) 148 | - some tweaks in examples 149 | 150 | 0.07 2015-02-11 151 | - call verify_client before redirecting to login / confirm scopes 152 | as there's no point logging a user in, etc, if the client is bad 153 | - make _verify_access_token return a list as _verify_auth_code so 154 | we can report the failure reason in a meaningful way. 155 | - Add example schema and code for using the module with a relational 156 | database 157 | - harden token generation function using Crypt::PRNG random_string 158 | 159 | 0.06 2015-02-10 160 | - test and documentation for flash + redirect in a full fat app 161 | 162 | 0.05 2015-02-07 163 | - use warnings and fix any raised by tests 164 | 165 | 0.04 2015-02-06 166 | - refatoring and consistency tweaks 167 | 168 | 0.03 2015-02-06 169 | - fix regexp in tests to be looser 170 | 171 | 0.02 2015-02-06 172 | - POD tweaks 173 | - set minimum perl version (5.10.1) 174 | 175 | 0.01 2015-02-06 176 | - First release inspired by frustration, confusion, and hate when trying 177 | to implement OAuth2 resource/auth server using existing CPAN modules 178 | -------------------------------------------------------------------------------- /t/AllTests.pm: -------------------------------------------------------------------------------- 1 | package AllTests; 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Mojo::URL; 7 | use Mojo::Util qw/ b64_encode url_unescape /; 8 | use Test::More; 9 | use Test::Deep; 10 | use Test::Mojo; 11 | 12 | sub run { 13 | my ( $args ) = @_; 14 | 15 | my $grant_type = $args->{grant_type} // 'authorization_code'; 16 | my $auth_route = $args->{authorize_route} // '/oauth/authorize'; 17 | my $token_route = $args->{access_token_route} // '/oauth/access_token'; 18 | 19 | my %valid_auth_params = ( 20 | client_id => 1, 21 | client_secret => 'boo', 22 | response_type => $grant_type eq 'token' ? 'token' : 'code', 23 | redirect_uri => 'https://client/cb', 24 | scope => 'eat', 25 | state => 'queasy', 26 | ); 27 | 28 | # now the testing begins 29 | my $t = Test::Mojo->new; 30 | my $auth_code; 31 | 32 | if ( $grant_type =~ /(authorization_code|token)/ ) { 33 | 34 | my $response_type = $grant_type eq 'token' ? 'token' : 'code'; 35 | 36 | note( "authorization request" ); 37 | 38 | note( " ... not authorized (missing params)" ); 39 | foreach my $form_params ( 40 | { response_type => $response_type, }, 41 | { client_id => 1 }, 42 | ) { 43 | $t->get_ok( $auth_route => form => $form_params ) 44 | ->status_is( 400 ) 45 | ->json_is( { 46 | error => 'invalid_request', 47 | error_description => 'the request was missing one of: client_id, ' 48 | . 'response_type;' 49 | . 'or response_type did not equal "code" or "token"', 50 | error_uri => '', 51 | } ) 52 | ; 53 | } 54 | 55 | note( " ... not authorized (errors)" ); 56 | 57 | foreach my $invalid_params ( 58 | { client_id => 2, error => 'unauthorized_client', }, 59 | { scope => 'cry', error => 'invalid_scope', }, 60 | ) { 61 | my $expected_error = delete( $invalid_params->{error} ); 62 | $t->get_ok( $auth_route => form => { 63 | %valid_auth_params, %{ $invalid_params } 64 | } ) 65 | ->status_is( 302 ) 66 | ; 67 | 68 | my $location = Mojo::URL->new( $t->tx->res->headers->location ); 69 | is( $location->path,'/cb','redirect to right place' ); 70 | ok( ! $location->query->param( 'code' ),'no code' ); 71 | is( $location->query->param( 'error' ),$expected_error,'expected error' ); 72 | } 73 | 74 | if ( $grant_type eq 'authorization_code' ) { 75 | # check this is enforced, i.e. we can't use implicit grant by 76 | # sending "token" rather than "code" 77 | my %force_implicit_grant = %valid_auth_params; 78 | delete( $force_implicit_grant{client_secret} ); 79 | $force_implicit_grant{response_type} = 'token'; 80 | 81 | $t->get_ok( $auth_route => form => \%force_implicit_grant ) 82 | ->status_is( 302 ) 83 | ; 84 | 85 | my $location = Mojo::URL->new( $t->tx->res->headers->location ); 86 | is( $location->path,'/cb','redirect to right place' ); 87 | ok( ! $location->query->param( 'code' ),'no code' ); 88 | ok( ! $location->query->param( 'access_token' ),'no code' ); 89 | is( $location->query->param( 'error' ),'unauthorized_client','expected error' ); 90 | } 91 | 92 | $t->get_ok( $auth_route => form => \%valid_auth_params ) 93 | ->status_is( 302 ) 94 | ; 95 | 96 | note( " ... authorized" ); 97 | my $location = Mojo::URL->new( $t->tx->res->headers->location ); 98 | is( $location->path,'/cb','redirect to right place' ); 99 | 100 | if ( $response_type eq 'token' ) { 101 | like( $location->fragment,qr/access_token/,'includes access_token' ); 102 | like( $location->fragment,qr/bearer/,'includes token_type' ); 103 | like( $location->fragment,qr/queasy/,'includes state' ); 104 | } else { 105 | ok( $auth_code = $location->query->param( 'code' ),'includes code' ); 106 | is( $location->query->param( 'state' ),'queasy','includes state' ); 107 | } 108 | } 109 | 110 | if ( $grant_type eq 'token' ) { 111 | 112 | my $location = Mojo::URL->new( $t->tx->res->headers->location ); 113 | note $location->fragment; 114 | ok( my ( $access_token ) = ( $location->fragment =~ qr/access_token=([^&]*)/ ),'includes token' ); 115 | 116 | $access_token = url_unescape( $access_token ); 117 | 118 | note( "don't use access token to access route" ); 119 | $t->get_ok('/api/eat')->status_is( 401 ); 120 | $t->get_ok('/api/sleep')->status_is( 401 ); 121 | 122 | note( "use access token to access route" ); 123 | 124 | $t->ua->on(start => sub { 125 | my ( $ua,$tx ) = @_; 126 | $tx->req->headers->header( 'Authorization' => "Bearer $access_token" ); 127 | }); 128 | 129 | $t->get_ok('/api/eat')->status_is( $args->{no_200_responses} ? 401 : 200 ); 130 | $t->get_ok('/api/sleep')->status_is( 401 ); 131 | 132 | return; 133 | } 134 | 135 | note( "access token" ); 136 | 137 | my %valid_token_params = ( 138 | client_id => 1, 139 | client_secret => 'boo', 140 | grant_type => $grant_type =~ s/_body$//r, 141 | ( $grant_type eq 'authorization_code' ? ( 142 | code => $auth_code, 143 | redirect_uri => $valid_auth_params{redirect_uri}, 144 | ) : ( 145 | username => 'bob', 146 | password => 'hey_ho!', 147 | scope => [ qw/ eat / ], 148 | ) ), 149 | ); 150 | 151 | note( " ... no token (missing params)" ); 152 | foreach my $form_params ( 153 | { response_type => 'code', }, 154 | { client_id => 1 }, 155 | ) { 156 | $t->post_ok( $token_route => form => $form_params ) 157 | ->status_is( 400 ) 158 | ->json_is( { 159 | error => 'invalid_request', 160 | error_description => 'the request was missing one of: grant_type, ' 161 | . 'client_id, client_secret, code, redirect_uri;' 162 | . 'or grant_type did not equal "authorization_code" ' 163 | . 'or "refresh_token"', 164 | error_uri => '', 165 | } ) 166 | ; 167 | } 168 | 169 | note( " ... no token (errors)" ); 170 | 171 | foreach my $invalid_params ( 172 | { client_id => 2, error => 'unauthorized_client', }, 173 | { client_secret => 'wee', error => 'unauthorized_client', }, 174 | ) { 175 | my $expected_error = delete( $invalid_params->{error} ); 176 | $t->post_ok( $token_route => form => { 177 | %valid_token_params, %{ $invalid_params } 178 | } ) 179 | ->status_is( 400 ) 180 | ; 181 | } 182 | 183 | my @post_args; 184 | 185 | if ( $grant_type =~ /client_credentials/ ) { 186 | 187 | my $encoded_client_details = b64_encode( join( ':',1,'boo' ) ); 188 | chomp( $encoded_client_details ); 189 | 190 | my $header = $grant_type =~ /body/ 191 | ? {} 192 | : { 'Authorization' => "Basic $encoded_client_details" }; 193 | 194 | my %valid_token_params = ( 195 | grant_type => $grant_type =~ s/_body$//r, 196 | scope => [ qw/ eat / ], 197 | 198 | ( $grant_type =~ /body/ 199 | ? ( client_id => 1, client_secret => 'boo' ) 200 | : () 201 | ), 202 | ); 203 | 204 | @post_args = ( %{ $header } 205 | ? ( $token_route => $header => form => \%valid_token_params ) 206 | : ( $token_route => form => \%valid_token_params ) 207 | ); 208 | 209 | } else { 210 | @post_args = ( $token_route => form => \%valid_token_params ); 211 | } 212 | 213 | $t->post_ok( @post_args ) 214 | ->status_is( $args->{no_200_responses} ? 400 : 200 ) 215 | ->header_is( 'Cache-Control' => 'no-store' ) 216 | ->header_is( 'Pragma' => 'no-cache' ) 217 | ; 218 | 219 | if ( $args->{no_200_responses} ) { 220 | 221 | cmp_deeply( 222 | $t->tx->res->json, 223 | { 224 | 'error' => 'unauthorized_client', 225 | }, 226 | 'json_is_deeply' 227 | ); 228 | 229 | } else { 230 | cmp_deeply( 231 | $t->tx->res->json, 232 | { 233 | scopes => ignore(), 234 | access_token => re( '^.+$' ), 235 | token_type => 'Bearer', 236 | expires_in => '3600', 237 | ( $grant_type =~ /client_credentials/ 238 | ? () 239 | : ( refresh_token => re( '^.+$' ) ) 240 | ), 241 | }, 242 | 'json_is_deeply' 243 | ); 244 | } 245 | 246 | my $access_token = $t->tx->res->json->{access_token} // ''; 247 | my $refresh_token = $t->tx->res->json->{refresh_token} // ''; 248 | 249 | note( "don't use access token to access route" ); 250 | $t->get_ok('/api/eat')->status_is( 401 ); 251 | $t->get_ok('/api/sleep')->status_is( 401 ); 252 | 253 | note( "use access token to access route" ); 254 | 255 | $t->ua->on(start => sub { 256 | my ( $ua,$tx ) = @_; 257 | $tx->req->headers->header( 'Authorization' => "Bearer $access_token" ); 258 | }); 259 | 260 | $t->get_ok('/api/eat')->status_is( $args->{no_200_responses} ? 401 : 200 ); 261 | $t->get_ok('/api/sleep')->status_is( 401 ); 262 | 263 | if ( $grant_type !~ /client_credentials/ ) { 264 | note( "refresh token cannot access routes" ); 265 | 266 | $t->ua->on(start => sub { 267 | my ( $ua,$tx ) = @_; 268 | $tx->req->headers->header( 'Authorization' => "Bearer $refresh_token" ); 269 | }); 270 | 271 | $t->get_ok('/api/eat')->status_is( 401 ); 272 | $t->get_ok('/api/sleep')->status_is( 401 ); 273 | 274 | note( "get a new access token using refresh token" ); 275 | 276 | my %valid_refresh_token_params = ( 277 | grant_type => 'refresh_token', 278 | refresh_token => $refresh_token, 279 | scope => 'eat', 280 | ); 281 | 282 | $t->post_ok( $token_route => form => \%valid_refresh_token_params ) 283 | ->status_is( 200 ) 284 | ->header_is( 'Cache-Control' => 'no-store' ) 285 | ->header_is( 'Pragma' => 'no-cache' ) 286 | ; 287 | 288 | cmp_deeply( 289 | $t->tx->res->json, 290 | { 291 | access_token => re( '^.+$' ), 292 | token_type => 'Bearer', 293 | expires_in => '3600', 294 | refresh_token => re( '^.+$' ), 295 | }, 296 | 'json_is_deeply' 297 | ); 298 | 299 | isnt( $t->tx->res->json->{access_token},$access_token,'new access_token' ); 300 | isnt( $t->tx->res->json->{refresh_token},$refresh_token,'new refresh_token' ); 301 | } 302 | 303 | return $t if $args->{skip_revoke_tests}; 304 | 305 | my $new_access_token = $t->tx->res->json->{access_token}; 306 | my $new_refresh_token = $t->tx->res->json->{refresh_token}; 307 | 308 | note( "previous access token revoked" ); 309 | 310 | $t->get_ok('/api/eat')->status_is( 401 ); 311 | $t->get_ok('/api/sleep')->status_is( 401 ); 312 | 313 | note( "new access token valid" ); 314 | 315 | $t->ua->on(start => sub { 316 | my ( $ua,$tx ) = @_; 317 | $tx->req->headers->header( 'Authorization' => "Bearer $new_access_token" ); 318 | }); 319 | 320 | $t->get_ok('/api/eat')->status_is( 200 ); 321 | $t->get_ok('/api/sleep')->status_is( 401 ); 322 | 323 | note( "access token (2nd time with same auth code fails)" ); 324 | 325 | $t->post_ok( $token_route => form => \%valid_token_params ) 326 | ->status_is( 400 ) 327 | ; 328 | 329 | note( " ... access revoked" ); 330 | $t->get_ok('/api/eat')->status_is( 401 ); 331 | $t->get_ok('/api/sleep')->status_is( 401 ); 332 | 333 | return $t; 334 | } 335 | 336 | 1; 337 | 338 | # vim: ts=2:sw=2:et 339 | -------------------------------------------------------------------------------- /lib/Mojolicious/Plugin/OAuth2/Server.pm: -------------------------------------------------------------------------------- 1 | package Mojolicious::Plugin::OAuth2::Server; 2 | 3 | =head1 NAME 4 | 5 | Mojolicious::Plugin::OAuth2::Server - Easier implementation of an OAuth2 6 | Authorization Server / Resource Server with Mojolicious 7 | 8 | =for html 9 | Build Status 10 | Coverage Status 11 | 12 | =head1 VERSION 13 | 14 | 0.52 15 | 16 | =head1 SYNOPSIS 17 | 18 | use Mojolicious::Lite; 19 | 20 | plugin 'OAuth2::Server' => { 21 | ... # see SYNOPSIS in Net::OAuth2::AuthorizationServer::Manual 22 | }; 23 | 24 | group { 25 | # /api - must be authorized 26 | under '/api' => sub { 27 | my ( $c ) = @_; 28 | 29 | return 1 if $c->oauth; # must be authorized via oauth 30 | 31 | $c->render( status => 401, text => 'Unauthorized' ); 32 | return undef; 33 | }; 34 | 35 | any '/annoy_friends' => sub { shift->render( text => "Annoyed Friends" ); }; 36 | any '/post_image' => sub { shift->render( text => "Posted Image" ); }; 37 | }; 38 | 39 | any '/track_location' => sub { 40 | my ( $c ) = @_; 41 | 42 | my $oauth_details = $c->oauth( 'track_location' ) 43 | || return $c->render( status => 401, text => 'You cannot track location' ); 44 | 45 | $c->render( text => "Target acquired: @{[$oauth_details->{user_id}]}" ); 46 | }; 47 | 48 | app->start; 49 | 50 | Or full fat app: 51 | 52 | use Mojo::Base 'Mojolicious'; 53 | 54 | ... 55 | 56 | sub startup { 57 | my $self = shift; 58 | 59 | ... 60 | 61 | $self->plugin( 'OAuth2::Server' => $oauth2_auth_code_grant_config ); 62 | } 63 | 64 | Then in your controller: 65 | 66 | sub my_route_name { 67 | my ( $c ) = @_; 68 | 69 | if ( my $oauth_details = $c->oauth( qw/required scopes/ ) ) { 70 | ... # do something, user_id, client_id, etc, available in $oauth_details 71 | } else { 72 | return $c->render( status => 401, text => 'Unauthorized' ); 73 | } 74 | 75 | ... 76 | } 77 | 78 | =head1 DESCRIPTION 79 | 80 | This plugin implements the various OAuth2 grant types flow as described at 81 | L. It is a complete implementation of 82 | RFC6749, with the exception of the "Extension Grants" as the description of 83 | that grant type is rather hand-wavy. 84 | 85 | The bulk of the functionality is implemented in the L 86 | distribution, you should see that for more comprehensive documentation and 87 | examples of usage. 88 | 89 | The examples here use the "Authorization Code Grant" flow as that is considered 90 | the most secure and most complete form of OAuth2. 91 | 92 | =cut 93 | 94 | use strict; 95 | use warnings; 96 | use base qw/ Mojolicious::Plugin /; 97 | 98 | use Mojo::URL; 99 | use Mojo::Parameters; 100 | use Mojo::Util qw/ b64_decode url_unescape /; 101 | use Net::OAuth2::AuthorizationServer; 102 | use Carp qw/ croak /; 103 | 104 | our $VERSION = '0.52'; 105 | 106 | my ( $AuthCodeGrant,$PasswordGrant,$ImplicitGrant,$ClientCredentialsGrant,$Grant,$JWTCallback ); 107 | 108 | =head1 METHODS 109 | 110 | =head2 register 111 | 112 | Registers the plugin with your app - note that you must pass callbacks for 113 | certain functions that the plugin expects to call if you are not using the 114 | plugin in its simplest form. 115 | 116 | $self->register($app, \%config); 117 | 118 | Registering the plugin will call the L 119 | and create a C that can be accessed using the defined 120 | C and C. The arguments passed to the 121 | plugin are passed straight through to the C method in 122 | the L module. 123 | 124 | =head2 oauth 125 | 126 | Checks if there is a valid Authorization: Bearer header with a valid access 127 | token and if the access token has the requisite scopes. The scopes are optional: 128 | 129 | unless ( my $oauth_details = $c->oauth( @scopes ) ) { 130 | return $c->render( status => 401, text => 'Unauthorized' ); 131 | } 132 | 133 | This calls the L 134 | module (C method) to validate the access/refresh token. 135 | 136 | =head2 oauth2_auth_request 137 | 138 | This is a helper to allow you get get the redirect URI instead of directing 139 | a user to the authorize_route - it requires the details of the client: 140 | 141 | my $redirect_uri = $c->oauth2_auth_request({ 142 | client_id => $client_id, 143 | redirect_uri => 'https://foo', 144 | response_type => 'token', 145 | scope => 'list,of,scopes', 146 | state => 'foo=bar&baz=boz', 147 | }); 148 | 149 | if ( $redirect_uri ) { 150 | # do something with $redirect_uri 151 | } else { 152 | # something didn't work, e.g. bad client, scopes, etc 153 | } 154 | 155 | You can use this helper instead of directing a user to the authorize_route if 156 | you need to do something more involved with the redirect_uri rather than 157 | having the plugin direct to the user to the resulting redirect uri 158 | 159 | =cut 160 | 161 | my $warned_dep = 0; 162 | 163 | sub register { 164 | my ( $self,$app,$config ) = @_; 165 | 166 | my $auth_route = $config->{authorize_route} // '/oauth/authorize'; 167 | my $atoken_route = $config->{access_token_route} // '/oauth/access_token'; 168 | 169 | if ( $config->{users} && ! $config->{jwt_secret} ) { 170 | croak "You MUST provide a jwt_secret to use the password grant (users supplied)"; 171 | } 172 | 173 | my $Server = Net::OAuth2::AuthorizationServer->new; 174 | 175 | # note that access_tokens and refresh_tokens will not be shared between 176 | # the various grant type objects, so if you need to support 177 | # both then you *must* either supply a jwt_secret or supply callbacks 178 | $AuthCodeGrant = $Server->auth_code_grant( 179 | ( map { +"${_}_cb" => ( $config->{$_} // undef ) } qw/ 180 | verify_client store_auth_code verify_auth_code 181 | store_access_token verify_access_token 182 | login_resource_owner confirm_by_resource_owner 183 | / ), 184 | %{ $config }, 185 | ); 186 | 187 | $PasswordGrant = $Server->password_grant( 188 | ( map { +"${_}_cb" => ( $config->{$_} // undef ) } qw/ 189 | verify_client verify_user_password 190 | store_access_token verify_access_token 191 | login_resource_owner confirm_by_resource_owner 192 | / ), 193 | %{ $config }, 194 | ); 195 | 196 | $ImplicitGrant = $Server->implicit_grant( 197 | ( map { +"${_}_cb" => ( $config->{$_} // undef ) } qw/ 198 | verify_client store_access_token verify_access_token 199 | login_resource_owner confirm_by_resource_owner 200 | / ), 201 | %{ $config }, 202 | ); 203 | 204 | $ClientCredentialsGrant = $Server->client_credentials_grant( 205 | ( map { +"${_}_cb" => ( $config->{$_} // undef ) } qw/ 206 | verify_client store_access_token verify_access_token 207 | / ), 208 | %{ $config }, 209 | ); 210 | 211 | $JWTCallback = $config->{jwt_claims}; 212 | 213 | $app->routes->get( 214 | $auth_route => sub { _authorization_request( @_ ) }, 215 | ); 216 | 217 | $app->helper( oauth2_auth_request => sub { 218 | my ( $c,$args ) = @_; 219 | _authorization_request( $c,$args,1 ); 220 | } ); 221 | 222 | $app->routes->post( 223 | $atoken_route => sub { _access_token_request( @_ ) }, 224 | ); 225 | 226 | $app->helper( 227 | oauth => sub { 228 | my $c = shift; 229 | my @scopes = @_; 230 | $Grant ||= $AuthCodeGrant; 231 | my @res = $Grant->verify_token_and_scope( 232 | scopes => [ @scopes ], 233 | auth_header => $c->req->headers->header( 'Authorization' ), 234 | mojo_controller => $c, 235 | ); 236 | 237 | my $oauth_details = $res[0]; 238 | 239 | if ( ref( $oauth_details ) ) { 240 | $oauth_details->{client_id} ||= $oauth_details->{client}; 241 | } 242 | 243 | return $oauth_details; 244 | }, 245 | ); 246 | } 247 | 248 | sub _authorization_request { 249 | my ( $self,$args,$is_helper ) = @_; 250 | 251 | $args //= {}; 252 | 253 | my $client_id = $args->{client_id} // $self->param( 'client_id' ); 254 | my $uri = $args->{redirect_uri} // $self->param( 'redirect_uri' ); 255 | my $type = $args->{response_type} // $self->param( 'response_type' ); 256 | my $scope = $args->{scope} // $self->param( 'scope' ); 257 | my $state = $args->{state} // $self->param( 'state' ); 258 | my $user_id = $args->{user_id} // undef; 259 | 260 | my @scopes = $scope ? split( / /,$scope ) : (); 261 | 262 | if ( 263 | ! defined( $client_id ) 264 | or ! defined( $type ) 265 | or $type !~ /^(code|token)$/ 266 | ) { 267 | $self->render( 268 | status => 400, 269 | json => { 270 | error => 'invalid_request', 271 | error_description => 'the request was missing one of: client_id, ' 272 | . 'response_type;' 273 | . 'or response_type did not equal "code" or "token"', 274 | error_uri => '', 275 | } 276 | ); 277 | return; 278 | } 279 | 280 | $Grant = $type eq 'token' ? $ImplicitGrant : $AuthCodeGrant; 281 | 282 | my $mojo_url = Mojo::URL->new( $uri ); 283 | my ( $res,$error,$error_description ) = $Grant->verify_client( 284 | client_id => $client_id, 285 | redirect_uri => $uri, 286 | scopes => [ @scopes ], 287 | mojo_controller => $self, 288 | response_type => $type, 289 | ); 290 | 291 | if ( $res ) { 292 | 293 | if ( ! $Grant->login_resource_owner( 294 | mojo_controller => $self, 295 | client_id => $client_id, 296 | ) ) { 297 | $self->app->log->debug( "OAuth2::Server: Resource owner not logged in" ); 298 | # call to $resource_owner_logged_in method should have called redirect_to 299 | return; 300 | } else { 301 | $self->app->log->debug( "OAuth2::Server: Resource owner is logged in" ); 302 | $res = $Grant->confirm_by_resource_owner( 303 | client_id => $client_id, 304 | scopes => [ @scopes ], 305 | mojo_controller => $self, 306 | ); 307 | if ( ! defined $res ) { 308 | $self->app->log->debug( "OAuth2::Server: Resource owner to confirm scopes" ); 309 | # call to $resource_owner_confirms method should have called redirect_to 310 | return; 311 | } 312 | elsif ( $res == 0 ) { 313 | $self->app->log->debug( "OAuth2::Server: Resource owner denied scopes" ); 314 | $error = 'access_denied'; 315 | $error_description //= 'resource owner denied access'; 316 | } 317 | } 318 | } 319 | 320 | if ( $res ) { 321 | 322 | return _maybe_generate_access_token( 323 | $self,$mojo_url,$client_id,[ @scopes ],$state,$is_helper,$user_id 324 | ) if $type eq 'token'; # implicit grant 325 | 326 | $self->app->log->debug( "OAuth2::Server: Generating auth code for $client_id" ); 327 | my $auth_code = $Grant->token( 328 | client_id => $client_id, 329 | scopes => [ @scopes ], 330 | type => 'auth', 331 | redirect_uri => $uri, 332 | jwt_claims_cb => $JWTCallback, 333 | user_id => $user_id, 334 | ); 335 | 336 | $Grant->store_auth_code( 337 | auth_code => $auth_code, 338 | client_id => $client_id, 339 | expires_in => $Grant->auth_code_ttl, 340 | redirect_uri => $uri, 341 | scopes => [ @scopes ], 342 | mojo_controller => $self, 343 | ); 344 | 345 | $mojo_url->query->append( code => $auth_code ); 346 | 347 | } elsif ( $error ) { 348 | $mojo_url->query->append( error => $error ); 349 | $mojo_url->query->append( error_description => $error_description ) if $error_description; 350 | } else { 351 | # callback has not returned anything, assume server error 352 | $mojo_url->query->append( 353 | error => 'server_error', 354 | error_description => 'call to verify_client returned unexpected value', 355 | ); 356 | } 357 | 358 | $mojo_url->query->append( state => $state ) if defined( $state ); 359 | 360 | return $is_helper ? $mojo_url : $self->redirect_to( $mojo_url ); 361 | } 362 | 363 | sub _maybe_generate_access_token { 364 | my ( $self,$mojo_url,$client,$scope,$state,$is_helper,$user_id ) = @_; 365 | 366 | my $access_token_ttl = $Grant->can('get_access_token_ttl') 367 | ? $Grant->get_access_token_ttl( 368 | scopes => $scope, 369 | client_id => $client, 370 | ) 371 | : $Grant->access_token_ttl; 372 | 373 | my $access_token = $Grant->token( 374 | client_id => $client, 375 | scopes => $scope, 376 | type => 'access', 377 | jwt_claims_cb => $JWTCallback, 378 | user_id => $user_id, 379 | ); 380 | 381 | $Grant->store_access_token( 382 | client_id => $client, 383 | access_token => $access_token, 384 | expires_in => $access_token_ttl, 385 | scopes => $scope, 386 | mojo_controller => $self, 387 | ); 388 | 389 | # http://example.com/cb#access_token=2YotnFZFEjr1zCsicMWpAA 390 | # &state=xyz&token_type=example&expires_in=3600 391 | my $params = Mojo::Parameters->new( 392 | access_token => $access_token, 393 | token_type => 'bearer', 394 | expires_in => $access_token_ttl, 395 | ( $state 396 | ? ( state => $state ) 397 | : (), 398 | ) 399 | ); 400 | 401 | $mojo_url->fragment( url_unescape( $params->to_string ) ); 402 | return $is_helper ? $mojo_url : $self->redirect_to( $mojo_url ); 403 | } 404 | 405 | sub _access_token_request { 406 | my ( $self ) = @_; 407 | 408 | my ( 409 | $client_id,$client_secret,$grant_type,$auth_code,$uri, 410 | $refresh_token,$username,$password 411 | ) = map { $self->param( $_ ) // undef } qw/ 412 | client_id client_secret grant_type code redirect_uri 413 | refresh_token username password 414 | /; 415 | 416 | $grant_type //=''; 417 | 418 | _access_token_request_check_params( 419 | $self,$grant_type,$username,$password,$auth_code,$uri 420 | ) || return; 421 | 422 | my $json_response = {}; 423 | my $status = 400; 424 | 425 | $Grant = $grant_type eq 'password' 426 | ? $PasswordGrant : $grant_type eq 'client_credentials' 427 | ? $ClientCredentialsGrant : $AuthCodeGrant; 428 | 429 | my ( $client,$error,$scope,$user_id,$old_refresh_token,$error_description ) = _verify_credentials( 430 | $self,$Grant,$grant_type,$refresh_token,$client_id,$client_secret, 431 | $auth_code,$username,$password,$uri 432 | ); 433 | 434 | if ( $client ) { 435 | 436 | $self->app->log->debug( "OAuth2::Server: Generating access token for @{[ ref $client ? $client->{client} : $client ]}" ); 437 | 438 | my $access_token_ttl = $Grant->can('get_access_token_ttl') 439 | ? $Grant->get_access_token_ttl( 440 | scopes => $scope, 441 | client_id => $client_id, 442 | ) 443 | : $Grant->access_token_ttl; 444 | 445 | my $access_token = $Grant->token( 446 | client_id => $client, 447 | scopes => $scope, 448 | type => 'access', 449 | user_id => $user_id, 450 | jwt_claims_cb => $JWTCallback, 451 | ); 452 | 453 | my $refresh_token = $Grant->token( 454 | client_id => $client, 455 | scopes => $scope, 456 | type => 'refresh', 457 | user_id => $user_id, 458 | jwt_claims_cb => $JWTCallback, 459 | ); 460 | 461 | $Grant->store_access_token( 462 | client_id => $client, 463 | ( $grant_type ne 'password' ? ( auth_code => $auth_code ) : () ), 464 | access_token => $access_token, 465 | expires_in => $access_token_ttl, 466 | scopes => $scope, 467 | ( $grant_type eq 'client_credentials' 468 | ? () 469 | : ( 470 | refresh_token => $refresh_token, 471 | old_refresh_token => $old_refresh_token, 472 | ) 473 | ), 474 | mojo_controller => $self, 475 | ); 476 | 477 | $status = 200; 478 | $json_response = { 479 | # RFC6749 section 5.1 says that Access Token Response should include 480 | # the authorized scope when it does not match the requested scopes: 481 | # 482 | # OPTIONAL, if identical to the scope requested by the client; 483 | # otherwise, REQUIRED. The scope of the access token as 484 | # described by Section 3.3. 485 | ( ! $old_refresh_token && $scope 486 | ? ( scopes => ref( $scope ) eq 'HASH' 487 | ? [ map { $_ } grep { $scope->{$_} } keys %{ $scope } ] 488 | : $scope ) 489 | : ()), 490 | 491 | access_token => $access_token, 492 | token_type => 'Bearer', 493 | expires_in => $access_token_ttl, 494 | ( $grant_type eq 'client_credentials' 495 | ? () 496 | : ( refresh_token => $refresh_token ), 497 | ) 498 | }; 499 | 500 | } elsif ( $error ) { 501 | $json_response->{error} = $error; 502 | $json_response->{error_description} = $error_description if $error_description; 503 | } else { 504 | # callback has not returned anything, assume server error 505 | my $method = $grant_type eq 'password' 506 | ? 'verify_user_password' : $grant_type eq 'client_credentials' 507 | ? 'verify_client' : 'verify_auth_code'; 508 | 509 | $json_response = { 510 | error => 'server_error', 511 | error_description => "call to $method returned unexpected value", 512 | }; 513 | } 514 | 515 | $self->res->headers->header( 'Cache-Control' => 'no-store' ); 516 | $self->res->headers->header( 'Pragma' => 'no-cache' ); 517 | 518 | $self->render( 519 | status => $status, 520 | json => $json_response, 521 | ); 522 | } 523 | 524 | sub _access_token_request_check_params { 525 | my ( $self,$grant_type,$username,$password,$auth_code,$uri ) = @_; 526 | 527 | if ( 528 | $grant_type eq 'password' 529 | ) { 530 | if ( ! $username && ! $password ) { 531 | $self->render( 532 | status => 400, 533 | json => { 534 | error => 'invalid_request', 535 | error_description => 'the request was missing one of: ' 536 | . 'client_id, client_secret, username, password', 537 | error_uri => '', 538 | } 539 | ); 540 | return 0; 541 | } 542 | } elsif ( 543 | $grant_type eq 'client_credentials' 544 | ) { 545 | my ( $client_id,$client_secret ) = _client_credentials_from_header( $self ); 546 | 547 | if ( ! $client_id || ! $client_secret ) { 548 | $self->render( 549 | status => 400, 550 | json => { 551 | error => 'invalid_request', 552 | error_description => 'the request was missing an Authorization: Basic' 553 | . ' header or it was missing the encoded client_id:client_secret data', 554 | error_uri => '', 555 | } 556 | ); 557 | return 0; 558 | } 559 | } elsif ( 560 | ( $grant_type ne 'authorization_code' and $grant_type ne 'refresh_token' ) 561 | or ( $grant_type eq 'authorization_code' and ! defined( $auth_code ) ) 562 | or ( $grant_type eq 'authorization_code' and ! defined( $uri ) ) 563 | ) { 564 | $self->render( 565 | status => 400, 566 | json => { 567 | error => 'invalid_request', 568 | error_description => 'the request was missing one of: grant_type, ' 569 | . 'client_id, client_secret, code, redirect_uri;' 570 | . 'or grant_type did not equal "authorization_code" ' 571 | . 'or "refresh_token"', 572 | error_uri => '', 573 | } 574 | ); 575 | return 0; 576 | } 577 | 578 | return 1; 579 | } 580 | 581 | sub _client_credentials_from_header { 582 | my ( $self ) = @_; 583 | 584 | my ( $client_id,$client_secret ); 585 | 586 | # params in the header 587 | if ( my $auth_header = $self->req->headers->header( 'Authorization' ) ) { 588 | if ( my ( $encoded_details ) = ( split( 'Basic ',$auth_header ) )[1] ) { 589 | my $decoded_details = b64_decode( $encoded_details // '' ); 590 | ( $client_id,$client_secret ) = split( ':',$decoded_details, 2); 591 | return ( $client_id,$client_secret ); 592 | } 593 | 594 | # params in the body 595 | } elsif ( $client_id = $self->req->param('client_id') ) { 596 | $client_secret = $self->req->param('client_secret'); 597 | } 598 | 599 | if ( $client_id && $client_secret ) { 600 | return ( $client_id,$client_secret ); 601 | } 602 | } 603 | 604 | sub _verify_credentials { 605 | my ( 606 | $self,$Grant,$grant_type,$refresh_token,$client_id,$client_secret, 607 | $auth_code,$username,$password,$uri 608 | ) = @_; 609 | 610 | my ( $client,$error,$scope,$user_id,$old_refresh_token,$error_description ); 611 | 612 | if ( $grant_type eq 'refresh_token' ) { 613 | ( $client,$error,$scope,$user_id,$error_description ) = $Grant->verify_token_and_scope( 614 | refresh_token => $refresh_token, 615 | auth_header => $self->req->headers->header( 'Authorization' ), 616 | mojo_controller => $self, 617 | ); 618 | $old_refresh_token = $refresh_token; 619 | 620 | } elsif ( $grant_type eq 'password' ) { 621 | $scope = $self->every_param( 'scope' ); 622 | 623 | ( $client,$error,$scope,$user_id,$error_description ) = $Grant->verify_user_password( 624 | client_id => $client_id, 625 | client_secret => $client_secret, 626 | username => $username, 627 | password => $password, 628 | mojo_controller => $self, 629 | scopes => $scope, 630 | ); 631 | } elsif ( $grant_type eq 'client_credentials' ) { 632 | 633 | my $client_secret; 634 | 635 | ( $client,$client_secret ) = _client_credentials_from_header( $self ); 636 | 637 | $scope = $self->every_param( 'scope' ); 638 | my $res; 639 | 640 | ( $res,$error,$error_description ) = $Grant->verify_client( 641 | client_id => $client, 642 | client_secret => $client_secret, 643 | mojo_controller => $self, 644 | scopes => $scope, 645 | ); 646 | 647 | undef( $client ) if ! $res; 648 | 649 | } else { 650 | ( $client,$error,$scope,$user_id,$error_description ) = $Grant->verify_auth_code( 651 | client_id => $client_id, 652 | client_secret => $client_secret, 653 | auth_code => $auth_code, 654 | redirect_uri => $uri, 655 | mojo_controller => $self, 656 | ); 657 | } 658 | 659 | $client = ! ref $client 660 | ? $client 661 | : ( $client->{client_id} || $client->{client} ); 662 | 663 | return ( $client,$error,$scope,$user_id,$old_refresh_token,$error_description ); 664 | } 665 | 666 | =head1 SEE ALSO 667 | 668 | L - The dist that handles the bulk of the 669 | functionality used by this plugin 670 | 671 | =head1 AUTHOR & CONTRIBUTORS 672 | 673 | Lee Johnson - C 674 | 675 | With contributions from: 676 | 677 | Nick Logan C 678 | 679 | Pierre VIGIER C 680 | 681 | Renee C 682 | 683 | =head1 LICENSE 684 | 685 | This library is free software; you can redistribute it and/or modify it under 686 | the same terms as Perl itself. If you would like to contribute documentation 687 | or file a bug report then please raise an issue / pull request: 688 | 689 | https://github.com/Humanstate/mojolicious-plugin-oauth2-server 690 | 691 | =cut 692 | 693 | 1; 694 | 695 | # vim: ts=2:sw=2:et 696 | --------------------------------------------------------------------------------