perlsky is a Perl 5 implementation of an AT Protocol Personal Data Server.
13
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add built-in ATProto OAuth provider

alice 02ea81f9 87ea5c35

+1123 -7
+50
lib/ATProto/PDS.pm
··· 14 14 use ATProto::PDS::API::Registry; 15 15 use ATProto::PDS::API::Server qw(register_server_handlers); 16 16 use ATProto::PDS::API::Sync qw(register_sync_handlers); 17 + use ATProto::PDS::Auth::OAuth; 17 18 use ATProto::PDS::Crawlers; 18 19 use ATProto::PDS::Identity qw(account_did_doc normalize_handle service_did); 19 20 use ATProto::PDS::LexiconCatalog qw(endpoint_catalog); ··· 128 129 settings => $c->app->settings, 129 130 ); 130 131 }); 132 + $self->helper(oauth_provider => sub ($c) { 133 + state $oauth = ATProto::PDS::Auth::OAuth->new( 134 + settings => $c->app->settings, 135 + ); 136 + }); 131 137 132 138 my $routes = $self->routes; 133 139 $routes->get('/')->to(cb => sub ($c) { ··· 199 205 $c->render(data => $account->{did}); 200 206 }); 201 207 208 + $routes->get('/.well-known/oauth-protected-resource')->to(cb => sub ($c) { 209 + _apply_cors_headers($c); 210 + $c->render(json => $c->oauth_provider->protected_resource_metadata); 211 + }); 212 + 213 + $routes->get('/.well-known/oauth-authorization-server')->to(cb => sub ($c) { 214 + _apply_cors_headers($c); 215 + $c->render(json => $c->oauth_provider->authorization_server_metadata); 216 + }); 217 + 218 + $routes->get('/oauth/jwks')->to(cb => sub ($c) { 219 + _apply_cors_headers($c); 220 + $c->render(json => $c->oauth_provider->jwks); 221 + }); 222 + 223 + $routes->post('/oauth/par')->to(cb => sub ($c) { 224 + _apply_cors_headers($c); 225 + return $c->oauth_provider->pushed_authorization_request($c); 226 + }); 227 + 228 + $routes->get('/oauth/authorize')->to(cb => sub ($c) { 229 + return $c->oauth_provider->render_authorize($c); 230 + }); 231 + 232 + $routes->post('/oauth/authorize')->to(cb => sub ($c) { 233 + return $c->oauth_provider->submit_authorize($c); 234 + }); 235 + 236 + $routes->post('/oauth/token')->to(cb => sub ($c) { 237 + _apply_cors_headers($c); 238 + return $c->oauth_provider->token($c); 239 + }); 240 + 241 + $routes->post('/oauth/revoke')->to(cb => sub ($c) { 242 + _apply_cors_headers($c); 243 + return $c->oauth_provider->revoke($c); 244 + }); 245 + 202 246 $routes->get('/users/:account_id/did.json')->to(cb => sub ($c) { 203 247 my $match = $c->store->get_account_by_id($c->stash('account_id')); 204 248 return $c->render(status => 404, json => { error => 'DidNotFound' }) unless $match; ··· 230 274 sub _cors_path ($path) { 231 275 my $text = ref($path) ? $path->to_string : ($path // q()); 232 276 return 1 if $text =~ m{\A/xrpc(?:/|\z)}; 277 + return 1 if $text eq '/.well-known/oauth-protected-resource'; 278 + return 1 if $text eq '/.well-known/oauth-authorization-server'; 279 + return 1 if $text eq '/oauth/jwks'; 280 + return 1 if $text eq '/oauth/par'; 281 + return 1 if $text eq '/oauth/token'; 282 + return 1 if $text eq '/oauth/revoke'; 233 283 return 1 if $text eq '/.well-known/did.json'; 234 284 return 1 if $text eq '/.well-known/atproto-did'; 235 285 return 0;
+10 -2
lib/ATProto/PDS/API/Server.pm
··· 10 10 11 11 use ATProto::PDS::API::Helpers qw(find_account invite_code_view issue_account_action_token require_admin verify_account_password verify_login_password); 12 12 use ATProto::PDS::API::Util qw(iso8601 xrpc_error); 13 + use ATProto::PDS::Auth::OAuth qw(oauth_scope_allows oauth_scope_has_atproto); 13 14 use ATProto::PDS::Auth::JWT qw(decode_jwt encode_jwt encode_service_jwt); 14 15 use ATProto::PDS::Auth::Password qw(hash_password random_hex); 15 16 use ATProto::PDS::Constants qw( ··· 618 619 sub require_auth ($c, %opts) { 619 620 my $auth = $c->req->headers->authorization // q(); 620 621 xrpc_error(401, 'AuthRequired', 'Authorization header is required') 621 - unless $auth =~ /\ABearer\s+(.+)\z/i; 622 - my $token = $1; 622 + unless $auth =~ /\A(Bearer|DPoP)\s+(.+)\z/i; 623 + my ($scheme, $token) = (lc($1), $2); 624 + 625 + if ($scheme eq 'dpop') { 626 + xrpc_error(401, 'InvalidToken', 'DPoP tokens cannot be used as refresh tokens') 627 + if (($opts{audience} // TOKEN_AUD_ACCESS) eq TOKEN_AUD_REFRESH) || $opts{allow_refresh}; 628 + return $c->oauth_provider->authenticate_oauth_access_token($c, $token, %opts); 629 + } 623 630 624 631 my $decoded = eval { decode_jwt($token, _jwt_secret($c)) }; 625 632 if (my $err = $@) { ··· 740 747 741 748 sub _scope_allows ($scope, $required_scope) { 742 749 $scope = _canonical_access_scope($scope); 750 + return oauth_scope_allows($scope, $required_scope) if oauth_scope_has_atproto($scope); 743 751 return 1 if !defined($required_scope) || !length($required_scope); 744 752 return $scope eq TOKEN_AUD_ACCESS 745 753 if $required_scope eq 'full';
+1 -1
lib/ATProto/PDS/Auth/JWT.pm
··· 50 50 my $now = $opts{now} // time; 51 51 52 52 die 'token not yet valid' if defined $claims->{nbf} && $claims->{nbf} > $now; 53 - die 'token expired' if defined $claims->{exp} && $claims->{exp} <= $now; 53 + die 'token expired' if !$opts{allow_expired} && defined $claims->{exp} && $claims->{exp} <= $now; 54 54 55 55 if (defined $opts{audience}) { 56 56 my $aud = $claims->{aud};
+854
lib/ATProto/PDS/Auth/OAuth.pm
··· 1 + package ATProto::PDS::Auth::OAuth; 2 + 3 + use v5.34; 4 + use warnings; 5 + use feature 'signatures'; 6 + no warnings 'experimental::signatures'; 7 + 8 + use Exporter 'import'; 9 + use Mojo::Base -base, -signatures; 10 + 11 + use Crypt::PK::ECC; 12 + use Digest::SHA qw(sha256); 13 + use JSON::PP qw(decode_json encode_json); 14 + use Mojo::JSON qw(false true); 15 + use Mojo::URL; 16 + use Mojo::UserAgent; 17 + use Mojo::Util qw(xml_escape); 18 + use ATProto::PDS::API::Helpers qw(find_account verify_login_password); 19 + use ATProto::PDS::API::Util qw(xrpc_error); 20 + use ATProto::PDS::Auth::JWT qw(decode_jwt encode_jwt); 21 + use ATProto::PDS::Auth::Password qw(random_hex timing_safe_eq); 22 + use ATProto::PDS::Constants qw(TOKEN_AUD_ACCESS TOKEN_AUD_REFRESH); 23 + use ATProto::PDS::Moderation qw(assert_login_allowed is_repo_takedown); 24 + use ATProto::PDS::Util::BaseX qw(base64url_decode base64url_encode); 25 + 26 + our @EXPORT_OK = qw( 27 + oauth_scope_allows 28 + oauth_scope_has_atproto 29 + ); 30 + 31 + has settings => sub { {} }; 32 + has ua => sub { 33 + state $ua = Mojo::UserAgent->new; 34 + $ua->max_redirects(5); 35 + return $ua; 36 + }; 37 + 38 + sub protected_resource_metadata ($self) { 39 + my $resource = $self->_issuer; 40 + return { 41 + resource => $resource, 42 + authorization_servers => [$resource], 43 + bearer_methods_supported => ['header'], 44 + scopes_supported => [], 45 + resource_documentation => 'https://atproto.com', 46 + }; 47 + } 48 + 49 + sub authorization_server_metadata ($self) { 50 + my $issuer = $self->_issuer; 51 + return { 52 + issuer => $issuer, 53 + authorization_endpoint => $issuer . '/oauth/authorize', 54 + token_endpoint => $issuer . '/oauth/token', 55 + revocation_endpoint => $issuer . '/oauth/revoke', 56 + pushed_authorization_request_endpoint => $issuer . '/oauth/par', 57 + jwks_uri => $issuer . '/oauth/jwks', 58 + response_types_supported => ['code'], 59 + grant_types_supported => ['authorization_code', 'refresh_token'], 60 + code_challenge_methods_supported => ['S256'], 61 + token_endpoint_auth_methods_supported => ['private_key_jwt', 'none'], 62 + token_endpoint_auth_signing_alg_values_supported => ['ES256'], 63 + dpop_signing_alg_values_supported => ['ES256'], 64 + authorization_response_iss_parameter_supported => true, 65 + request_parameter_supported => true, 66 + request_uri_parameter_supported => true, 67 + require_request_uri_registration => true, 68 + require_pushed_authorization_requests => true, 69 + client_id_metadata_document_supported => true, 70 + protected_resources => [$issuer], 71 + }; 72 + } 73 + 74 + sub jwks ($self) { 75 + return { keys => [] }; 76 + } 77 + 78 + sub pushed_authorization_request ($self, $c) { 79 + my $body = $c->req->body_params->to_hash; 80 + my $client_id = $body->{client_id} // q(); 81 + return _oauth_json_error($c, 400, 'invalid_request', 'client_id is required') 82 + unless length $client_id; 83 + 84 + my $client = eval { $self->_load_client_metadata($client_id) }; 85 + return _oauth_json_error($c, 400, 'invalid_client', "$@") if $@; 86 + 87 + my $client_auth = eval { 88 + $self->_verify_client_auth( 89 + client => $client, 90 + body => $body, 91 + url => $self->_issuer . '/oauth/par', 92 + ); 93 + }; 94 + return _oauth_json_error($c, 401, 'invalid_client', "$@") if $@; 95 + 96 + my $dpop = eval { 97 + $self->_verify_dpop_proof( 98 + $c->req->headers->header('DPoP'), 99 + $c->req->method, 100 + $self->_request_url_without_query($c), 101 + ); 102 + }; 103 + return _oauth_json_error($c, 400, 'invalid_dpop_proof', "$@") if $@; 104 + 105 + my $redirect_uri = $body->{redirect_uri} // q(); 106 + my $scope = _normalize_scope($body->{scope} // q()); 107 + return _oauth_json_error($c, 400, 'invalid_request', 'response_type must be code') 108 + unless ($body->{response_type} // q()) eq 'code'; 109 + return _oauth_json_error($c, 400, 'invalid_scope', 'scope must include atproto') 110 + unless oauth_scope_has_atproto($scope); 111 + return _oauth_json_error($c, 400, 'invalid_request', 'redirect_uri is required') 112 + unless length $redirect_uri; 113 + return _oauth_json_error($c, 400, 'invalid_request', 'redirect_uri is not registered') 114 + unless grep { $_ eq $redirect_uri } @{ $client->{redirect_uris} // [] }; 115 + return _oauth_json_error($c, 400, 'invalid_request', 'code_challenge is required') 116 + unless length($body->{code_challenge} // q()); 117 + return _oauth_json_error($c, 400, 'invalid_request', 'code_challenge_method must be S256') 118 + unless ($body->{code_challenge_method} // q()) eq 'S256'; 119 + 120 + if (defined($body->{resource}) && length($body->{resource}) && ($body->{resource} ne $self->_issuer)) { 121 + return _oauth_json_error($c, 400, 'invalid_target', 'resource is not supported'); 122 + } 123 + 124 + my $request_uri = 'urn:ietf:params:oauth:request_uri:' . random_hex(24); 125 + my $expires_at = time + 600; 126 + $c->store->create_oauth_request( 127 + id => random_hex(12), 128 + request_uri => $request_uri, 129 + client_id => $client_id, 130 + client_name => $client->{client_name}, 131 + client_uri => $client->{client_uri}, 132 + redirect_uri => $redirect_uri, 133 + scope => $scope, 134 + state => $body->{state}, 135 + nonce => $body->{nonce}, 136 + login_hint => $body->{login_hint}, 137 + prompt => $body->{prompt}, 138 + code_challenge => $body->{code_challenge}, 139 + code_challenge_method => $body->{code_challenge_method}, 140 + client_auth_method => $client_auth->{method}, 141 + client_auth_alg => $client_auth->{alg}, 142 + client_auth_kid => $client_auth->{kid}, 143 + client_auth_jkt => $client_auth->{jkt}, 144 + client_assertion_jti => $client_auth->{jti}, 145 + client_assertion_exp => $client_auth->{exp}, 146 + dpop_jkt => $dpop->{jkt}, 147 + created_at => time, 148 + expires_at => $expires_at, 149 + ); 150 + 151 + $c->res->code(201); 152 + $c->res->headers->header('Cache-Control' => 'no-store'); 153 + $c->res->headers->header('Pragma' => 'no-cache'); 154 + $c->render(json => { 155 + request_uri => $request_uri, 156 + expires_in => $expires_at - time, 157 + }); 158 + } 159 + 160 + sub render_authorize ($self, $c) { 161 + my $request_uri = $c->param('request_uri') // q(); 162 + my $request = $c->store->get_oauth_request_by_request_uri($request_uri); 163 + return _render_authorize_error($c, 400, 'invalid_request', 'authorization request was not found') 164 + unless $request; 165 + return _render_authorize_error($c, 400, 'invalid_request', 'authorization request has expired') 166 + if ($request->{expires_at} // 0) < time; 167 + 168 + my $identifier = $c->param('identifier') // ($request->{login_hint} // q()); 169 + return $c->render( 170 + status => 200, 171 + format => 'html', 172 + data => _authorize_html( 173 + request => $request, 174 + identifier => $identifier, 175 + error => $c->param('error'), 176 + issuer => $self->_issuer, 177 + ), 178 + ); 179 + } 180 + 181 + sub submit_authorize ($self, $c) { 182 + my $body = $c->req->body_params->to_hash; 183 + my $request_uri = $body->{request_uri} // q(); 184 + my $request = $c->store->get_oauth_request_by_request_uri($request_uri); 185 + return _render_authorize_error($c, 400, 'invalid_request', 'authorization request was not found') 186 + unless $request; 187 + return _render_authorize_error($c, 400, 'invalid_request', 'authorization request has expired') 188 + if ($request->{expires_at} // 0) < time; 189 + 190 + if (($body->{decision} // 'approve') ne 'approve') { 191 + return $c->redirect_to($self->_authorization_redirect($request, error => 'access_denied')); 192 + } 193 + 194 + my $identifier = $body->{identifier} // ($request->{login_hint} // q()); 195 + my $account = find_account($c, $identifier); 196 + my $authn = $account ? verify_login_password($c, $account, $body->{password} // q()) : undef; 197 + unless ($account && $authn && ($authn->{kind} // q()) eq 'account') { 198 + return $c->render( 199 + status => 401, 200 + format => 'html', 201 + data => _authorize_html( 202 + request => $request, 203 + identifier => $identifier, 204 + error => 'Invalid identifier or password', 205 + issuer => $self->_issuer, 206 + ), 207 + ); 208 + } 209 + if (is_repo_takedown($c, $account->{did})) { 210 + return $c->render( 211 + status => 401, 212 + format => 'html', 213 + data => _authorize_html( 214 + request => $request, 215 + identifier => $identifier, 216 + error => 'Invalid identifier or password', 217 + issuer => $self->_issuer, 218 + ), 219 + ); 220 + } 221 + assert_login_allowed($c, $account); 222 + 223 + my $grant = $c->store->upsert_oauth_grant( 224 + did => $account->{did}, 225 + client_id => $request->{client_id}, 226 + scope => $request->{scope}, 227 + created_at => time, 228 + updated_at => time, 229 + ); 230 + 231 + my $code = random_hex(24); 232 + $c->store->authorize_oauth_request( 233 + id => $request->{id}, 234 + did => $account->{did}, 235 + grant_id => $grant->{id}, 236 + code => $code, 237 + code_expires_at => time + 60, 238 + ); 239 + 240 + return $c->redirect_to($self->_authorization_redirect($request, code => $code)); 241 + } 242 + 243 + sub token ($self, $c) { 244 + my $body = $c->req->body_params->to_hash; 245 + my $client_id = $body->{client_id} // q(); 246 + return _oauth_json_error($c, 400, 'invalid_request', 'client_id is required') 247 + unless length $client_id; 248 + 249 + my $client = eval { $self->_load_client_metadata($client_id) }; 250 + return _oauth_json_error($c, 400, 'invalid_client', "$@") if $@; 251 + 252 + my $client_auth = eval { 253 + $self->_verify_client_auth( 254 + client => $client, 255 + body => $body, 256 + url => $self->_issuer . '/oauth/token', 257 + ); 258 + }; 259 + return _oauth_json_error($c, 401, 'invalid_client', "$@") if $@; 260 + 261 + my $dpop = eval { 262 + $self->_verify_dpop_proof( 263 + $c->req->headers->header('DPoP'), 264 + $c->req->method, 265 + $self->_request_url_without_query($c), 266 + ); 267 + }; 268 + return _oauth_json_error($c, 400, 'invalid_dpop_proof', "$@") if $@; 269 + 270 + my $grant_type = $body->{grant_type} // q(); 271 + my $response = eval { 272 + if ($grant_type eq 'authorization_code') { 273 + return $self->_exchange_authorization_code($c, $body, $client_auth, $dpop); 274 + } 275 + if ($grant_type eq 'refresh_token') { 276 + return $self->_refresh_token_grant($c, $body, $client_auth, $dpop); 277 + } 278 + die 'unsupported grant_type'; 279 + }; 280 + if (my $err = $@) { 281 + my $message = "$err"; 282 + my ($error, $description) = $message =~ /\A([^:]+):\s*(.+)\z/ 283 + ? ($1, $2) 284 + : ('invalid_grant', $message); 285 + return _oauth_json_error($c, $error eq 'invalid_client' ? 401 : 400, $error, $description); 286 + } 287 + 288 + $c->res->headers->header('Cache-Control' => 'no-store'); 289 + $c->res->headers->header('Pragma' => 'no-cache'); 290 + $c->render(json => $response); 291 + } 292 + 293 + sub revoke ($self, $c) { 294 + my $body = $c->req->body_params->to_hash; 295 + my $client_id = $body->{client_id} // q(); 296 + return _oauth_json_error($c, 400, 'invalid_request', 'client_id is required') 297 + unless length $client_id; 298 + 299 + my $client = eval { $self->_load_client_metadata($client_id) }; 300 + return _oauth_json_error($c, 400, 'invalid_client', "$@") if $@; 301 + 302 + my $client_auth = eval { 303 + $self->_verify_client_auth( 304 + client => $client, 305 + body => $body, 306 + url => $self->_issuer . '/oauth/revoke', 307 + ); 308 + }; 309 + return _oauth_json_error($c, 401, 'invalid_client', "$@") if $@; 310 + 311 + eval { 312 + $self->_verify_dpop_proof( 313 + $c->req->headers->header('DPoP'), 314 + $c->req->method, 315 + $self->_request_url_without_query($c), 316 + ); 317 + }; 318 + return _oauth_json_error($c, 400, 'invalid_dpop_proof', "$@") if $@; 319 + 320 + my $token = $body->{token} // q(); 321 + if (length $token) { 322 + my $decoded = eval { decode_jwt($token, $self->_jwt_secret, allow_expired => 1) }; 323 + if ($decoded) { 324 + my $claims = $decoded->{claims}; 325 + my $session = $c->store->get_session($claims->{jti} // q()); 326 + if ($session && ($session->{kind} // q()) eq 'oauth' && ($session->{client_id} // q()) eq $client_id) { 327 + _revoke_session_chain($c, $session); 328 + } 329 + } 330 + } 331 + 332 + $c->res->headers->header('Cache-Control' => 'no-store'); 333 + $c->res->headers->header('Pragma' => 'no-cache'); 334 + $c->render(json => {}); 335 + } 336 + 337 + sub authenticate_oauth_access_token ($self, $c, $token, %opts) { 338 + my $decoded = eval { decode_jwt($token, $self->_jwt_secret) }; 339 + if (my $err = $@) { 340 + my ($code, $safe_message) = _jwt_decode_error("$err"); 341 + xrpc_error(401, $code, $safe_message); 342 + } 343 + 344 + my $claims = $decoded->{claims}; 345 + xrpc_error(401, 'InvalidToken', 'Unexpected token audience') 346 + unless ($claims->{aud} // q()) eq ($opts{audience} // TOKEN_AUD_ACCESS); 347 + xrpc_error(401, 'InvalidToken', 'Unexpected token type') 348 + unless ($claims->{typ} // q()) eq 'oauth_access'; 349 + 350 + my $session_id = $claims->{jti} // q(); 351 + xrpc_error(401, 'InvalidToken', 'Token is missing a session identifier') 352 + unless length $session_id; 353 + 354 + my $session = $c->store->get_session($session_id); 355 + xrpc_error(401, 'InvalidToken', 'Token session was not found') unless $session; 356 + xrpc_error(401, 'InvalidToken', 'Token session is not OAuth-backed') 357 + unless ($session->{kind} // q()) eq 'oauth'; 358 + xrpc_error(401, 'ExpiredToken', 'Token session has already been revoked') 359 + if defined $session->{revoked_at}; 360 + xrpc_error(401, 'ExpiredToken', 'Token session has expired') 361 + if defined($session->{expires_at}) && $session->{expires_at} < time; 362 + xrpc_error(401, 'InvalidToken', 'Token session did not match token subject') 363 + unless ($session->{did} // q()) eq ($claims->{sub} // q()); 364 + 365 + my $cnf = $claims->{cnf}; 366 + xrpc_error(401, 'InvalidToken', 'Token is missing confirmation key') 367 + unless ref($cnf) eq 'HASH' && length($cnf->{jkt} // q()); 368 + xrpc_error(401, 'InvalidToken', 'Token confirmation key did not match session binding') 369 + unless ($session->{dpop_jkt} // q()) eq ($cnf->{jkt} // q()); 370 + 371 + my $proof = eval { 372 + $self->_verify_dpop_proof( 373 + $c->req->headers->header('DPoP'), 374 + $c->req->method, 375 + $self->_request_url_without_query($c), 376 + access_token => $token, 377 + expected_jkt => $cnf->{jkt}, 378 + ); 379 + }; 380 + if (my $err = $@) { 381 + xrpc_error(401, 'InvalidToken', "$err"); 382 + } 383 + 384 + my $scope = _normalize_scope($claims->{scope} // $session->{scope}); 385 + if ($opts{required_scope} && !oauth_scope_allows($scope, $opts{required_scope})) { 386 + xrpc_error(400, 'InvalidToken', 'Bad token scope'); 387 + } 388 + 389 + my $account = $c->store->get_account_by_did($claims->{sub}); 390 + xrpc_error(401, 'InvalidToken', 'Token subject no longer exists') unless $account; 391 + xrpc_error(401, 'InvalidToken', 'Token subject has been deleted') if defined $account->{deleted_at}; 392 + return ($claims, $account, $session, $proof); 393 + } 394 + 395 + sub oauth_scope_has_atproto ($scope) { 396 + return scalar grep { $_ eq 'atproto' } split /\s+/, ($scope // q()); 397 + } 398 + 399 + sub oauth_scope_allows ($scope, $required_scope) { 400 + return 1 if oauth_scope_has_atproto($scope); 401 + return 0; 402 + } 403 + 404 + sub _exchange_authorization_code ($self, $c, $body, $client_auth, $dpop) { 405 + my $code = $body->{code} // q(); 406 + die 'invalid_grant: code is required' unless length $code; 407 + 408 + my $request = $c->store->get_oauth_request_by_code($code); 409 + die 'invalid_grant: authorization code was not found' unless $request; 410 + die 'invalid_grant: authorization code has already been used' 411 + if defined $request->{consumed_at}; 412 + die 'invalid_grant: authorization code has expired' 413 + if ($request->{code_expires_at} // 0) < time; 414 + die 'invalid_grant: authorization code was not approved' unless length($request->{did} // q()); 415 + die 'invalid_grant: client_id did not match authorization request' 416 + unless ($request->{client_id} // q()) eq ($body->{client_id} // q()); 417 + die 'invalid_grant: redirect_uri did not match authorization request' 418 + unless ($request->{redirect_uri} // q()) eq ($body->{redirect_uri} // q()); 419 + die 'invalid_grant: client authentication changed during authorization flow' 420 + unless _client_auth_matches($request, $client_auth); 421 + 422 + my $verifier = $body->{code_verifier} // q(); 423 + die 'invalid_grant: code_verifier is required' unless length $verifier; 424 + my $challenge = base64url_encode(sha256($verifier)); 425 + die 'invalid_grant: PKCE verification failed' 426 + unless timing_safe_eq($challenge, $request->{code_challenge} // q()); 427 + 428 + my $grant = $c->store->get_oauth_grant($request->{grant_id} // q()); 429 + die 'invalid_grant: authorization grant was not found' unless $grant; 430 + my $account = $c->store->get_account_by_did($request->{did}); 431 + die 'invalid_grant: authorization subject no longer exists' unless $account; 432 + 433 + my $session = $c->store->create_session( 434 + did => $account->{did}, 435 + kind => 'oauth', 436 + scope => $grant->{scope}, 437 + expires_at => time + (90 * 24 * 60 * 60), 438 + client_id => $request->{client_id}, 439 + grant_id => $grant->{id}, 440 + dpop_jkt => $dpop->{jkt}, 441 + client_auth_alg => $client_auth->{alg}, 442 + client_auth_kid => $client_auth->{kid}, 443 + client_auth_jkt => $client_auth->{jkt}, 444 + ); 445 + 446 + $c->store->consume_oauth_request_code($request->{id}); 447 + return $self->_oauth_token_response($account, $session); 448 + } 449 + 450 + sub _refresh_token_grant ($self, $c, $body, $client_auth, $dpop) { 451 + my $refresh_token = $body->{refresh_token} // q(); 452 + die 'invalid_grant: refresh_token is required' unless length $refresh_token; 453 + 454 + my $decoded = eval { decode_jwt($refresh_token, $self->_jwt_secret) }; 455 + die 'invalid_grant: refresh token is invalid' unless $decoded; 456 + 457 + my $claims = $decoded->{claims}; 458 + die 'invalid_grant: refresh token audience is invalid' 459 + unless ($claims->{aud} // q()) eq TOKEN_AUD_REFRESH; 460 + die 'invalid_grant: refresh token type is invalid' 461 + unless ($claims->{typ} // q()) eq 'oauth_refresh'; 462 + 463 + my $session = $c->store->get_session($claims->{jti} // q()); 464 + die 'invalid_grant: refresh token session was not found' unless $session; 465 + die 'invalid_grant: refresh token session is not OAuth-backed' 466 + unless ($session->{kind} // q()) eq 'oauth'; 467 + die 'invalid_grant: client_id did not match session' 468 + unless ($session->{client_id} // q()) eq ($body->{client_id} // q()); 469 + die 'invalid_grant: client authentication did not match session' 470 + unless _session_client_auth_matches($session, $client_auth); 471 + die 'invalid_grant: DPoP key changed during refresh' 472 + unless ($session->{dpop_jkt} // q()) eq ($dpop->{jkt} // q()); 473 + 474 + my $rotated = $c->store->rotate_session( 475 + $session->{id}, 476 + session_ttl => (90 * 24 * 60 * 60), 477 + grace_ttl => 300, 478 + ); 479 + die 'invalid_grant: refresh token has already been used or revoked' unless $rotated; 480 + 481 + my $account = $c->store->get_account_by_did($rotated->{did}); 482 + die 'invalid_grant: session subject no longer exists' unless $account; 483 + return $self->_oauth_token_response($account, $rotated); 484 + } 485 + 486 + sub _oauth_token_response ($self, $account, $session) { 487 + my $issuer = $self->_issuer; 488 + my $secret = $self->_jwt_secret; 489 + my $now = time; 490 + my $scope = _normalize_scope($session->{scope}); 491 + 492 + my $access_token = encode_jwt({ 493 + iss => $issuer, 494 + sub => $account->{did}, 495 + aud => TOKEN_AUD_ACCESS, 496 + typ => 'oauth_access', 497 + scope => $scope, 498 + cnf => { jkt => $session->{dpop_jkt} }, 499 + jti => $session->{id}, 500 + exp => $now + 3600, 501 + }, $secret); 502 + 503 + my $refresh_token = encode_jwt({ 504 + iss => $issuer, 505 + sub => $account->{did}, 506 + aud => TOKEN_AUD_REFRESH, 507 + typ => 'oauth_refresh', 508 + scope => $scope, 509 + client_id => $session->{client_id}, 510 + jti => $session->{id}, 511 + exp => $session->{expires_at} // ($now + (90 * 24 * 60 * 60)), 512 + }, $secret); 513 + 514 + return { 515 + access_token => $access_token, 516 + token_type => 'DPoP', 517 + expires_in => 3600, 518 + refresh_token => $refresh_token, 519 + scope => $scope, 520 + sub => $account->{did}, 521 + }; 522 + } 523 + 524 + sub _authorization_redirect ($self, $request, %params) { 525 + my $url = Mojo::URL->new($request->{redirect_uri}); 526 + my %query = map { 527 + my $value = $params{$_}; 528 + defined($value) && length($value) ? ($_ => $value) : () 529 + } sort keys %params; 530 + $query{state} = $request->{state} if defined($request->{state}) && length($request->{state}); 531 + $query{iss} = $self->_issuer; 532 + $url->query(\%query); 533 + return $url->to_string; 534 + } 535 + 536 + sub _load_client_metadata ($self, $client_id) { 537 + my $url = Mojo::URL->new($client_id); 538 + die 'client_id must be an absolute URL' 539 + unless length($url->scheme // q()) && length($url->host // q()); 540 + die 'client_id must use https' 541 + if lc($url->scheme // q()) ne 'https' && !_is_localhost_url($url); 542 + 543 + my $tx = $self->ua->get($url); 544 + die 'unable to fetch client metadata' unless $tx->result; 545 + die 'unable to fetch client metadata' 546 + if $tx->error || !$tx->result->is_success; 547 + my $json = $tx->result->json; 548 + die 'client metadata must be a JSON object' unless ref($json) eq 'HASH'; 549 + die 'client metadata client_id did not match request' 550 + unless ($json->{client_id} // q()) eq $client_id; 551 + die 'client metadata must declare code response support' 552 + unless grep { $_ eq 'code' } @{ $json->{response_types} // [] }; 553 + die 'client metadata must declare authorization_code grant support' 554 + unless grep { $_ eq 'authorization_code' } @{ $json->{grant_types} // [] }; 555 + die 'client metadata must declare DPoP-bound access tokens' 556 + unless $json->{dpop_bound_access_tokens}; 557 + 558 + my $method = $json->{token_endpoint_auth_method} // 'none'; 559 + die 'unsupported client authentication method' 560 + unless $method eq 'private_key_jwt' || $method eq 'none'; 561 + if ($method eq 'private_key_jwt') { 562 + die 'client metadata must provide jwks_uri for private_key_jwt' 563 + unless length($json->{jwks_uri} // q()) || ref($json->{jwks}) eq 'HASH'; 564 + } 565 + 566 + return $json; 567 + } 568 + 569 + sub _verify_client_auth ($self, %args) { 570 + my $client = $args{client}; 571 + my $body = $args{body} || {}; 572 + my $method = $client->{token_endpoint_auth_method} // 'none'; 573 + return { method => 'none' } if $method eq 'none'; 574 + 575 + my $assertion_type = $body->{client_assertion_type} // q(); 576 + my $assertion = $body->{client_assertion} // q(); 577 + die 'client_assertion is required' 578 + unless $assertion_type eq 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' 579 + && length $assertion; 580 + 581 + my ($header, $claims, $signing_str, $signature) = _jwt_parts($assertion); 582 + die 'unsupported client assertion alg' unless ($header->{alg} // q()) eq 'ES256'; 583 + my $jwk = $self->_resolve_client_jwk($client, $header->{kid}); 584 + _verify_es256_signature($jwk, $signing_str, $signature); 585 + 586 + my $client_id = $client->{client_id} // q(); 587 + die 'client assertion subject is invalid' unless ($claims->{sub} // q()) eq $client_id; 588 + die 'client assertion issuer is invalid' unless ($claims->{iss} // q()) eq $client_id; 589 + die 'client assertion jti is required' unless length($claims->{jti} // q()); 590 + my $now = time; 591 + die 'client assertion has expired' if ($claims->{exp} // 0) <= $now; 592 + my @audiences = ($self->_issuer, $args{url}); 593 + my $aud = $claims->{aud}; 594 + my $aud_ok = ref($aud) eq 'ARRAY' 595 + ? scalar grep { my $candidate = $_; grep { $candidate eq $_ } @audiences } @$aud 596 + : scalar grep { defined($aud) && $aud eq $_ } @audiences; 597 + die 'client assertion audience is invalid' unless $aud_ok; 598 + 599 + return { 600 + method => 'private_key_jwt', 601 + alg => $header->{alg}, 602 + kid => $header->{kid}, 603 + jkt => _jwk_thumbprint($jwk), 604 + jti => $claims->{jti}, 605 + exp => $claims->{exp}, 606 + }; 607 + } 608 + 609 + sub _resolve_client_jwk ($self, $client, $kid = undef) { 610 + my $jwks = $client->{jwks}; 611 + if (!$jwks && length($client->{jwks_uri} // q())) { 612 + my $url = Mojo::URL->new($client->{jwks_uri}); 613 + die 'jwks_uri must use https' 614 + if lc($url->scheme // q()) ne 'https' && !_is_localhost_url($url); 615 + my $tx = $self->ua->get($url); 616 + die 'unable to fetch client jwks' unless $tx->result && $tx->result->is_success; 617 + $jwks = $tx->result->json; 618 + } 619 + die 'client jwks must be a JSON object' unless ref($jwks) eq 'HASH'; 620 + my $keys = $jwks->{keys}; 621 + die 'client jwks must contain keys' unless ref($keys) eq 'ARRAY' && @$keys; 622 + my @matches = defined($kid) && length($kid) 623 + ? grep { ($_->{kid} // q()) eq $kid } @$keys 624 + : @$keys; 625 + die 'client jwk was not found' unless @matches; 626 + return $matches[0]; 627 + } 628 + 629 + sub _verify_dpop_proof ($self, $proof, $method, $url, %opts) { 630 + die 'DPoP proof is required' unless defined $proof && length $proof; 631 + my ($header, $claims, $signing_str, $signature) = _jwt_parts($proof); 632 + die 'DPoP proof alg is invalid' unless ($header->{alg} // q()) eq 'ES256'; 633 + die 'DPoP proof typ is invalid' unless lc($header->{typ} // q()) eq 'dpop+jwt'; 634 + die 'DPoP proof must include a JWK' unless ref($header->{jwk}) eq 'HASH'; 635 + _verify_es256_signature($header->{jwk}, $signing_str, $signature); 636 + 637 + my $now = time; 638 + die 'DPoP proof is expired or too far in the future' 639 + if abs(($claims->{iat} // 0) - $now) > 300; 640 + die 'DPoP proof jti is required' unless length($claims->{jti} // q()); 641 + die 'DPoP proof htm did not match request' 642 + unless uc($claims->{htm} // q()) eq uc($method // q()); 643 + die 'DPoP proof htu did not match request' 644 + unless ($claims->{htu} // q()) eq $url; 645 + 646 + my $jkt = _jwk_thumbprint($header->{jwk}); 647 + die 'DPoP proof key did not match token binding' 648 + if defined($opts{expected_jkt}) && ($opts{expected_jkt} // q()) ne $jkt; 649 + if (defined($opts{access_token})) { 650 + die 'DPoP proof ath is required' unless length($claims->{ath} // q()); 651 + my $expected_ath = base64url_encode(sha256($opts{access_token})); 652 + die 'DPoP proof ath did not match token' 653 + unless timing_safe_eq($expected_ath, $claims->{ath}); 654 + } 655 + 656 + return { 657 + jkt => $jkt, 658 + header => $header, 659 + claims => $claims, 660 + }; 661 + } 662 + 663 + sub _request_url_without_query ($self, $c) { 664 + my $url = Mojo::URL->new($self->_issuer); 665 + my $req_url = $c->req->url->clone; 666 + $url->path($req_url->path->to_string); 667 + $url->query(undef); 668 + $url->fragment(undef); 669 + return $url->to_string; 670 + } 671 + 672 + sub _issuer ($self) { 673 + my $base = $self->settings->{base_url} // 'http://127.0.0.1:7755'; 674 + return Mojo::URL->new($base)->path('')->query(undef)->fragment(undef)->to_string =~ s{/\z}{}r; 675 + } 676 + 677 + sub _jwt_secret ($self) { 678 + my $secret = $self->settings->{jwt_secret}; 679 + die 'jwt_secret is not configured' 680 + unless defined $secret && length $secret; 681 + die 'jwt_secret is using the legacy dev default' 682 + if $secret eq 'perlsky-dev-secret'; 683 + return $secret; 684 + } 685 + 686 + sub _oauth_json_error ($c, $status, $error, $description) { 687 + $description =~ s/\s+at\s+\S+\s+line\s+\d+\.?\n?\z//; 688 + $c->res->code($status); 689 + $c->res->headers->header('Cache-Control' => 'no-store'); 690 + $c->res->headers->header('Pragma' => 'no-cache'); 691 + return $c->render(json => { 692 + error => $error, 693 + error_description => $description, 694 + }); 695 + } 696 + 697 + sub _render_authorize_error ($c, $status, $error, $description) { 698 + $c->render( 699 + status => $status, 700 + format => 'html', 701 + data => qq{<!doctype html><html><body><h1>@{[xml_escape($error)]}</h1><p>@{[xml_escape($description)]}</p></body></html>}, 702 + ); 703 + } 704 + 705 + sub _authorize_html (%args) { 706 + my $request = $args{request}; 707 + my $identifier = xml_escape($args{identifier} // q()); 708 + my $error = xml_escape($args{error} // q()); 709 + my $client = xml_escape($request->{client_name} || $request->{client_id}); 710 + my $scopes = join q{}, map { 711 + my $scope = xml_escape($_); 712 + qq{<li><code>$scope</code></li>} 713 + } grep { length } split /\s+/, ($request->{scope} // q()); 714 + 715 + return <<"HTML"; 716 + <!doctype html> 717 + <html lang="en"> 718 + <head> 719 + <meta charset="utf-8"> 720 + <meta name="viewport" content="width=device-width, initial-scale=1"> 721 + <title>Authorize $client</title> 722 + <style> 723 + body { font-family: sans-serif; max-width: 38rem; margin: 2rem auto; padding: 0 1rem; } 724 + label { display: block; margin-top: 1rem; font-weight: 600; } 725 + input { width: 100%; padding: 0.6rem; font: inherit; } 726 + .error { color: #8b0000; margin: 1rem 0; } 727 + .actions { margin-top: 1.5rem; display: flex; gap: 0.75rem; } 728 + button { padding: 0.7rem 1rem; font: inherit; } 729 + ul { padding-left: 1.25rem; } 730 + </style> 731 + </head> 732 + <body> 733 + <h1>Authorize $client</h1> 734 + <p>This application is requesting access to your perlsky account.</p> 735 + @{[$error ? qq{<p class="error">$error</p>} : q()]} 736 + <p>Requested scopes:</p> 737 + <ul>$scopes</ul> 738 + <form method="post" action="/oauth/authorize"> 739 + <input type="hidden" name="request_uri" value="@{[xml_escape($request->{request_uri})]}"> 740 + <label for="identifier">Handle or email</label> 741 + <input id="identifier" name="identifier" type="text" autocomplete="username" value="$identifier"> 742 + <label for="password">Password</label> 743 + <input id="password" name="password" type="password" autocomplete="current-password"> 744 + <div class="actions"> 745 + <button type="submit" name="decision" value="approve">Approve</button> 746 + <button type="submit" name="decision" value="deny">Deny</button> 747 + </div> 748 + </form> 749 + </body> 750 + </html> 751 + HTML 752 + } 753 + 754 + sub _normalize_scope ($scope) { 755 + my %seen; 756 + return join ' ', grep { !$seen{$_}++ } grep { length } split /\s+/, ($scope // q()); 757 + } 758 + 759 + sub _client_auth_matches ($request, $client_auth) { 760 + return 0 unless ($request->{client_auth_method} // q()) eq ($client_auth->{method} // q()); 761 + return 1 if ($client_auth->{method} // q()) eq 'none'; 762 + return 0 unless ($request->{client_auth_alg} // q()) eq ($client_auth->{alg} // q()); 763 + return 0 unless ($request->{client_auth_kid} // q()) eq ($client_auth->{kid} // q()); 764 + return 0 unless ($request->{client_auth_jkt} // q()) eq ($client_auth->{jkt} // q()); 765 + return 1; 766 + } 767 + 768 + sub _session_client_auth_matches ($session, $client_auth) { 769 + return 0 unless defined($session->{client_auth_alg}) || ($client_auth->{method} // q()) eq 'none'; 770 + return 0 unless ($session->{client_auth_alg} // q()) eq ($client_auth->{alg} // q()); 771 + return 0 unless ($session->{client_auth_kid} // q()) eq ($client_auth->{kid} // q()); 772 + return 0 unless ($session->{client_auth_jkt} // q()) eq ($client_auth->{jkt} // q()); 773 + return 1; 774 + } 775 + 776 + sub _revoke_session_chain ($c, $session) { 777 + my %seen; 778 + while ($session && !$seen{$session->{id}}++) { 779 + $c->store->revoke_session($session->{id}); 780 + my $next_id = $session->{next_id} // q(); 781 + last unless length $next_id; 782 + $session = $c->store->get_session($next_id); 783 + } 784 + } 785 + 786 + sub _jwt_parts ($jwt) { 787 + my ($header_b64, $claims_b64, $sig_b64) = split /\./, ($jwt // q()), 3; 788 + die 'token must contain three sections' 789 + unless defined($header_b64) && defined($claims_b64) && defined($sig_b64); 790 + my $header = decode_json(base64url_decode($header_b64)); 791 + my $claims = decode_json(base64url_decode($claims_b64)); 792 + my $signature = base64url_decode($sig_b64); 793 + return ($header, $claims, join('.', $header_b64, $claims_b64), $signature); 794 + } 795 + 796 + sub _verify_es256_signature ($jwk, $signing_str, $signature) { 797 + die 'jwk must be a P-256 EC key' 798 + unless ref($jwk) eq 'HASH' 799 + && ($jwk->{kty} // q()) eq 'EC' 800 + && ($jwk->{crv} // q()) eq 'P-256' 801 + && defined($jwk->{x}) 802 + && defined($jwk->{y}); 803 + my $public = _p256_public_key_from_jwk($jwk); 804 + my $pk = Crypt::PK::ECC->new; 805 + $pk->import_key_raw($public, 'prime256v1'); 806 + die 'signature verification failed' 807 + unless $pk->verify_message_rfc7518($signature, $signing_str, 'SHA256'); 808 + return 1; 809 + } 810 + 811 + sub _p256_public_key_from_jwk ($jwk) { 812 + my $x = base64url_decode($jwk->{x}); 813 + my $y = base64url_decode($jwk->{y}); 814 + die 'unexpected P-256 coordinate size' 815 + unless length($x) == 32 && length($y) == 32; 816 + return "\x04" . $x . $y; 817 + } 818 + 819 + sub _jwk_thumbprint ($jwk) { 820 + my %canonical = ( 821 + crv => $jwk->{crv} // q(), 822 + kty => $jwk->{kty} // q(), 823 + x => $jwk->{x} // q(), 824 + y => $jwk->{y} // q(), 825 + ); 826 + my $json = '{' . join(',', map { 827 + encode_json($_) . ':' . encode_json($canonical{$_}) 828 + } qw(crv kty x y)) . '}'; 829 + return base64url_encode(sha256($json)); 830 + } 831 + 832 + sub _is_localhost_url ($url) { 833 + my $host = lc($url->host // q()); 834 + return 1 if $host eq 'localhost'; 835 + return 1 if $host eq '127.0.0.1'; 836 + return 1 if $host eq '::1'; 837 + return 0; 838 + } 839 + 840 + sub _jwt_decode_error ($message) { 841 + return ('ExpiredToken', 'Token has expired') 842 + if $message =~ /expired/i; 843 + return ('InvalidToken', 'Token is not yet valid') 844 + if $message =~ /not yet valid/i; 845 + return ('InvalidToken', 'Token has an unexpected audience') 846 + if $message =~ /unexpected audience/i; 847 + return ('InvalidToken', 'Token has an invalid signature') 848 + if $message =~ /invalid signature/i; 849 + return ('InvalidToken', 'Token is malformed') 850 + if $message =~ /three sections/i; 851 + return ('InvalidToken', 'Token is invalid'); 852 + } 853 + 854 + 1;
+208 -4
lib/ATProto/PDS/Store/SQLite.pm
··· 313 313 q{ 314 314 INSERT INTO sessions ( 315 315 id, did, token, kind, scope, created_at, expires_at, 316 - revoked_at, ip, user_agent, next_id 317 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 316 + revoked_at, ip, user_agent, next_id, client_id, grant_id, 317 + dpop_jkt, client_auth_alg, client_auth_kid, client_auth_jkt 318 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 318 319 }, 319 320 undef, 320 321 $id, ··· 328 329 $args{ip}, 329 330 $args{user_agent}, 330 331 $args{next_id}, 332 + $args{client_id}, 333 + $args{grant_id}, 334 + $args{dpop_jkt}, 335 + $args{client_auth_alg}, 336 + $args{client_auth_kid}, 337 + $args{client_auth_jkt}, 331 338 ); 332 339 333 340 return $self->get_session($id); ··· 415 422 q{ 416 423 INSERT INTO sessions ( 417 424 id, did, token, kind, scope, created_at, expires_at, 418 - revoked_at, ip, user_agent, next_id 419 - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 425 + revoked_at, ip, user_agent, next_id, client_id, grant_id, 426 + dpop_jkt, client_auth_alg, client_auth_kid, client_auth_jkt 427 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 420 428 }, 421 429 undef, 422 430 $next_id, ··· 430 438 $session->{ip}, 431 439 $session->{user_agent}, 432 440 undef, 441 + $args{client_id} // $session->{client_id}, 442 + $args{grant_id} // $session->{grant_id}, 443 + $args{dpop_jkt} // $session->{dpop_jkt}, 444 + $args{client_auth_alg} // $session->{client_auth_alg}, 445 + $args{client_auth_kid} // $session->{client_auth_kid}, 446 + $args{client_auth_jkt} // $session->{client_auth_jkt}, 433 447 ); 434 448 435 449 return $dbh->selectrow_hashref( ··· 439 453 ); 440 454 }); 441 455 }); 456 + } 457 + 458 + sub create_oauth_request ($self, %args) { 459 + my $id = $args{id} // _random_id(); 460 + $self->dbh->do( 461 + q{ 462 + INSERT INTO oauth_requests ( 463 + id, request_uri, client_id, client_name, client_uri, redirect_uri, scope, state, 464 + nonce, login_hint, prompt, code_challenge, code_challenge_method, 465 + client_auth_method, client_auth_alg, client_auth_kid, client_auth_jkt, 466 + client_assertion_jti, client_assertion_exp, dpop_jkt, 467 + did, grant_id, code, created_at, expires_at, code_expires_at, consumed_at 468 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 469 + }, 470 + undef, 471 + $id, 472 + $args{request_uri}, 473 + $args{client_id}, 474 + $args{client_name}, 475 + $args{client_uri}, 476 + $args{redirect_uri}, 477 + $args{scope}, 478 + $args{state}, 479 + $args{nonce}, 480 + $args{login_hint}, 481 + $args{prompt}, 482 + $args{code_challenge}, 483 + $args{code_challenge_method}, 484 + $args{client_auth_method}, 485 + $args{client_auth_alg}, 486 + $args{client_auth_kid}, 487 + $args{client_auth_jkt}, 488 + $args{client_assertion_jti}, 489 + $args{client_assertion_exp}, 490 + $args{dpop_jkt}, 491 + $args{did}, 492 + $args{grant_id}, 493 + $args{code}, 494 + $args{created_at} // time, 495 + $args{expires_at}, 496 + $args{code_expires_at}, 497 + $args{consumed_at}, 498 + ); 499 + return $self->get_oauth_request($id); 500 + } 501 + 502 + sub get_oauth_request ($self, $id) { 503 + return $self->dbh->selectrow_hashref( 504 + q{SELECT * FROM oauth_requests WHERE id = ?}, 505 + undef, 506 + $id, 507 + ); 508 + } 509 + 510 + sub get_oauth_request_by_request_uri ($self, $request_uri) { 511 + return $self->dbh->selectrow_hashref( 512 + q{SELECT * FROM oauth_requests WHERE request_uri = ?}, 513 + undef, 514 + $request_uri, 515 + ); 516 + } 517 + 518 + sub get_oauth_request_by_code ($self, $code) { 519 + return $self->dbh->selectrow_hashref( 520 + q{SELECT * FROM oauth_requests WHERE code = ?}, 521 + undef, 522 + $code, 523 + ); 524 + } 525 + 526 + sub authorize_oauth_request ($self, %args) { 527 + $self->dbh->do( 528 + q{ 529 + UPDATE oauth_requests 530 + SET did = ?, grant_id = ?, code = ?, code_expires_at = ? 531 + WHERE id = ? 532 + }, 533 + undef, 534 + $args{did}, 535 + $args{grant_id}, 536 + $args{code}, 537 + $args{code_expires_at}, 538 + $args{id}, 539 + ); 540 + return $self->get_oauth_request($args{id}); 541 + } 542 + 543 + sub consume_oauth_request_code ($self, $id, %args) { 544 + $self->dbh->do( 545 + q{UPDATE oauth_requests SET consumed_at = ? WHERE id = ?}, 546 + undef, 547 + $args{consumed_at} // time, 548 + $id, 549 + ); 550 + return $self->get_oauth_request($id); 551 + } 552 + 553 + sub upsert_oauth_grant ($self, %args) { 554 + my $id = $args{id} // _random_id(); 555 + my $now = $args{updated_at} // time; 556 + $self->dbh->do( 557 + q{ 558 + INSERT INTO oauth_grants ( 559 + id, did, client_id, scope, created_at, updated_at 560 + ) VALUES (?, ?, ?, ?, ?, ?) 561 + ON CONFLICT(did, client_id) DO UPDATE SET 562 + scope = excluded.scope, 563 + updated_at = excluded.updated_at 564 + }, 565 + undef, 566 + $id, 567 + $args{did}, 568 + $args{client_id}, 569 + $args{scope}, 570 + $args{created_at} // $now, 571 + $now, 572 + ); 573 + return $self->get_oauth_grant_by_subject($args{did}, $args{client_id}); 574 + } 575 + 576 + sub get_oauth_grant ($self, $id) { 577 + return $self->dbh->selectrow_hashref( 578 + q{SELECT * FROM oauth_grants WHERE id = ?}, 579 + undef, 580 + $id, 581 + ); 582 + } 583 + 584 + sub get_oauth_grant_by_subject ($self, $did, $client_id) { 585 + return $self->dbh->selectrow_hashref( 586 + q{SELECT * FROM oauth_grants WHERE did = ? AND client_id = ?}, 587 + undef, 588 + $did, 589 + $client_id, 590 + ); 442 591 } 443 592 444 593 sub create_app_password ($self, %args) { ··· 1448 1597 version => 9, 1449 1598 statements => [ 1450 1599 q{ALTER TABLE labels ADD COLUMN neg INTEGER NOT NULL DEFAULT 0}, 1600 + ], 1601 + }, 1602 + { 1603 + version => 10, 1604 + statements => [ 1605 + q{ALTER TABLE sessions ADD COLUMN client_id TEXT}, 1606 + q{ALTER TABLE sessions ADD COLUMN grant_id TEXT}, 1607 + q{ALTER TABLE sessions ADD COLUMN dpop_jkt TEXT}, 1608 + q{ALTER TABLE sessions ADD COLUMN client_auth_alg TEXT}, 1609 + q{ALTER TABLE sessions ADD COLUMN client_auth_kid TEXT}, 1610 + q{ALTER TABLE sessions ADD COLUMN client_auth_jkt TEXT}, 1611 + q{ 1612 + CREATE TABLE IF NOT EXISTS oauth_requests ( 1613 + id TEXT PRIMARY KEY, 1614 + request_uri TEXT NOT NULL UNIQUE, 1615 + client_id TEXT NOT NULL, 1616 + client_name TEXT, 1617 + client_uri TEXT, 1618 + redirect_uri TEXT NOT NULL, 1619 + scope TEXT NOT NULL, 1620 + state TEXT, 1621 + nonce TEXT, 1622 + login_hint TEXT, 1623 + prompt TEXT, 1624 + code_challenge TEXT NOT NULL, 1625 + code_challenge_method TEXT NOT NULL, 1626 + client_auth_method TEXT NOT NULL, 1627 + client_auth_alg TEXT, 1628 + client_auth_kid TEXT, 1629 + client_auth_jkt TEXT, 1630 + client_assertion_jti TEXT, 1631 + client_assertion_exp INTEGER, 1632 + dpop_jkt TEXT, 1633 + did TEXT, 1634 + grant_id TEXT, 1635 + code TEXT UNIQUE, 1636 + created_at INTEGER NOT NULL, 1637 + expires_at INTEGER NOT NULL, 1638 + code_expires_at INTEGER, 1639 + consumed_at INTEGER 1640 + ) 1641 + }, 1642 + q{CREATE INDEX IF NOT EXISTS oauth_requests_by_request_uri ON oauth_requests (request_uri)}, 1643 + q{CREATE INDEX IF NOT EXISTS oauth_requests_by_code ON oauth_requests (code)}, 1644 + q{ 1645 + CREATE TABLE IF NOT EXISTS oauth_grants ( 1646 + id TEXT PRIMARY KEY, 1647 + did TEXT NOT NULL, 1648 + client_id TEXT NOT NULL, 1649 + scope TEXT NOT NULL, 1650 + created_at INTEGER NOT NULL, 1651 + updated_at INTEGER NOT NULL, 1652 + UNIQUE (did, client_id) 1653 + ) 1654 + }, 1451 1655 ], 1452 1656 }, 1453 1657 );