package Mojolicious::Plugin::OAuth2;
use Mojo::Base 'Mojolicious::Plugin';

use Carp qw(croak);
use Mojo::Promise;
use Mojo::URL;
use Mojo::UserAgent;

use constant MOJO_JWT => eval 'use Mojo::JWT 0.09; use Crypt::OpenSSL::RSA; use Crypt::OpenSSL::Bignum; 1';

our @CARP_NOT = qw(Mojolicious::Plugin::OAuth2 Mojolicious::Renderer);
our $VERSION = '2.02';

has providers => sub {
  return {
    dailymotion => {
      authorize_url => 'https://api.dailymotion.com/oauth/authorize',
      token_url     => 'https://api.dailymotion.com/oauth/token'
    },
    debian_salsa => {
      authorize_url => 'https://salsa.debian.org/oauth/authorize?response_type=code',
      token_url     => 'https://salsa.debian.org/oauth/token',
    },
    eventbrite => {
      authorize_url => 'https://www.eventbrite.com/oauth/authorize',
      token_url     => 'https://www.eventbrite.com/oauth/token',
    },
    facebook => {
      authorize_url => 'https://graph.facebook.com/oauth/authorize',
      token_url     => 'https://graph.facebook.com/oauth/access_token',
    },
    instagram => {
      authorize_url => 'https://api.instagram.com/oauth/authorize/?response_type=code',
      token_url     => 'https://api.instagram.com/oauth/access_token',
    },
    github => {
      authorize_url => 'https://github.com/login/oauth/authorize',
      token_url     => 'https://github.com/login/oauth/access_token',
    },
    google => {
      authorize_url => 'https://accounts.google.com/o/oauth2/v2/auth?response_type=code',
      token_url     => 'https://www.googleapis.com/oauth2/v4/token',
    },
    vkontakte => {authorize_url => 'https://oauth.vk.com/authorize', token_url => 'https://oauth.vk.com/access_token',},
    mocked => {authorize_url => '/mocked/oauth/authorize', token_url => '/mocked/oauth/token', secret => 'fake_secret'},
  };
};

has _ua => sub { Mojo::UserAgent->new };

sub register {
  my ($self, $app, $config) = @_;

  if ($config->{providers}) {
    $self->_config_to_providers($config->{providers});
    $self->_ua($config->{ua}) if $config->{ua};
    $self->_ua->proxy->detect if $config->{proxy};
  }
  else {
    $self->_config_to_providers($config);
  }

  $app->helper('oauth2.auth_url'            => sub { $self->_call(_auth_url            => @_) });
  $app->helper('oauth2.get_refresh_token_p' => sub { $self->_call(_get_refresh_token_p => @_) });
  $app->helper('oauth2.get_token_p'         => sub { $self->_call(_get_token_p         => @_) });
  $app->helper('oauth2.jwt_decode'          => sub { $self->_call(_jwt_decode          => @_) });
  $app->helper('oauth2.logout_url'          => sub { $self->_call(_logout_url          => @_) });
  $app->helper('oauth2.providers'           => sub { $self->providers });

  $self->_apply_mock($self->providers->{mocked}) if $self->providers->{mocked}{key};
  $self->_warmup_openid($app);
}

sub _apply_mock {
  my ($self, $provider_args) = @_;

  require Mojolicious::Plugin::OAuth2::Mock;
  require Mojolicious;
  my $app = $self->_ua->server->app || Mojolicious->new;
  Mojolicious::Plugin::OAuth2::Mock->apply_to($app, $provider_args);
  $self->_ua->server->app($app);
}

sub _auth_url {
  my ($self, $c, $args) = @_;
  my $provider_args = $self->providers->{$args->{provider}};
  my $authorize_url;

  $args->{scope}        ||= $provider_args->{scope};
  $args->{redirect_uri} ||= $c->url_for->to_abs->to_string;
  $authorize_url = Mojo::URL->new($provider_args->{authorize_url});
  $authorize_url->host($args->{host}) if exists $args->{host};
  $authorize_url->query->append(client_id => $provider_args->{key}, redirect_uri => $args->{redirect_uri});
  $authorize_url->query->append(scope     => $args->{scope}) if defined $args->{scope};
  $authorize_url->query->append(state     => $args->{state}) if defined $args->{state};
  $authorize_url->query($args->{authorize_query}) if exists $args->{authorize_query};
  $authorize_url;
}

sub _call {
  my ($self, $method, $c, $provider) = (shift, shift, shift, shift);
  my $args = @_ % 2 ? shift : {@_};
  $args->{provider} = $provider || 'unknown';
  croak "Invalid provider: $args->{provider}" unless $self->providers->{$args->{provider}};
  return $self->$method($c, $args);
}

sub _config_to_providers {
  my ($self, $config) = @_;

  for my $provider (keys %$config) {
    my $p = $self->providers->{$provider} ||= {};
    for my $key (keys %{$config->{$provider}}) {
      $p->{$key} = $config->{$provider}{$key};
    }
  }
}

sub _get_refresh_token_p {
  my ($self, $c, $args) = @_;

  # TODO: Handle error response from oidc provider callback URL, if possible
  my $err = $c->param('error_description') || $c->param('error');
  return Mojo::Promise->reject($err) if $err;

  my $provider_args = $self->providers->{$args->{provider}};
  my $params        = {
    client_id     => $provider_args->{key},
    client_secret => $provider_args->{secret},
    grant_type    => 'refresh_token',
    refresh_token => $args->{refresh_token},
    scope         => $provider_args->{scope},
  };

  my $token_url = Mojo::URL->new($provider_args->{token_url});
  $token_url->host($args->{host}) if exists $args->{host};

  return $self->_ua->post_p($token_url, form => $params)->then(sub { $self->_parse_provider_response(@_) });
}

sub _get_token_p {
  my ($self, $c, $args) = @_;

  # Handle error response from provider callback URL
  my $err = $c->param('error_description') || $c->param('error');
  return Mojo::Promise->reject($err) if $err;

  # No error or code response from provider callback URL
  unless ($c->param('code')) {
    $c->redirect_to($self->_auth_url($c, $args)) if $args->{redirect} // 1;
    return Mojo::Promise->resolve(undef);
  }

  # Handle "code" from provider callback
  my $provider_args = $self->providers->{$args->{provider}};
  my $params        = {
    client_id     => $provider_args->{key},
    client_secret => $provider_args->{secret},
    code          => scalar($c->param('code')),
    grant_type    => 'authorization_code',
    redirect_uri  => $args->{redirect_uri} || $c->url_for->to_abs->to_string,
  };

  $params->{state} = $c->param('state') if $c->param('state');

  my $token_url = Mojo::URL->new($provider_args->{token_url});
  $token_url->host($args->{host}) if exists $args->{host};

  return $self->_ua->post_p($token_url, form => $params)->then(sub { $self->_parse_provider_response(@_) });
}

sub _jwt_decode {
  my $peek = ref $_[-1] eq 'CODE' && pop;
  my ($self, $c, $args) = @_;
  croak 'Provider does not have "jwt" defined.' unless my $jwt = $self->providers->{$args->{provider}}{jwt};
  return $jwt->decode($args->{data}, $peek);
}

sub _logout_url {
  my ($self, $c, $args) = @_;
  return Mojo::URL->new($self->providers->{$args->{provider}}{end_session_url})->tap(
    query => {
      post_logout_redirect_uri => $args->{post_logout_redirect_uri},
      id_token_hint            => $args->{id_token_hint},
      state                    => $args->{state}
    }
  );
}

sub _parse_provider_response {
  my ($self, $tx) = @_;
  my $code = $tx->res->code || 'No response';

  # Will cause the promise to be rejected
  return Mojo::Promise->reject(sprintf '%s == %s', $tx->req->url, $tx->error->{message} // $code) if $code ne '200';
  return $tx->res->headers->content_type =~ m!^(application/json|text/javascript)(;\s*charset=\S+)?$!
    ? $tx->res->json
    : Mojo::Parameters->new($tx->res->body)->to_hash;
}

sub _warmup_openid {
  my ($self, $app) = (shift, shift);

  my ($providers, @p) = ($self->providers);
  for my $provider (values %$providers) {
    next unless $provider->{well_known_url};
    $app->log->debug("Fetching OpenID configuration from $provider->{well_known_url}");
    push @p, $self->_warmup_openid_provider_p($app, $provider);
  }

  return @p && Mojo::Promise->all(@p)->wait;
}

sub _warmup_openid_provider_p {
  my ($self, $app, $provider) = @_;

  return $self->_ua->get_p($provider->{well_known_url})->then(sub {
    my $tx  = shift;
    my $res = $tx->result->json;
    $provider->{authorize_url}   = $res->{authorization_endpoint};
    $provider->{end_session_url} = $res->{end_session_endpoint};
    $provider->{issuer}          = $res->{issuer};
    $provider->{token_url}       = $res->{token_endpoint};
    $provider->{userinfo_url}    = $res->{userinfo_endpoint};
    $provider->{scope} //= 'openid';

    return $self->_ua->get_p($res->{jwks_uri});
  })->then(sub {
    my $tx = shift;
    $provider->{jwt} = Mojo::JWT->new->add_jwkset($tx->result->json);
    return $provider;
  })->catch(sub {
    my $err = shift;
    $app->log->error("[OAuth2] Failed to warm up $provider->{well_known_url}: $err");
  });
}

1;

=head1 NAME

Mojolicious::Plugin::OAuth2 - Auth against OAuth2 APIs including OpenID Connect

=head1 SYNOPSIS

=head2 Example application

  use Mojolicious::Lite;

  plugin OAuth2 => {
    providers => {
      facebook => {
        key    => 'some-public-app-id',
        secret => $ENV{OAUTH2_FACEBOOK_SECRET},
      },
    },
  };

  get '/connect' => sub {
    my $c         = shift;
    my %get_token = (redirect_uri => $c->url_for('connect')->userinfo(undef)->to_abs);

    return $c->oauth2->get_token_p(facebook => \%get_token)->then(sub {
      # Redirected to Facebook
      return unless my $provider_res = shift;

      # Token received
      $c->session(token => $provider_res->{access_token});
      $c->redirect_to('profile');
    })->catch(sub {
      $c->render('connect', error => shift);
    });
  };

See L</register> for more details about the configuration this plugin takes.

=head2 Testing

Code using this plugin can perform offline testing, using the "mocked"
provider:

  $app->plugin(OAuth2 => {mocked => {key => 42}});
  $app->routes->get('/profile' => sub {
    my $c = shift;

    state $mocked = $ENV{TEST_MOCKED} && 'mocked';
    return $c->oauth2->get_token_p($mocked || 'facebook')->then(sub {
      ...
    });
  });

See L<Mojolicious::Plugin::OAuth2::Mock> for more details.

=head2 Connect button

You can add a "connect link" to your template using the L</oauth2.auth_url>
helper. Example template:

  Click here to log in:
  <%= link_to 'Connect!', $c->oauth2->auth_url('facebook', scope => 'user_about_me email') %>

=head1 DESCRIPTION

This Mojolicious plugin allows you to easily authenticate against a
L<OAuth2|http://oauth.net> or L<OpenID Connect|https://openid.net/connect/>
provider. It includes configurations for a few popular L<providers|/register>,
but you can add your own as well.

See L</register> for a full list of bundled providers.

To support "OpenID Connect", the following optional modules must be installed
manually: L<Crypt::OpenSSL::Bignum>, L<Crypt::OpenSSL::RSA> and L<Mojo::JWT>.
The modules can be installed with L<App::cpanminus>:

  $ cpanm Crypt::OpenSSL::Bignum Crypt::OpenSSL::RSA Mojo::JWT

=head1 HELPERS

=head2 oauth2.auth_url

  $url = $c->oauth2->auth_url($provider_name => \%args);

Returns a L<Mojo::URL> object which contain the authorize URL. This is
useful if you want to add the authorize URL as a link to your webpage
instead of doing a redirect like L</oauth2.get_token> does. C<%args> is optional,
but can contain:

=over 2

=item * host

Useful if your provider uses different hosts for accessing different accounts.
The default is specified in the provider configuration.

  $url->host($host);

=item * authorize_query

Either a hash-ref or an array-ref which can be used to give extra query
params to the URL.

  $url->query($authorize_url);

=item * redirect_uri

Useful if you want to go back to a different page than what you came from.
The default is:

  $c->url_for->to_abs->to_string

=item * scope

Scope to ask for credentials to. Should be a space separated list.

=item * state

A string that will be sent to the identity provider. When the user returns
from the identity provider, this exact same string will be carried with the user,
as a GET parameter called C<state> in the URL that the user will return to.

=back

=head2 oauth2.get_refresh_token_p

  $promise = $c->oauth2->get_refresh_token_p($provider_name => \%args);

When L<Mojolicious::Plugin::OAuth2> is being used in OpenID Connect mode this
helper allows for a token to be refreshed by specifying a C<refresh_token> in
C<%args>. Usage is similar to L</"oauth2.get_token_p">.

=head2 oauth2.get_token_p

  $promise = $c->oauth2->get_token_p($provider_name => \%args)
               ->then(sub { my $provider_res = shift })
               ->catch(sub { my $err = shift; });

L</oauth2.get_token_p> is used to either fetch an access token from an OAuth2
provider, handle errors or redirect to OAuth2 provider.  C<$err> in the
rejection handler holds a error description if something went wrong.
C<$provider_res> is a hash-ref containing the access token from the OAauth2
provider or C<undef> if this plugin performed a 302 redirect to the provider's
connect website.

In more detail, this method will do one of two things:

=over 2

=item 1.

When called from an action on your site, it will redirect you to the provider's
C<authorize_url>. This site will probably have some sort of "Connect" and
"Reject" button, allowing the visitor to either connect your site with his/her
profile on the OAuth2 provider's page or not.

=item 2.

The OAuth2 provider will redirect the user back to your site after clicking the
"Connect" or "Reject" button. C<$provider_res> will then contain a key
"access_token" on "Connect" and a false value on "Reject".

=back

The method takes these arguments: C<$provider_name> need to match on of
the provider names under L</Configuration> or a custom provider defined
when L<registering|/SYNOPSIS> the plugin.

C<%args> can have:

=over 2

=item * host

Useful if your provider uses different hosts for accessing different accounts.
The default is specified in the provider configuration.

=item * redirect

Set C<redirect> to 0 to disable automatic redirect.

=item * scope

Scope to ask for credentials to. Should be a space separated list.

=back

=head2 oauth2.jwt_decode

  $claims = $c->oauth2->jwt_decode($provider, sub { my $jwt = shift; ... });
  $claims = $c->oauth2->jwt_decode($provider);

When L<Mojolicious::Plugin::OAuth2> is being used in OpenID Connect mode this
helper allows you to decode the response data encoded with the JWKS discovered
from C<well_known_url> configuration.

=head2 oauth2.logout_url

  $url = $c->oauth2->logout_url($provider_name => \%args);

When L<Mojolicious::Plugin::OAuth2> is being used in OpenID Connect mode this
helper creates the url to redirect to end the session. The OpenID Connect
Provider will redirect to the C<post_logout_redirect_uri> provided in C<%args>.
Additional keys for C<%args> are C<id_token_hint> and C<state>.

=head2 oauth2.providers

  $hash_ref = $c->oauth2->providers;

This helper allow you to access the raw providers mapping, which looks
something like this:

  {
    facebook => {
      authorize_url => "https://graph.facebook.com/oauth/authorize",
      token_url     => "https://graph.facebook.com/oauth/access_token",
      key           => ...,
      secret        => ...,
    },
    ...
  }

=head1 ATTRIBUTES

=head2 providers

  $hash_ref = $oauth2->providers;

Holds a hash of provider information. See L</oauth2.providers>.

=head1 METHODS

=head2 register

  $app->plugin(OAuth2 => \%provider_config);
  $app->plugin(OAuth2 => {providers => \%provider_config, proxy => 1, ua => Mojo::UserAgent->new});

Will register this plugin in your application with a given C<%provider_config>.
The keys in C<%provider_config> are provider names and the values are
configuration for each provider. Note that the value will be merged with the
predefined providers below.

Instead of just passing in C<%provider_config>, it is possible to pass in a
more complex config, with these keys:

=over 2

=item * providers

The C<%provider_config> must be present under this key.

=item * proxy

Setting this to a true value will automatically detect proxy settings using
L<Mojo::UserAgent::Proxy/detect>.

=item * ua

A custom L<Mojo::UserAgent>, in case you want to change proxy settings,
timeouts or other attributes.

=back

Instead of just passing in C<%provider_config>, it is possible to pass in a
hash-ref "providers" (C<%provider_config>) and "ua" (a custom
L<Mojo::UserAgent> object).

Here is an example to add adddition information like "key" and "secret":

  $app->plugin(OAuth2 => {
    providers => {
      custom_provider => {
        key           => 'APP_ID',
        secret        => 'SECRET_KEY',
        authorize_url => 'https://provider.example.com/auth',
        token_url     => 'https://provider.example.com/token',
      },
      github => {
        key    => 'APP_ID',
        secret => 'SECRET_KEY',
      },
    },
  });

For L<OpenID Connect|https://openid.net/connect/>, C<authorize_url> and C<token_url> are configured from the
C<well_known_url> so these are replaced by the C<well_known_url> key.

  $app->plugin(OAuth2 => {
    providers => {
      azure_ad => {
        key            => 'APP_ID',
        secret         => 'SECRET_KEY',
        well_known_url => 'https://login.microsoftonline.com/tenant-id/v2.0/.well-known/openid-configuration',
      },
    },
  });

To make it a bit easier the are already some predefined providers bundled with
this plugin:

=head3 dailymotion

Authentication for L<https://www.dailymotion.com/> video site.

=head3 debian_salsa

Authentication for L<https://salsa.debian.org/>.

=head3 eventbrite

Authentication for L<https://www.eventbrite.com> event site.

See also L<http://developer.eventbrite.com/docs/auth/>.

=head3 facebook

OAuth2 for Facebook's graph API, L<http://graph.facebook.com/>. You can find
C<key> (App ID) and C<secret> (App Secret) from the app dashboard here:
L<https://developers.facebook.com/apps>.

See also L<https://developers.facebook.com/docs/reference/dialogs/oauth/>.

=head3 instagram

OAuth2 for Instagram API. You can find C<key> (Client ID) and
C<secret> (Client Secret) from the app dashboard here:
L<https://www.instagram.com/developer/clients/manage/>.

See also L<https://www.instagram.com/developer/authentication/>.

=head3 github

Authentication with Github.

See also L<https://developer.github.com/v3/oauth/>.

=head3 google

OAuth2 for Google. You can find the C<key> (CLIENT ID) and C<secret>
(CLIENT SECRET) from the app console here under "APIs & Auth" and
"Credentials" in the menu at L<https://console.developers.google.com/project>.

See also L<https://developers.google.com/+/quickstart/>.

=head3 vkontakte

OAuth2 for Vkontakte. You can find C<key> (App ID) and C<secret>
(Secure key) from the app dashboard here: L<https://vk.com/apps?act=manage>.

See also L<https://vk.com/dev/authcode_flow_user>.

=head1 AUTHOR

Marcus Ramberg - C<mramberg@cpan.org>

Jan Henning Thorsen - C<jhthorsen@cpan.org>

=head1 LICENSE

This software is licensed under the same terms as Perl itself.

=head1 SEE ALSO

=over 2

=item * L<http://oauth.net/documentation/>

=item * L<http://aaronparecki.com/articles/2012/07/29/1/oauth2-simplified>

=item * L<http://homakov.blogspot.jp/2013/03/oauth1-oauth2-oauth.html>

=item * L<http://en.wikipedia.org/wiki/OAuth#OAuth_2.0>

=item * L<https://openid.net/connect/>

=back

=cut
