···5050 my $now = $opts{now} // time;
51515252 die 'token not yet valid' if defined $claims->{nbf} && $claims->{nbf} > $now;
5353- die 'token expired' if defined $claims->{exp} && $claims->{exp} <= $now;
5353+ die 'token expired' if !$opts{allow_expired} && defined $claims->{exp} && $claims->{exp} <= $now;
54545555 if (defined $opts{audience}) {
5656 my $aud = $claims->{aud};
+854
lib/ATProto/PDS/Auth/OAuth.pm
···11+package ATProto::PDS::Auth::OAuth;
22+33+use v5.34;
44+use warnings;
55+use feature 'signatures';
66+no warnings 'experimental::signatures';
77+88+use Exporter 'import';
99+use Mojo::Base -base, -signatures;
1010+1111+use Crypt::PK::ECC;
1212+use Digest::SHA qw(sha256);
1313+use JSON::PP qw(decode_json encode_json);
1414+use Mojo::JSON qw(false true);
1515+use Mojo::URL;
1616+use Mojo::UserAgent;
1717+use Mojo::Util qw(xml_escape);
1818+use ATProto::PDS::API::Helpers qw(find_account verify_login_password);
1919+use ATProto::PDS::API::Util qw(xrpc_error);
2020+use ATProto::PDS::Auth::JWT qw(decode_jwt encode_jwt);
2121+use ATProto::PDS::Auth::Password qw(random_hex timing_safe_eq);
2222+use ATProto::PDS::Constants qw(TOKEN_AUD_ACCESS TOKEN_AUD_REFRESH);
2323+use ATProto::PDS::Moderation qw(assert_login_allowed is_repo_takedown);
2424+use ATProto::PDS::Util::BaseX qw(base64url_decode base64url_encode);
2525+2626+our @EXPORT_OK = qw(
2727+ oauth_scope_allows
2828+ oauth_scope_has_atproto
2929+);
3030+3131+has settings => sub { {} };
3232+has ua => sub {
3333+ state $ua = Mojo::UserAgent->new;
3434+ $ua->max_redirects(5);
3535+ return $ua;
3636+};
3737+3838+sub protected_resource_metadata ($self) {
3939+ my $resource = $self->_issuer;
4040+ return {
4141+ resource => $resource,
4242+ authorization_servers => [$resource],
4343+ bearer_methods_supported => ['header'],
4444+ scopes_supported => [],
4545+ resource_documentation => 'https://atproto.com',
4646+ };
4747+}
4848+4949+sub authorization_server_metadata ($self) {
5050+ my $issuer = $self->_issuer;
5151+ return {
5252+ issuer => $issuer,
5353+ authorization_endpoint => $issuer . '/oauth/authorize',
5454+ token_endpoint => $issuer . '/oauth/token',
5555+ revocation_endpoint => $issuer . '/oauth/revoke',
5656+ pushed_authorization_request_endpoint => $issuer . '/oauth/par',
5757+ jwks_uri => $issuer . '/oauth/jwks',
5858+ response_types_supported => ['code'],
5959+ grant_types_supported => ['authorization_code', 'refresh_token'],
6060+ code_challenge_methods_supported => ['S256'],
6161+ token_endpoint_auth_methods_supported => ['private_key_jwt', 'none'],
6262+ token_endpoint_auth_signing_alg_values_supported => ['ES256'],
6363+ dpop_signing_alg_values_supported => ['ES256'],
6464+ authorization_response_iss_parameter_supported => true,
6565+ request_parameter_supported => true,
6666+ request_uri_parameter_supported => true,
6767+ require_request_uri_registration => true,
6868+ require_pushed_authorization_requests => true,
6969+ client_id_metadata_document_supported => true,
7070+ protected_resources => [$issuer],
7171+ };
7272+}
7373+7474+sub jwks ($self) {
7575+ return { keys => [] };
7676+}
7777+7878+sub pushed_authorization_request ($self, $c) {
7979+ my $body = $c->req->body_params->to_hash;
8080+ my $client_id = $body->{client_id} // q();
8181+ return _oauth_json_error($c, 400, 'invalid_request', 'client_id is required')
8282+ unless length $client_id;
8383+8484+ my $client = eval { $self->_load_client_metadata($client_id) };
8585+ return _oauth_json_error($c, 400, 'invalid_client', "$@") if $@;
8686+8787+ my $client_auth = eval {
8888+ $self->_verify_client_auth(
8989+ client => $client,
9090+ body => $body,
9191+ url => $self->_issuer . '/oauth/par',
9292+ );
9393+ };
9494+ return _oauth_json_error($c, 401, 'invalid_client', "$@") if $@;
9595+9696+ my $dpop = eval {
9797+ $self->_verify_dpop_proof(
9898+ $c->req->headers->header('DPoP'),
9999+ $c->req->method,
100100+ $self->_request_url_without_query($c),
101101+ );
102102+ };
103103+ return _oauth_json_error($c, 400, 'invalid_dpop_proof', "$@") if $@;
104104+105105+ my $redirect_uri = $body->{redirect_uri} // q();
106106+ my $scope = _normalize_scope($body->{scope} // q());
107107+ return _oauth_json_error($c, 400, 'invalid_request', 'response_type must be code')
108108+ unless ($body->{response_type} // q()) eq 'code';
109109+ return _oauth_json_error($c, 400, 'invalid_scope', 'scope must include atproto')
110110+ unless oauth_scope_has_atproto($scope);
111111+ return _oauth_json_error($c, 400, 'invalid_request', 'redirect_uri is required')
112112+ unless length $redirect_uri;
113113+ return _oauth_json_error($c, 400, 'invalid_request', 'redirect_uri is not registered')
114114+ unless grep { $_ eq $redirect_uri } @{ $client->{redirect_uris} // [] };
115115+ return _oauth_json_error($c, 400, 'invalid_request', 'code_challenge is required')
116116+ unless length($body->{code_challenge} // q());
117117+ return _oauth_json_error($c, 400, 'invalid_request', 'code_challenge_method must be S256')
118118+ unless ($body->{code_challenge_method} // q()) eq 'S256';
119119+120120+ if (defined($body->{resource}) && length($body->{resource}) && ($body->{resource} ne $self->_issuer)) {
121121+ return _oauth_json_error($c, 400, 'invalid_target', 'resource is not supported');
122122+ }
123123+124124+ my $request_uri = 'urn:ietf:params:oauth:request_uri:' . random_hex(24);
125125+ my $expires_at = time + 600;
126126+ $c->store->create_oauth_request(
127127+ id => random_hex(12),
128128+ request_uri => $request_uri,
129129+ client_id => $client_id,
130130+ client_name => $client->{client_name},
131131+ client_uri => $client->{client_uri},
132132+ redirect_uri => $redirect_uri,
133133+ scope => $scope,
134134+ state => $body->{state},
135135+ nonce => $body->{nonce},
136136+ login_hint => $body->{login_hint},
137137+ prompt => $body->{prompt},
138138+ code_challenge => $body->{code_challenge},
139139+ code_challenge_method => $body->{code_challenge_method},
140140+ client_auth_method => $client_auth->{method},
141141+ client_auth_alg => $client_auth->{alg},
142142+ client_auth_kid => $client_auth->{kid},
143143+ client_auth_jkt => $client_auth->{jkt},
144144+ client_assertion_jti => $client_auth->{jti},
145145+ client_assertion_exp => $client_auth->{exp},
146146+ dpop_jkt => $dpop->{jkt},
147147+ created_at => time,
148148+ expires_at => $expires_at,
149149+ );
150150+151151+ $c->res->code(201);
152152+ $c->res->headers->header('Cache-Control' => 'no-store');
153153+ $c->res->headers->header('Pragma' => 'no-cache');
154154+ $c->render(json => {
155155+ request_uri => $request_uri,
156156+ expires_in => $expires_at - time,
157157+ });
158158+}
159159+160160+sub render_authorize ($self, $c) {
161161+ my $request_uri = $c->param('request_uri') // q();
162162+ my $request = $c->store->get_oauth_request_by_request_uri($request_uri);
163163+ return _render_authorize_error($c, 400, 'invalid_request', 'authorization request was not found')
164164+ unless $request;
165165+ return _render_authorize_error($c, 400, 'invalid_request', 'authorization request has expired')
166166+ if ($request->{expires_at} // 0) < time;
167167+168168+ my $identifier = $c->param('identifier') // ($request->{login_hint} // q());
169169+ return $c->render(
170170+ status => 200,
171171+ format => 'html',
172172+ data => _authorize_html(
173173+ request => $request,
174174+ identifier => $identifier,
175175+ error => $c->param('error'),
176176+ issuer => $self->_issuer,
177177+ ),
178178+ );
179179+}
180180+181181+sub submit_authorize ($self, $c) {
182182+ my $body = $c->req->body_params->to_hash;
183183+ my $request_uri = $body->{request_uri} // q();
184184+ my $request = $c->store->get_oauth_request_by_request_uri($request_uri);
185185+ return _render_authorize_error($c, 400, 'invalid_request', 'authorization request was not found')
186186+ unless $request;
187187+ return _render_authorize_error($c, 400, 'invalid_request', 'authorization request has expired')
188188+ if ($request->{expires_at} // 0) < time;
189189+190190+ if (($body->{decision} // 'approve') ne 'approve') {
191191+ return $c->redirect_to($self->_authorization_redirect($request, error => 'access_denied'));
192192+ }
193193+194194+ my $identifier = $body->{identifier} // ($request->{login_hint} // q());
195195+ my $account = find_account($c, $identifier);
196196+ my $authn = $account ? verify_login_password($c, $account, $body->{password} // q()) : undef;
197197+ unless ($account && $authn && ($authn->{kind} // q()) eq 'account') {
198198+ return $c->render(
199199+ status => 401,
200200+ format => 'html',
201201+ data => _authorize_html(
202202+ request => $request,
203203+ identifier => $identifier,
204204+ error => 'Invalid identifier or password',
205205+ issuer => $self->_issuer,
206206+ ),
207207+ );
208208+ }
209209+ if (is_repo_takedown($c, $account->{did})) {
210210+ return $c->render(
211211+ status => 401,
212212+ format => 'html',
213213+ data => _authorize_html(
214214+ request => $request,
215215+ identifier => $identifier,
216216+ error => 'Invalid identifier or password',
217217+ issuer => $self->_issuer,
218218+ ),
219219+ );
220220+ }
221221+ assert_login_allowed($c, $account);
222222+223223+ my $grant = $c->store->upsert_oauth_grant(
224224+ did => $account->{did},
225225+ client_id => $request->{client_id},
226226+ scope => $request->{scope},
227227+ created_at => time,
228228+ updated_at => time,
229229+ );
230230+231231+ my $code = random_hex(24);
232232+ $c->store->authorize_oauth_request(
233233+ id => $request->{id},
234234+ did => $account->{did},
235235+ grant_id => $grant->{id},
236236+ code => $code,
237237+ code_expires_at => time + 60,
238238+ );
239239+240240+ return $c->redirect_to($self->_authorization_redirect($request, code => $code));
241241+}
242242+243243+sub token ($self, $c) {
244244+ my $body = $c->req->body_params->to_hash;
245245+ my $client_id = $body->{client_id} // q();
246246+ return _oauth_json_error($c, 400, 'invalid_request', 'client_id is required')
247247+ unless length $client_id;
248248+249249+ my $client = eval { $self->_load_client_metadata($client_id) };
250250+ return _oauth_json_error($c, 400, 'invalid_client', "$@") if $@;
251251+252252+ my $client_auth = eval {
253253+ $self->_verify_client_auth(
254254+ client => $client,
255255+ body => $body,
256256+ url => $self->_issuer . '/oauth/token',
257257+ );
258258+ };
259259+ return _oauth_json_error($c, 401, 'invalid_client', "$@") if $@;
260260+261261+ my $dpop = eval {
262262+ $self->_verify_dpop_proof(
263263+ $c->req->headers->header('DPoP'),
264264+ $c->req->method,
265265+ $self->_request_url_without_query($c),
266266+ );
267267+ };
268268+ return _oauth_json_error($c, 400, 'invalid_dpop_proof', "$@") if $@;
269269+270270+ my $grant_type = $body->{grant_type} // q();
271271+ my $response = eval {
272272+ if ($grant_type eq 'authorization_code') {
273273+ return $self->_exchange_authorization_code($c, $body, $client_auth, $dpop);
274274+ }
275275+ if ($grant_type eq 'refresh_token') {
276276+ return $self->_refresh_token_grant($c, $body, $client_auth, $dpop);
277277+ }
278278+ die 'unsupported grant_type';
279279+ };
280280+ if (my $err = $@) {
281281+ my $message = "$err";
282282+ my ($error, $description) = $message =~ /\A([^:]+):\s*(.+)\z/
283283+ ? ($1, $2)
284284+ : ('invalid_grant', $message);
285285+ return _oauth_json_error($c, $error eq 'invalid_client' ? 401 : 400, $error, $description);
286286+ }
287287+288288+ $c->res->headers->header('Cache-Control' => 'no-store');
289289+ $c->res->headers->header('Pragma' => 'no-cache');
290290+ $c->render(json => $response);
291291+}
292292+293293+sub revoke ($self, $c) {
294294+ my $body = $c->req->body_params->to_hash;
295295+ my $client_id = $body->{client_id} // q();
296296+ return _oauth_json_error($c, 400, 'invalid_request', 'client_id is required')
297297+ unless length $client_id;
298298+299299+ my $client = eval { $self->_load_client_metadata($client_id) };
300300+ return _oauth_json_error($c, 400, 'invalid_client', "$@") if $@;
301301+302302+ my $client_auth = eval {
303303+ $self->_verify_client_auth(
304304+ client => $client,
305305+ body => $body,
306306+ url => $self->_issuer . '/oauth/revoke',
307307+ );
308308+ };
309309+ return _oauth_json_error($c, 401, 'invalid_client', "$@") if $@;
310310+311311+ eval {
312312+ $self->_verify_dpop_proof(
313313+ $c->req->headers->header('DPoP'),
314314+ $c->req->method,
315315+ $self->_request_url_without_query($c),
316316+ );
317317+ };
318318+ return _oauth_json_error($c, 400, 'invalid_dpop_proof', "$@") if $@;
319319+320320+ my $token = $body->{token} // q();
321321+ if (length $token) {
322322+ my $decoded = eval { decode_jwt($token, $self->_jwt_secret, allow_expired => 1) };
323323+ if ($decoded) {
324324+ my $claims = $decoded->{claims};
325325+ my $session = $c->store->get_session($claims->{jti} // q());
326326+ if ($session && ($session->{kind} // q()) eq 'oauth' && ($session->{client_id} // q()) eq $client_id) {
327327+ _revoke_session_chain($c, $session);
328328+ }
329329+ }
330330+ }
331331+332332+ $c->res->headers->header('Cache-Control' => 'no-store');
333333+ $c->res->headers->header('Pragma' => 'no-cache');
334334+ $c->render(json => {});
335335+}
336336+337337+sub authenticate_oauth_access_token ($self, $c, $token, %opts) {
338338+ my $decoded = eval { decode_jwt($token, $self->_jwt_secret) };
339339+ if (my $err = $@) {
340340+ my ($code, $safe_message) = _jwt_decode_error("$err");
341341+ xrpc_error(401, $code, $safe_message);
342342+ }
343343+344344+ my $claims = $decoded->{claims};
345345+ xrpc_error(401, 'InvalidToken', 'Unexpected token audience')
346346+ unless ($claims->{aud} // q()) eq ($opts{audience} // TOKEN_AUD_ACCESS);
347347+ xrpc_error(401, 'InvalidToken', 'Unexpected token type')
348348+ unless ($claims->{typ} // q()) eq 'oauth_access';
349349+350350+ my $session_id = $claims->{jti} // q();
351351+ xrpc_error(401, 'InvalidToken', 'Token is missing a session identifier')
352352+ unless length $session_id;
353353+354354+ my $session = $c->store->get_session($session_id);
355355+ xrpc_error(401, 'InvalidToken', 'Token session was not found') unless $session;
356356+ xrpc_error(401, 'InvalidToken', 'Token session is not OAuth-backed')
357357+ unless ($session->{kind} // q()) eq 'oauth';
358358+ xrpc_error(401, 'ExpiredToken', 'Token session has already been revoked')
359359+ if defined $session->{revoked_at};
360360+ xrpc_error(401, 'ExpiredToken', 'Token session has expired')
361361+ if defined($session->{expires_at}) && $session->{expires_at} < time;
362362+ xrpc_error(401, 'InvalidToken', 'Token session did not match token subject')
363363+ unless ($session->{did} // q()) eq ($claims->{sub} // q());
364364+365365+ my $cnf = $claims->{cnf};
366366+ xrpc_error(401, 'InvalidToken', 'Token is missing confirmation key')
367367+ unless ref($cnf) eq 'HASH' && length($cnf->{jkt} // q());
368368+ xrpc_error(401, 'InvalidToken', 'Token confirmation key did not match session binding')
369369+ unless ($session->{dpop_jkt} // q()) eq ($cnf->{jkt} // q());
370370+371371+ my $proof = eval {
372372+ $self->_verify_dpop_proof(
373373+ $c->req->headers->header('DPoP'),
374374+ $c->req->method,
375375+ $self->_request_url_without_query($c),
376376+ access_token => $token,
377377+ expected_jkt => $cnf->{jkt},
378378+ );
379379+ };
380380+ if (my $err = $@) {
381381+ xrpc_error(401, 'InvalidToken', "$err");
382382+ }
383383+384384+ my $scope = _normalize_scope($claims->{scope} // $session->{scope});
385385+ if ($opts{required_scope} && !oauth_scope_allows($scope, $opts{required_scope})) {
386386+ xrpc_error(400, 'InvalidToken', 'Bad token scope');
387387+ }
388388+389389+ my $account = $c->store->get_account_by_did($claims->{sub});
390390+ xrpc_error(401, 'InvalidToken', 'Token subject no longer exists') unless $account;
391391+ xrpc_error(401, 'InvalidToken', 'Token subject has been deleted') if defined $account->{deleted_at};
392392+ return ($claims, $account, $session, $proof);
393393+}
394394+395395+sub oauth_scope_has_atproto ($scope) {
396396+ return scalar grep { $_ eq 'atproto' } split /\s+/, ($scope // q());
397397+}
398398+399399+sub oauth_scope_allows ($scope, $required_scope) {
400400+ return 1 if oauth_scope_has_atproto($scope);
401401+ return 0;
402402+}
403403+404404+sub _exchange_authorization_code ($self, $c, $body, $client_auth, $dpop) {
405405+ my $code = $body->{code} // q();
406406+ die 'invalid_grant: code is required' unless length $code;
407407+408408+ my $request = $c->store->get_oauth_request_by_code($code);
409409+ die 'invalid_grant: authorization code was not found' unless $request;
410410+ die 'invalid_grant: authorization code has already been used'
411411+ if defined $request->{consumed_at};
412412+ die 'invalid_grant: authorization code has expired'
413413+ if ($request->{code_expires_at} // 0) < time;
414414+ die 'invalid_grant: authorization code was not approved' unless length($request->{did} // q());
415415+ die 'invalid_grant: client_id did not match authorization request'
416416+ unless ($request->{client_id} // q()) eq ($body->{client_id} // q());
417417+ die 'invalid_grant: redirect_uri did not match authorization request'
418418+ unless ($request->{redirect_uri} // q()) eq ($body->{redirect_uri} // q());
419419+ die 'invalid_grant: client authentication changed during authorization flow'
420420+ unless _client_auth_matches($request, $client_auth);
421421+422422+ my $verifier = $body->{code_verifier} // q();
423423+ die 'invalid_grant: code_verifier is required' unless length $verifier;
424424+ my $challenge = base64url_encode(sha256($verifier));
425425+ die 'invalid_grant: PKCE verification failed'
426426+ unless timing_safe_eq($challenge, $request->{code_challenge} // q());
427427+428428+ my $grant = $c->store->get_oauth_grant($request->{grant_id} // q());
429429+ die 'invalid_grant: authorization grant was not found' unless $grant;
430430+ my $account = $c->store->get_account_by_did($request->{did});
431431+ die 'invalid_grant: authorization subject no longer exists' unless $account;
432432+433433+ my $session = $c->store->create_session(
434434+ did => $account->{did},
435435+ kind => 'oauth',
436436+ scope => $grant->{scope},
437437+ expires_at => time + (90 * 24 * 60 * 60),
438438+ client_id => $request->{client_id},
439439+ grant_id => $grant->{id},
440440+ dpop_jkt => $dpop->{jkt},
441441+ client_auth_alg => $client_auth->{alg},
442442+ client_auth_kid => $client_auth->{kid},
443443+ client_auth_jkt => $client_auth->{jkt},
444444+ );
445445+446446+ $c->store->consume_oauth_request_code($request->{id});
447447+ return $self->_oauth_token_response($account, $session);
448448+}
449449+450450+sub _refresh_token_grant ($self, $c, $body, $client_auth, $dpop) {
451451+ my $refresh_token = $body->{refresh_token} // q();
452452+ die 'invalid_grant: refresh_token is required' unless length $refresh_token;
453453+454454+ my $decoded = eval { decode_jwt($refresh_token, $self->_jwt_secret) };
455455+ die 'invalid_grant: refresh token is invalid' unless $decoded;
456456+457457+ my $claims = $decoded->{claims};
458458+ die 'invalid_grant: refresh token audience is invalid'
459459+ unless ($claims->{aud} // q()) eq TOKEN_AUD_REFRESH;
460460+ die 'invalid_grant: refresh token type is invalid'
461461+ unless ($claims->{typ} // q()) eq 'oauth_refresh';
462462+463463+ my $session = $c->store->get_session($claims->{jti} // q());
464464+ die 'invalid_grant: refresh token session was not found' unless $session;
465465+ die 'invalid_grant: refresh token session is not OAuth-backed'
466466+ unless ($session->{kind} // q()) eq 'oauth';
467467+ die 'invalid_grant: client_id did not match session'
468468+ unless ($session->{client_id} // q()) eq ($body->{client_id} // q());
469469+ die 'invalid_grant: client authentication did not match session'
470470+ unless _session_client_auth_matches($session, $client_auth);
471471+ die 'invalid_grant: DPoP key changed during refresh'
472472+ unless ($session->{dpop_jkt} // q()) eq ($dpop->{jkt} // q());
473473+474474+ my $rotated = $c->store->rotate_session(
475475+ $session->{id},
476476+ session_ttl => (90 * 24 * 60 * 60),
477477+ grace_ttl => 300,
478478+ );
479479+ die 'invalid_grant: refresh token has already been used or revoked' unless $rotated;
480480+481481+ my $account = $c->store->get_account_by_did($rotated->{did});
482482+ die 'invalid_grant: session subject no longer exists' unless $account;
483483+ return $self->_oauth_token_response($account, $rotated);
484484+}
485485+486486+sub _oauth_token_response ($self, $account, $session) {
487487+ my $issuer = $self->_issuer;
488488+ my $secret = $self->_jwt_secret;
489489+ my $now = time;
490490+ my $scope = _normalize_scope($session->{scope});
491491+492492+ my $access_token = encode_jwt({
493493+ iss => $issuer,
494494+ sub => $account->{did},
495495+ aud => TOKEN_AUD_ACCESS,
496496+ typ => 'oauth_access',
497497+ scope => $scope,
498498+ cnf => { jkt => $session->{dpop_jkt} },
499499+ jti => $session->{id},
500500+ exp => $now + 3600,
501501+ }, $secret);
502502+503503+ my $refresh_token = encode_jwt({
504504+ iss => $issuer,
505505+ sub => $account->{did},
506506+ aud => TOKEN_AUD_REFRESH,
507507+ typ => 'oauth_refresh',
508508+ scope => $scope,
509509+ client_id => $session->{client_id},
510510+ jti => $session->{id},
511511+ exp => $session->{expires_at} // ($now + (90 * 24 * 60 * 60)),
512512+ }, $secret);
513513+514514+ return {
515515+ access_token => $access_token,
516516+ token_type => 'DPoP',
517517+ expires_in => 3600,
518518+ refresh_token => $refresh_token,
519519+ scope => $scope,
520520+ sub => $account->{did},
521521+ };
522522+}
523523+524524+sub _authorization_redirect ($self, $request, %params) {
525525+ my $url = Mojo::URL->new($request->{redirect_uri});
526526+ my %query = map {
527527+ my $value = $params{$_};
528528+ defined($value) && length($value) ? ($_ => $value) : ()
529529+ } sort keys %params;
530530+ $query{state} = $request->{state} if defined($request->{state}) && length($request->{state});
531531+ $query{iss} = $self->_issuer;
532532+ $url->query(\%query);
533533+ return $url->to_string;
534534+}
535535+536536+sub _load_client_metadata ($self, $client_id) {
537537+ my $url = Mojo::URL->new($client_id);
538538+ die 'client_id must be an absolute URL'
539539+ unless length($url->scheme // q()) && length($url->host // q());
540540+ die 'client_id must use https'
541541+ if lc($url->scheme // q()) ne 'https' && !_is_localhost_url($url);
542542+543543+ my $tx = $self->ua->get($url);
544544+ die 'unable to fetch client metadata' unless $tx->result;
545545+ die 'unable to fetch client metadata'
546546+ if $tx->error || !$tx->result->is_success;
547547+ my $json = $tx->result->json;
548548+ die 'client metadata must be a JSON object' unless ref($json) eq 'HASH';
549549+ die 'client metadata client_id did not match request'
550550+ unless ($json->{client_id} // q()) eq $client_id;
551551+ die 'client metadata must declare code response support'
552552+ unless grep { $_ eq 'code' } @{ $json->{response_types} // [] };
553553+ die 'client metadata must declare authorization_code grant support'
554554+ unless grep { $_ eq 'authorization_code' } @{ $json->{grant_types} // [] };
555555+ die 'client metadata must declare DPoP-bound access tokens'
556556+ unless $json->{dpop_bound_access_tokens};
557557+558558+ my $method = $json->{token_endpoint_auth_method} // 'none';
559559+ die 'unsupported client authentication method'
560560+ unless $method eq 'private_key_jwt' || $method eq 'none';
561561+ if ($method eq 'private_key_jwt') {
562562+ die 'client metadata must provide jwks_uri for private_key_jwt'
563563+ unless length($json->{jwks_uri} // q()) || ref($json->{jwks}) eq 'HASH';
564564+ }
565565+566566+ return $json;
567567+}
568568+569569+sub _verify_client_auth ($self, %args) {
570570+ my $client = $args{client};
571571+ my $body = $args{body} || {};
572572+ my $method = $client->{token_endpoint_auth_method} // 'none';
573573+ return { method => 'none' } if $method eq 'none';
574574+575575+ my $assertion_type = $body->{client_assertion_type} // q();
576576+ my $assertion = $body->{client_assertion} // q();
577577+ die 'client_assertion is required'
578578+ unless $assertion_type eq 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
579579+ && length $assertion;
580580+581581+ my ($header, $claims, $signing_str, $signature) = _jwt_parts($assertion);
582582+ die 'unsupported client assertion alg' unless ($header->{alg} // q()) eq 'ES256';
583583+ my $jwk = $self->_resolve_client_jwk($client, $header->{kid});
584584+ _verify_es256_signature($jwk, $signing_str, $signature);
585585+586586+ my $client_id = $client->{client_id} // q();
587587+ die 'client assertion subject is invalid' unless ($claims->{sub} // q()) eq $client_id;
588588+ die 'client assertion issuer is invalid' unless ($claims->{iss} // q()) eq $client_id;
589589+ die 'client assertion jti is required' unless length($claims->{jti} // q());
590590+ my $now = time;
591591+ die 'client assertion has expired' if ($claims->{exp} // 0) <= $now;
592592+ my @audiences = ($self->_issuer, $args{url});
593593+ my $aud = $claims->{aud};
594594+ my $aud_ok = ref($aud) eq 'ARRAY'
595595+ ? scalar grep { my $candidate = $_; grep { $candidate eq $_ } @audiences } @$aud
596596+ : scalar grep { defined($aud) && $aud eq $_ } @audiences;
597597+ die 'client assertion audience is invalid' unless $aud_ok;
598598+599599+ return {
600600+ method => 'private_key_jwt',
601601+ alg => $header->{alg},
602602+ kid => $header->{kid},
603603+ jkt => _jwk_thumbprint($jwk),
604604+ jti => $claims->{jti},
605605+ exp => $claims->{exp},
606606+ };
607607+}
608608+609609+sub _resolve_client_jwk ($self, $client, $kid = undef) {
610610+ my $jwks = $client->{jwks};
611611+ if (!$jwks && length($client->{jwks_uri} // q())) {
612612+ my $url = Mojo::URL->new($client->{jwks_uri});
613613+ die 'jwks_uri must use https'
614614+ if lc($url->scheme // q()) ne 'https' && !_is_localhost_url($url);
615615+ my $tx = $self->ua->get($url);
616616+ die 'unable to fetch client jwks' unless $tx->result && $tx->result->is_success;
617617+ $jwks = $tx->result->json;
618618+ }
619619+ die 'client jwks must be a JSON object' unless ref($jwks) eq 'HASH';
620620+ my $keys = $jwks->{keys};
621621+ die 'client jwks must contain keys' unless ref($keys) eq 'ARRAY' && @$keys;
622622+ my @matches = defined($kid) && length($kid)
623623+ ? grep { ($_->{kid} // q()) eq $kid } @$keys
624624+ : @$keys;
625625+ die 'client jwk was not found' unless @matches;
626626+ return $matches[0];
627627+}
628628+629629+sub _verify_dpop_proof ($self, $proof, $method, $url, %opts) {
630630+ die 'DPoP proof is required' unless defined $proof && length $proof;
631631+ my ($header, $claims, $signing_str, $signature) = _jwt_parts($proof);
632632+ die 'DPoP proof alg is invalid' unless ($header->{alg} // q()) eq 'ES256';
633633+ die 'DPoP proof typ is invalid' unless lc($header->{typ} // q()) eq 'dpop+jwt';
634634+ die 'DPoP proof must include a JWK' unless ref($header->{jwk}) eq 'HASH';
635635+ _verify_es256_signature($header->{jwk}, $signing_str, $signature);
636636+637637+ my $now = time;
638638+ die 'DPoP proof is expired or too far in the future'
639639+ if abs(($claims->{iat} // 0) - $now) > 300;
640640+ die 'DPoP proof jti is required' unless length($claims->{jti} // q());
641641+ die 'DPoP proof htm did not match request'
642642+ unless uc($claims->{htm} // q()) eq uc($method // q());
643643+ die 'DPoP proof htu did not match request'
644644+ unless ($claims->{htu} // q()) eq $url;
645645+646646+ my $jkt = _jwk_thumbprint($header->{jwk});
647647+ die 'DPoP proof key did not match token binding'
648648+ if defined($opts{expected_jkt}) && ($opts{expected_jkt} // q()) ne $jkt;
649649+ if (defined($opts{access_token})) {
650650+ die 'DPoP proof ath is required' unless length($claims->{ath} // q());
651651+ my $expected_ath = base64url_encode(sha256($opts{access_token}));
652652+ die 'DPoP proof ath did not match token'
653653+ unless timing_safe_eq($expected_ath, $claims->{ath});
654654+ }
655655+656656+ return {
657657+ jkt => $jkt,
658658+ header => $header,
659659+ claims => $claims,
660660+ };
661661+}
662662+663663+sub _request_url_without_query ($self, $c) {
664664+ my $url = Mojo::URL->new($self->_issuer);
665665+ my $req_url = $c->req->url->clone;
666666+ $url->path($req_url->path->to_string);
667667+ $url->query(undef);
668668+ $url->fragment(undef);
669669+ return $url->to_string;
670670+}
671671+672672+sub _issuer ($self) {
673673+ my $base = $self->settings->{base_url} // 'http://127.0.0.1:7755';
674674+ return Mojo::URL->new($base)->path('')->query(undef)->fragment(undef)->to_string =~ s{/\z}{}r;
675675+}
676676+677677+sub _jwt_secret ($self) {
678678+ my $secret = $self->settings->{jwt_secret};
679679+ die 'jwt_secret is not configured'
680680+ unless defined $secret && length $secret;
681681+ die 'jwt_secret is using the legacy dev default'
682682+ if $secret eq 'perlsky-dev-secret';
683683+ return $secret;
684684+}
685685+686686+sub _oauth_json_error ($c, $status, $error, $description) {
687687+ $description =~ s/\s+at\s+\S+\s+line\s+\d+\.?\n?\z//;
688688+ $c->res->code($status);
689689+ $c->res->headers->header('Cache-Control' => 'no-store');
690690+ $c->res->headers->header('Pragma' => 'no-cache');
691691+ return $c->render(json => {
692692+ error => $error,
693693+ error_description => $description,
694694+ });
695695+}
696696+697697+sub _render_authorize_error ($c, $status, $error, $description) {
698698+ $c->render(
699699+ status => $status,
700700+ format => 'html',
701701+ data => qq{<!doctype html><html><body><h1>@{[xml_escape($error)]}</h1><p>@{[xml_escape($description)]}</p></body></html>},
702702+ );
703703+}
704704+705705+sub _authorize_html (%args) {
706706+ my $request = $args{request};
707707+ my $identifier = xml_escape($args{identifier} // q());
708708+ my $error = xml_escape($args{error} // q());
709709+ my $client = xml_escape($request->{client_name} || $request->{client_id});
710710+ my $scopes = join q{}, map {
711711+ my $scope = xml_escape($_);
712712+ qq{<li><code>$scope</code></li>}
713713+ } grep { length } split /\s+/, ($request->{scope} // q());
714714+715715+ return <<"HTML";
716716+<!doctype html>
717717+<html lang="en">
718718+ <head>
719719+ <meta charset="utf-8">
720720+ <meta name="viewport" content="width=device-width, initial-scale=1">
721721+ <title>Authorize $client</title>
722722+ <style>
723723+ body { font-family: sans-serif; max-width: 38rem; margin: 2rem auto; padding: 0 1rem; }
724724+ label { display: block; margin-top: 1rem; font-weight: 600; }
725725+ input { width: 100%; padding: 0.6rem; font: inherit; }
726726+ .error { color: #8b0000; margin: 1rem 0; }
727727+ .actions { margin-top: 1.5rem; display: flex; gap: 0.75rem; }
728728+ button { padding: 0.7rem 1rem; font: inherit; }
729729+ ul { padding-left: 1.25rem; }
730730+ </style>
731731+ </head>
732732+ <body>
733733+ <h1>Authorize $client</h1>
734734+ <p>This application is requesting access to your perlsky account.</p>
735735+ @{[$error ? qq{<p class="error">$error</p>} : q()]}
736736+ <p>Requested scopes:</p>
737737+ <ul>$scopes</ul>
738738+ <form method="post" action="/oauth/authorize">
739739+ <input type="hidden" name="request_uri" value="@{[xml_escape($request->{request_uri})]}">
740740+ <label for="identifier">Handle or email</label>
741741+ <input id="identifier" name="identifier" type="text" autocomplete="username" value="$identifier">
742742+ <label for="password">Password</label>
743743+ <input id="password" name="password" type="password" autocomplete="current-password">
744744+ <div class="actions">
745745+ <button type="submit" name="decision" value="approve">Approve</button>
746746+ <button type="submit" name="decision" value="deny">Deny</button>
747747+ </div>
748748+ </form>
749749+ </body>
750750+</html>
751751+HTML
752752+}
753753+754754+sub _normalize_scope ($scope) {
755755+ my %seen;
756756+ return join ' ', grep { !$seen{$_}++ } grep { length } split /\s+/, ($scope // q());
757757+}
758758+759759+sub _client_auth_matches ($request, $client_auth) {
760760+ return 0 unless ($request->{client_auth_method} // q()) eq ($client_auth->{method} // q());
761761+ return 1 if ($client_auth->{method} // q()) eq 'none';
762762+ return 0 unless ($request->{client_auth_alg} // q()) eq ($client_auth->{alg} // q());
763763+ return 0 unless ($request->{client_auth_kid} // q()) eq ($client_auth->{kid} // q());
764764+ return 0 unless ($request->{client_auth_jkt} // q()) eq ($client_auth->{jkt} // q());
765765+ return 1;
766766+}
767767+768768+sub _session_client_auth_matches ($session, $client_auth) {
769769+ return 0 unless defined($session->{client_auth_alg}) || ($client_auth->{method} // q()) eq 'none';
770770+ return 0 unless ($session->{client_auth_alg} // q()) eq ($client_auth->{alg} // q());
771771+ return 0 unless ($session->{client_auth_kid} // q()) eq ($client_auth->{kid} // q());
772772+ return 0 unless ($session->{client_auth_jkt} // q()) eq ($client_auth->{jkt} // q());
773773+ return 1;
774774+}
775775+776776+sub _revoke_session_chain ($c, $session) {
777777+ my %seen;
778778+ while ($session && !$seen{$session->{id}}++) {
779779+ $c->store->revoke_session($session->{id});
780780+ my $next_id = $session->{next_id} // q();
781781+ last unless length $next_id;
782782+ $session = $c->store->get_session($next_id);
783783+ }
784784+}
785785+786786+sub _jwt_parts ($jwt) {
787787+ my ($header_b64, $claims_b64, $sig_b64) = split /\./, ($jwt // q()), 3;
788788+ die 'token must contain three sections'
789789+ unless defined($header_b64) && defined($claims_b64) && defined($sig_b64);
790790+ my $header = decode_json(base64url_decode($header_b64));
791791+ my $claims = decode_json(base64url_decode($claims_b64));
792792+ my $signature = base64url_decode($sig_b64);
793793+ return ($header, $claims, join('.', $header_b64, $claims_b64), $signature);
794794+}
795795+796796+sub _verify_es256_signature ($jwk, $signing_str, $signature) {
797797+ die 'jwk must be a P-256 EC key'
798798+ unless ref($jwk) eq 'HASH'
799799+ && ($jwk->{kty} // q()) eq 'EC'
800800+ && ($jwk->{crv} // q()) eq 'P-256'
801801+ && defined($jwk->{x})
802802+ && defined($jwk->{y});
803803+ my $public = _p256_public_key_from_jwk($jwk);
804804+ my $pk = Crypt::PK::ECC->new;
805805+ $pk->import_key_raw($public, 'prime256v1');
806806+ die 'signature verification failed'
807807+ unless $pk->verify_message_rfc7518($signature, $signing_str, 'SHA256');
808808+ return 1;
809809+}
810810+811811+sub _p256_public_key_from_jwk ($jwk) {
812812+ my $x = base64url_decode($jwk->{x});
813813+ my $y = base64url_decode($jwk->{y});
814814+ die 'unexpected P-256 coordinate size'
815815+ unless length($x) == 32 && length($y) == 32;
816816+ return "\x04" . $x . $y;
817817+}
818818+819819+sub _jwk_thumbprint ($jwk) {
820820+ my %canonical = (
821821+ crv => $jwk->{crv} // q(),
822822+ kty => $jwk->{kty} // q(),
823823+ x => $jwk->{x} // q(),
824824+ y => $jwk->{y} // q(),
825825+ );
826826+ my $json = '{' . join(',', map {
827827+ encode_json($_) . ':' . encode_json($canonical{$_})
828828+ } qw(crv kty x y)) . '}';
829829+ return base64url_encode(sha256($json));
830830+}
831831+832832+sub _is_localhost_url ($url) {
833833+ my $host = lc($url->host // q());
834834+ return 1 if $host eq 'localhost';
835835+ return 1 if $host eq '127.0.0.1';
836836+ return 1 if $host eq '::1';
837837+ return 0;
838838+}
839839+840840+sub _jwt_decode_error ($message) {
841841+ return ('ExpiredToken', 'Token has expired')
842842+ if $message =~ /expired/i;
843843+ return ('InvalidToken', 'Token is not yet valid')
844844+ if $message =~ /not yet valid/i;
845845+ return ('InvalidToken', 'Token has an unexpected audience')
846846+ if $message =~ /unexpected audience/i;
847847+ return ('InvalidToken', 'Token has an invalid signature')
848848+ if $message =~ /invalid signature/i;
849849+ return ('InvalidToken', 'Token is malformed')
850850+ if $message =~ /three sections/i;
851851+ return ('InvalidToken', 'Token is invalid');
852852+}
853853+854854+1;