commit 178e3a3206dfb9670c99880dca5f45e673fe0fd5 Author: jake Date: Tue Nov 26 22:28:43 2024 -0500 GIT THIS diff --git a/indieauth-client.pl b/indieauth-client.pl new file mode 100755 index 0000000..b339b92 --- /dev/null +++ b/indieauth-client.pl @@ -0,0 +1,486 @@ +#!/usr/bin/env perl + +use strict; +use warnings; +use 5.010; + +use CGI::Tiny; +use Text::Xslate; +use Data::Section::Simple 'get_data_section'; +use HTML::TokeParser; +use LWP::UserAgent; +use URL::Encode qw(url_encode_utf8 url_decode_utf8); +use Storable; +use File::Temp qw( tempfile ); +use String::Urandom; +use Crypt::Digest::SHA256 'sha256_b64u'; +use JSON; +use MIME::Base64; +use Data::Validate::URI qw(is_web_uri); +use URL::XS qw(parse_url); + +use Data::Dumper; + +our $PROGRAM_NAME = 'jakes-indieauth-thing'; + +my $client_id = 'https://jakesthoughts.xyz'; +my $scope = 'profile'; # specifiy more with + eg 'profile+email' (profile is required for email) + +# compaired with 'script_name' eg 'https://example.net/indieauth-client/indieauth-client.cgi' +my $prefix = '/indieauth-client'; +# these 'paths' below need to exist +# ln -s +my %paths = ( + 'show_form' => "$prefix/indieauth-client.cgi", + 'start' => "$prefix/indieauth-start.cgi", + 'redirect' => "$prefix/indieauth-redirect.cgi", + 'logged_in' => "$prefix/indieauth-logged-in.cgi", + 'log_out' => "$prefix/indieauth-log-out.cgi", +); + +my $timeout = 5; + +cgi { + my $cgi = $_; + $cgi->set_error_handler(sub { + my ($cgi, $error, $rendered) = @_; + warn $error; + unless ($rendered) { + $cgi->render(text => $error. ' '. $cgi->script_name); # want it to spit out errors + } + }); + + my $tx = Text::Xslate->new(path => [get_data_section], cache => 0); + + ## routing begins... + if ($cgi->script_name eq $paths{show_form}) { + my $data = get_our_cookie_data($cgi->cookies); + if ($data->{me}) { + $cgi->render(redirect => $client_id. $paths{logged_in}) + } + else { + $cgi->render(html => $tx->render('show_form.tx', {script_name => $cgi->script_name })); + } + } + elsif ($cgi->script_name eq $paths{start}) { + my $url = scalar $cgi->param('url'); + + die_if_not_valid_with_indie_auth_spec( validate_url_with_indie_auth_spec($url), $url ); + + my $meta = get_authenication_endpoint($url); + if (! $meta) { + return $cgi->render(html => $tx->render('start.tx', { + script_name => $cgi->script_name, + nothing => 1, + })); + } + + my $rand = String::Urandom->new(LENGTH => 50); + my $code_veri = $rand->rand_string; + my $base64_chal = sha256_b64u($code_veri); + + # store important info to disk + my ($fh, $fn) = tempfile(); + close $fh; + + $cgi->add_response_cookie( + $PROGRAM_NAME => to_cookie_data( + 'state' => $fn, + ), + 'Max-Age' => '120', + ); + + my %table = ( + user_url => $url, + code_verifiy => $code_veri, + authorization_endpoint => $meta->{authorization_endpoint}, + state => $fn, # to be retrieved later + ); + store \%table, $fn; + + # need to redirect users + $cgi->render(redirect => + $meta->{authorization_endpoint}. + '?response_type=code'. + '&client_id='. url_encode_utf8($client_id). + '&redirect_uri='. url_encode_utf8($client_id . $paths{redirect}). + '&state='. $fn. + '&code_challenge='. $base64_chal. + '&code_challenge_method=S256'. + '&me='. url_encode_utf8($url). + '&scope='. $scope + ); + } + elsif ($cgi->script_name eq $paths{redirect}) { + my $query = $cgi->query_string; + my %q; + for my $e ( split '&', $query ) { + my ($key, $value) = split '=', $e; + $q{$key} = url_decode_utf8($value); + } + #die Dumper(\%q); + + my $table_hashref; + + if (-e $q{state} and -r $q{state}) { + $table_hashref = retrieve($q{state}); + if ($table_hashref->{state} ne $q{state}) { + return $cgi->render(text => + 'Your shit is all fucked up - somehow loaded a state but not yours.' + ); + } + } + else { + return $cgi->render(text => 'Your shit is all fucked up.'); + } + + # looks good, send our codes + my $ua = LWP::UserAgent->new; + my $post_link; + + if ($table_hashref->{authorization_endpoint} =~ m/^http/) { + $post_link = $table_hashref->{authorization_endpoint}; + } + else { + if ($table_hashref->{user_url} =~ m/^http/) { + $post_link = $table_hashref->{user_url}. $table_hashref->{authorization_endpoint}; + } + else { + die 'They sent a reply but nothing we can use.'. Dumper($table_hashref). Dumper(%q) + } + } + + my $res = $ua->post( $post_link, + [ + grant_type => 'authorization_code', + client_id => $client_id, + redirect_uri => $client_id. $paths{redirect}, + code => $q{code}, + code_verifier => $table_hashref->{code_verifiy}, + ] + ); + + my $j; + eval { + $j = decode_json($res->decoded_content); + }; + if ($@) { + die Dumper($res->decoded_content). Dumper($table_hashref). Dumper(\%q) ; + } + if ($j->{me} ne $table_hashref->{user_url}) { + my $ae = get_authenication_endpoint($j->{me}); + if ($ae ne $table_hashref->{authorization_endpoint}) { + return $cgi->render(text => 'Your \'me\' parameter doesn\t match our records.'); + } + } + my $name; + my $url; + my $photo; + if ($j->{profile}{name}) { $name = $j->{profile}{name} } + if ($j->{profile}{url}) { $url = $j->{profile}{url} } + if ($j->{profile}{photo}) { $photo = $j->{profile}{photo} } + $name // 0; + $url // 0; + $photo // 0; + #die Dumper($j); + + #$cgi->reset_response_headers; + $cgi->add_response_cookie( + $PROGRAM_NAME => to_cookie_data( + 'me' => $j->{me}, + 'name' => $name, + 'url' => $url, + 'photo' => $photo + ), + 'Max-Age' => '1000', + ); + $cgi->render(redirect => $client_id. $paths{logged_in}); + } + elsif ($cgi->script_name eq $paths{logged_in}) { + + my $data = get_our_cookie_data($cgi->cookies); + my $logged = 0; + + if ($data and exists $data->{me}) { + $logged = 1; + } + $cgi->render(html => $tx->render('logged_in.tx', { + script_name => $cgi->script_name , + logged => $logged, + me => $data->{me}, + name => $data->{name}, + url => $data->{url}, + photo => $data->{photo}, + show_form => $paths{show_form}, + logout => $paths{log_out}, + })); + } + elsif ($cgi->script_name eq $paths{log_out}) { + $cgi->add_response_cookie( $PROGRAM_NAME => 'cleared', 'Max-Age' => 0); + $cgi->render(redirect => $client_id. $paths{show_form}); + } +}; + +sub to_cookie_data { + my (%data) = @_; + return encode_base64(encode_json(\%data), ''); # no '\n' or cookies fail +} + +sub from_cookie_data { + my ($s) = @_; + return decode_json(decode_base64($s)); +} + +# this looks for indieauth-metadata which it pokes for auth. endpoint and other details +# barring an indieauth-metadata, if an authorization_endpoint is present, just use that +sub get_authenication_endpoint { + my ($uri) = @_; + my $ua = LWP::UserAgent->new; + $ua->timeout($timeout); + my $res = $ua->get($uri); + + return unless $res->is_success; + + my $ae; + my $te; + my $aer = ''; + my @head_links; + my $meta; + + # head + for ($res->header('Link')) { + if ($_ =~ m{^<(.*)>; rel="((indieauth-metadata)|(authorization_endpoint))"}) { + $ae = $1; + $aer = $2; + if ($aer eq 'indieauth-metadata') { + last; + } + else { + push @head_links, [$ae, $aer]; + } + } + } + + # html + if ($aer ne 'indieauth-metadata') { + if ($head_links[0][0]) { + $ae = $head_links[0][0]; + undef @head_links; + } + + my $p = HTML::TokeParser->new( \$res->decoded_content ); + while (my $token = $p->get_tag('link') ) { + my $href = $token->[1]{href}; + my $rel = $token->[1]{rel}; + next unless $rel eq 'indieauth-metadata' + or $rel eq 'authorization_endpoint'; + + if ($href) { + if (($rel eq 'indieauth-metadata') or ($rel eq 'authorization_endpoint' and $aer eq '')) { + $ae = $href; + $aer = $rel; + last; + } + } + } + } + + if ($aer eq 'indieauth-metadata') { + if (is_web_uri($ae)) { + $res = $ua->get($ae) + } + else { + my $pu; + eval { + $pu = parse_url($uri); + }; + if ($@) { + die; + } + if (substr($ae,0,1) eq '/') { + $res = $ua->get("$pu->{scheme}://$pu->{host}$ae"); + } + else { + $res = $ua->get("$pu->{scheme}://$pu->{host}/$ae"); + } + } + if ($res->is_success) { + $meta = decode_json($res->decoded_content); + } + } + else { + $meta->{authorization_endpoint} = $ae; + } + return $meta; +} + +sub get_our_cookie_data { + my ($pairs) = @_; + my $data; + + for my $e (@$pairs) { + if ($e->[0] eq $PROGRAM_NAME) { + $data = from_cookie_data($e->[1]); + last; + } + } + return $data; +} + +sub validate_url_with_indie_auth_spec { + my ($url) = @_; + + my $pu; + eval { + $pu = parse_url($url); + }; + if ($@) { return 10; } + + if (! $pu->{scheme}) { return 2; } + if ($pu->{scheme} !~ m/^https?/) { return 3; } + if (! $pu->{host}) { return 4; } + if ($pu->{username}) { return 5; } + if ($pu->{path} =~ m/[..]/) { return 6; } + if ($pu->{port} != 0) { return 7; } + if ($pu->{fragment}) { return 8; } + + #die Dumper($pu). Dumper($url); + + if (! (is_web_uri($url)) ) { + if (! ($pu->{host} eq '127.0.0.1' or + # parse_url cannot handle IPv6 addresses + ($pu->{host} eq '[' and $url =~ m{^http[s]://\[::1]}))) + { + return 9; + } + } + + return 1; +} + +sub die_if_not_valid_with_indie_auth_spec { + my ($n, $url) = @_; + + return 1 if ($n == 1); + + if ($n == 2) { + die 'This, \''. $url. '\', is missing a scheme (E.G. https)'; + } elsif ($n == 3) { + die 'This, \''. $url. '\', scheme is not http or https.'; + } elsif ($n == 4) { + die 'This, \''. $url. '\', is missing a host.'; + } elsif ($n == 5) { + die 'This, \''. $url. '\', has a username component.'; + } elsif ($n == 6) { + die 'This, \''. $url. '\', has a double-dot path segment.'; + } elsif ($n == 7) { + die 'This, \''. $url. '\', has a port listed'; + } elsif ($n == 8) { + die 'This, \''. $url. '\', has a fragment.'; + } elsif ($n == 9) { + die 'This, \''. $url. '\', is not a valid URL (malformed or IPv4/IPv6 address)'; + } elsif ($n == 10) { + # occurs when parse_url dies + die 'This, \''. $url. '\', is malformed.'; + } + + return $n; +} + +1; + +__DATA__ +@@ wrapper.tx + + + + : block title -> { } + + + + +
+ <: block content -> { } :> +
+ + + +@@ show_form.tx +: cascade wrapper; +: override title -> { + Login +: } +: override content -> { +

Jake's IndieLogin Thing

+

<: $script_name; :>

+

Web-Sign in

+
+ +
+ +
+

(At this moment, only an IndieAuth server is supported)

+: } + +@@ start.tx +: cascade wrapper; +: override title -> { + Searching... +: } +: override content -> { +

Jake's IndieLogin Thing

+

<: $script_name; :>

+ : if ($nothing) { +

No 'link' tag with rel attribute of 'authorization_endpoint'

+ : } + : else { +

Please wait... :)

+

If waiting a long time, something bad has happenend!

+ : } +: } + +@@ logged_in.tx +: cascade wrapper; +: override title -> { + Logged In +: } +: override content -> { +

Jake's IndieLogin Thing

+

<: $script_name; :>

+ : if ($logged) { +

Nice! You're authenticated, Mr. <: $me :>

+ : if ($photo) { + A photo of you! + : } + : else { +

Your authorization server didn't give me a link to your photo.

+ : } + : if ($name) { +

Aren't you glad you signed up, <: $name :>?

+ : } + : else { +

Your authorization server didn't give me your name.

+ : } + : if ($url) { +

Nice! Your URL is: <: $url :>

+ : } + : else { +

Your authorization server didn't give me your URL.

+ : } +

Enjoy this picture:

+ A picture of a smelly cat. +

Logout do this all over again?

+

Click here to logout.

+ : } + : else { +

To login, please login.

+

Click here to login.

+ : } +: }