読者です 読者をやめる 読者になる 読者になる

Plack::Middleware::OAuth::Lite的なものを書いてる

TwitterとかGitHubユーザに対して自前のWebアプリケーションでOAuth認証するためのPlack::Middlewareを書いてる.

同様の動作をするモジュールとしてすでに Plack::Middleware::OAuth があったけど,Session周りでよくわからないエラーがでてよくわからなかったから,自分で書き始めた.

(よくわからないエラーというのは,$env->{psgix.session}と$env->{psgix.session.options}がundefになってて,Plack::Session->new($env)したときに,これらがHASHリファレンスじゃないと怒られる感じだった.

結局,Middlewareの読み込み順が間違ってて,Plack::Middleware::Sessionを先に書いてないだけだった.
Plack::Middleware::OAuthはPlack::Middleware::Sessionまたは他のSessionストレージの使用が前提になっている気がする.

builder {
   
    enable "Plack::Middleware::Session", # 先に書く
        ...

    enable 'Plack::Middleware::OAuth',
       ...

    $app
};

)

Plack::Middleware::OAuth::Liteは,各プロバイダのエンドポイントなどの設定を内部に持っていなくて,enable時に設定を書くようになっている.

あと,OAuth2.0には対応してない. OAuth2.0はシグネチャのためのハッシュ値計算みたいなのをやらなくて良いから外部モジュール使わずに手で書けばいい気がする.

OAuth認証まわりはOAuth::Lite::Consumerを使ってる.

書いてみたはいいけど,Plack::Middleware::OAuthのほうがあきらかによいインタフェースだということがわかってよかった. ただ,Plack::Middleware::OAuthのコールバックルーチンに渡される第一引数$selfにはredirectとかrenderとかが生えていて,これらは自前で実装してるようなので,この辺うまくPlack::Requestを使えなかったんだろうか.

Plack::Middleware::OAuthは内部でいろいろよしなにやってくれる感じで自由度が低めに感じたので,外からいろいろ設定を渡せるようにしたけど,なんだか微妙な感じになった感がある.

Plack::Middleware::OAtuhを使いましょう.

psgiファイルの設定

    enable "Plack::Middleware::Session";

    enable 'Plack::Middleware::OAuth::Lite',
        on_success => sub {
            my ($res, $provider_name, $token, $user_info, $location) = @_;
            $res->redirect("/auth/signup/$provider_name");
            return $res;
        },
        on_error => sub {
            my ($res, $error) = @_;
            $res->redirect("/oauth/error?reason=".$error);
            return $res;
        },
        providers => {
            'twitter' => {
                consumer_key       => 'XXXXXX',
                consumer_secret    => XXXXXX',
                site               => 'http://api.twitter.com/',
          request_token_path => 'https://api.twitter.com/oauth/request_token',
                access_token_path  => 'https://api.twitter.com/oauth/access_token',
          authorize_path     => 'https://api.twitter.com/oauth/authorize',
                login_path         => '/auth/twitter',
                scope              => '',
                user_info_uri      => qq{https://api.twitter.com/1.1/account/verify_credentials.json},
                user_info_uri_method => 'GET',
            },
            hatena => {
                consumer_key       => 'XXXXXX',
                consumer_secret    => 'XXXXXX',
                site                  => qq{https://www.hatena.com},
                request_token_path => qq{https://www.hatena.com/oauth/initiate},
                access_token_path  => qq{https://www.hatena.com/oauth/token},
                authorize_path     => qq{https://www.hatena.ne.jp/oauth/authorize},
                login_path         => '/auth/hatena',
                scope              => 'read_public',
                user_info_uri      => qq{http://n.hatena.com/applications/my.json},
                user_info_uri_method => 'POST',
        },

        };
package Plack::Middleware::OAuth::Lite;
use utf8;
use strict;
use warnings;

our $VERSION = '0.01';

use parent 'Plack::Middleware';
use Plack::Util;
use Plack::Util::Accessor qw(providers on_success on_error flash_success_message);
use Plack::Request;
use Plack::Session;

use Carp ();
use JSON::XS qw(decode_json);
use OAuth::Lite::Consumer;
use Try::Tiny;

our $CONFIG_KEYS = [qw(
    consumer_key
    consumer_secret
    site
    request_token_path
    access_token_path
    authorize_path
    login_path
    scope
    user_info_uri
)];

sub prepare_app {
    my ($self) = shift;
    my $p = $self->providers || Carp::croak "require providers";

    $self->{client_info}     ||= {}; # {provider_name => 'OAuth::Lire::Consumer instance'}
    $self->{login_path_info} ||= {}; # {provider_name => 'path'}
    $self->{scope_info}      ||= {}; # {provider_name => 'read,write'}
    $self->{user_info}       ||= {}; # {provider_name => {uri => 'user_info', method => 'HTTP Method', user_name_key => 'key name'}
    for my $provider_name (keys %$p) {
        my $config = $p->{$provider_name};
        check_config($config);

        my $lc_name = lc($provider_name);
        $self->{client_info}{$lc_name} = OAuth::Lite::Consumer->new(
            consumer_key       => $config->{consumer_key},
            consumer_secret    => $config->{consumer_secret},
            site               => $config->{site},
            request_token_path => $config->{request_token_path},
            access_token_path  => $config->{access_token_path},
            authorize_path     => $config->{authorize_path},
        );
        $self->{login_path_info}{$lc_name} = $config->{login_path};
        $self->{scope_info}{$lc_name} = $config->{scope};
        $self->{user_info}{$lc_name} = {
            uri      => $config->{user_info_uri},
            method   => $config->{user_info_uri_method} || 'GET',
        };
    }
}

sub check_config {
    my ($config) = @_;
    for my $expected (@$CONFIG_KEYS) {
        unless (grep {$expected eq $_} keys %$config) {
            Carp::croak "require $expected";
        }
    }
}

sub call {
    my ($self, $env) = @_;

    my $session = Plack::Session->new($env);

    $self->{handlers} //= do {
        my $handlers = {};
        while (my ($provider_name, $login_path) = each %{$self->{login_path_info}}) {
            $login_path =~ s!(.+)/$!$1!; # add
            my $callback_path = "$login_path/callback";
            my $consumer = $self->{client_info}{$provider_name};

            $handlers->{$login_path} = sub {
                my ($env) = @_;
                my $req = Plack::Request->new($env);
                my $res = $req->new_response(200);
                my $request_token = $consumer->get_request_token(
                    callback_url => _callback_uri($req->base, $callback_path),
                    scope        => $self->{scope_info}->{$provider_name} || undef,
                ) or die $consumer->errstr;

                $session->set($provider_name.'oauth_request_token' => {%$request_token});
                $session->set($provider_name.'oauth_location'      => $req->param('location'));
                $res->redirect($consumer->url_to_authorize(token => $request_token));

                return $res->finalize;
            };

            $handlers->{$callback_path} = sub {
                my ($env) = @_;
                my $req = Plack::Request->new($env);
                my $res = $req->new_response(200);

                if ($req->param('denied')) {
                    return $res->redirect('/');
                }

                my $verifier = $req->param('oauth_verifier')
                    || die "No oauth verifier";

                my $access_token = $consumer->get_access_token(
                    token    => (bless $session->get($provider_name.'oauth_request_token'), 'OAuth::Lite::Token'),
                    verifier => $verifier,
                ) or die $consumer->errstr;

                $session->remove($provider_name.'oauth_request_token');
                $session->set($provider_name.'oauth_access_token', {%$access_token});

                {
                    my $u = $self->{user_info}{$provider_name};
                    my $u_res = $consumer->request(
                        method => $u->{method}, url => $u->{uri}, token  => $access_token,
                    );
                    $u_res->is_success or die "failed getting user info";

                    my $user_info = eval { decode_json($u_res->decoded_content || $res->content) };
                    $session->set($provider_name.'oauth_user_info', $user_info);

                    my $location = $session->get($provider_name.'oauth_location') || "/";
                    $res = $self->on_success->($res, $provider_name, $access_token, $user_info, {
                        location => $location,
                    });
                }
                return $res->finalize;
            };
        }
        $handlers;
    };

    return $self->_run($env, $self->{handlers});
}

sub _run {
    my ($self, $env, $handlers) = @_;

    my $app = $handlers->{$env->{PATH_INFO}};
    return $self->app->($env) unless $app;

    my $res;
    try {
      $res = $app->($env);
    } catch {
        my $req = Plack::Request->new($env);
        my $res = $req->new_response(200);
        $_ =~ /(.*)\sat/;
        $res = $self->on_error->($res, $1);
        return $res->finalize;
    }
    $res;
}

sub _callback_uri {
    my ($base_uri, $callback_path) = @_;
    $callback_path =~ s!^/?(.+)!$1!; # remove head '/'
    $base_uri . $callback_path;
}

1;
__END__