blib/lib/Plack/Auth/SSO/OIDC.pm | |||
---|---|---|---|
Criterion | Covered | Total | % |
statement | 45 | 186 | 24.1 |
branch | 0 | 46 | 0.0 |
condition | 0 | 5 | 0.0 |
subroutine | 15 | 29 | 51.7 |
pod | 1 | 7 | 14.2 |
total | 61 | 273 | 22.3 |
line | stmt | bran | cond | sub | pod | time | code |
---|---|---|---|---|---|---|---|
1 | package Plack::Auth::SSO::OIDC; 2: 3: use strict; 4: use warnings; 5: use feature qw(:5.10); 6: use Data::Util qw(:check); 7: use Data::UUID; 8: use Moo; 9: use Plack::Request; 10: use Plack::Session; 11: use URI; 12: use LWP::UserAgent; 13: use JSON; 14: use Crypt::JWT; 15: use MIME::Base64; 16: use Digest::SHA; 17: use Try::Tiny; 18: 19: our $VERSION = "0.013"; 20: 21: with "Plack::Auth::SSO"; 22: 23: has scope => ( 24: is => "lazy", 25: isa => sub { 26: is_string($_[0]) or die("scope should be string"); 27: index($_[0], "openid") >= 0 or die("default scope openid not included"); 28: }, 29: default => sub { "openid profile email" }, 30: required => 1 31: ); 32: 33: has client_id => ( 34: is => "ro", 35: isa => sub { is_string($_[0]) or die("client_id should be string"); }, 36: required => 1 37: ); 38: 39: has client_secret => ( 40: is => "ro", 41: isa => sub { is_string($_[0]) or die("client_secret should be string"); }, 42: required => 1 43: ); 44: 45: has openid_uri => ( 46: is => "ro", 47: isa => sub { is_string($_[0]) or die("openid_uri should be string"); }, 48: required => 1 49: ); 50: 51: has uid_key => ( 52: is => "ro", 53: isa => sub { is_string($_[0]) or die("uid_key should be string"); }, 54: required => 1 55: ); 56: 57: has authorize_params => ( 58: is => "ro", 59: isa => sub { is_hash_ref($_[0]) or die("authorize_params should be hash reference"); }, 60: lazy => 1, 61: default => sub { +{}; }, 62: required => 1 63: ); 64: 65: has allowed_authorize_params => ( 66: is => "ro", 67: isa => sub { is_array_ref($_[0]) or die("allowed_authorize_params should be array reference"); }, 68: lazy => 1, 69: default => sub { []; }, 70: required => 1 71: ); 72: 73: # internal (non overwritable) moo attributes 74: has json => ( 75: is => "ro", 76: lazy => 1, 77: default => sub { 78: JSON->new->utf8(1); 79: }, 80: init_arg => undef 81: ); 82: 83: has ua => ( 84: is => "ro", 85: lazy => 1, 86: default => sub { 87: LWP::UserAgent->new(); 88: }, 89: init_arg => undef 90: ); 91: 92: has openid_configuration => ( 93: is => "lazy", 94: init_arg => undef 95: ); 96: 97: has jwks => ( 98: is => "lazy", 99: init_arg => undef 100: ); 101: 102: sub get_json { 103: 104: my ($self, $url) = @_; 105: 106: my $res = $self->ua->get($url); 107: 108: if ( $res->code ne "200" ) { 109: 110: $self->log->errorf("url $url returned invalid status code %s", $res->code); 111: return undef, "INVALID_HTTP_STATUS"; 112: 113: } 114: 115: if ( index($res->content_type, "json") < 0 ) { 116: 117: $self->log->errorf("url $url returned invalid content type %s", $res->content_type); 118: return undef, "INVALID_HTTP_CONTENT_TYPE"; 119: 120: } 121: 122: my $data; 123: my $data_error; 124: try { 125: $data = $self->json->decode($res->content); 126: } catch { 127: $data_error = $_; 128: }; 129: 130: if ( defined($data_error) ) { 131: 132: $self->log->error("could not decode json returned from $url"); 133: return undef, "INVALID_HTTP_CONTENT"; 134: 135: } 136: 137: $data; 138: 139: } 140: 141: sub _build_openid_configuration { 142: 143: my $self = $_[0]; 144: 145: my $url = $self->openid_uri; 146: my ($data, @errors) = $self->get_json($url); 147: die("unable to retrieve openid configuration from $url: ".join(", ",@errors)) 148: unless defined($data); 149: $data; 150: 151: } 152: 153: # https://auth0.com/blog/navigating-rs256-and-jwks/ 154: sub _build_jwks { 155: 156: my $self = $_[0]; 157: 158: my $jwks_uri = $self->openid_configuration->{jwks_uri}; 159: 160: die("attribute jwks_uri not found in openid_configuration") 161: unless is_string($jwks_uri); 162: 163: my ($data, @errors) = $self->get_json($jwks_uri); 164: 165: die("unable to retrieve jwks from ".$self->openid_configuration->{jwks_uri}.":".join(", ", @errors)) 166: if scalar(@errors); 167: 168: $data; 169: 170: } 171: 172: sub redirect_uri { 173: 174: my ($self, $request) = @_; 175: my $redirect_uri = $self->uri_base().$request->request_uri(); 176: my $idx = index( $redirect_uri, "?" ); 177: if ( $idx >= 0 ) { 178: 179: $redirect_uri = substr( $redirect_uri, 0, $idx ); 180: 181: } 182: $redirect_uri; 183: 184: } 185: 186: sub make_random_string { 187: 188: MIME::Base64::encode_base64url( 189: Data::UUID->new->create() . 190: Data::UUID->new->create() . 191: Data::UUID->new->create() 192: ); 193: 194: } 195: 196: sub generate_authorization_uri { 197: 198: my ($self, %args) = @_; 199: 200: my $request = $args{request}; 201: my $session = $args{session}; 202: my $query_params = $request->query_parameters(); 203: 204: my $openid_conf = $self->openid_configuration; 205: my $authorization_endpoint = $openid_conf->{authorization_endpoint}; 206: 207: # cf. https://developers.onelogin.com/openid-connect/guides/auth-flow-pkce 208: # Note: minimum of 43 characters! 209: my $code_verifier = $self->make_random_string(); 210: my $code_challenge = MIME::Base64::encode_base64url(Digest::SHA::sha256($code_verifier),""); 211: my $state = $self->make_random_string(); 212: 213: my %query; 214: 215: # merge in allowed params from current url 216: for my $key( @{ $self->allowed_authorize_params } ){ 217: 218: my $val = $query_params->get($key); 219: 220: next unless is_string($val); 221: 222: $query{$key} = $val; 223: 224: } 225: 226: %query = ( 227: %query, 228: %{ $self->authorize_params }, 229: code_challenge => $code_challenge, 230: code_challenge_method => "S256", 231: state => $state, 232: scope => $self->scope(), 233: client_id => $self->client_id, 234: response_type => "code", 235: redirect_uri => $self->redirect_uri($request) 236: ); 237: 238: my $uri = URI->new($authorization_endpoint); 239: $uri->query_form(%query); 240: $self->set_csrf_token($session, $state); 241: $session->set("auth_sso_oidc_code_verifier", $code_verifier); 242: 243: $uri->as_string; 244: 245: } 246: 247: around cleanup => sub { 248: 249: my ($orig, $self, $session) = @_; 250: $self->$orig($session); 251: $session->remove("auth_sso_oidc_code_verifier"); 252: 253: }; 254: 255: # extract_claims_from_id_token(id_token) : claims_as_hash 256: sub extract_claims_from_id_token { 257: 258: my ($self, $id_token) = @_; 259: 260: my ($jose_header, $payload, $s) = split(/\./o, $id_token); 261: 262: # '{ "alg": "RS256", "kid": "my-key-id" }' 263: $jose_header = $self->json->decode(MIME::Base64::decode($jose_header)); 264: 265: #{ "keys": [{ "kid": "my-key-id", "alg": "RS256", "use": "sig" .. }] } 266: my $jwks = $self->jwks(); 267: my ($key) = grep { $_->{kid} eq $jose_header->{kid} } 268: @{ $jwks->{keys} }; 269: 270: my $claims; 271: my $claims_error; 272: try { 273: $claims = Crypt::JWT::decode_jwt(token => $id_token, key => $key); 274: } catch { 275: $claims_error = $_; 276: }; 277: 278: $self->log->errorf("error occurred while decoding JWS: %s", $claims_error) 279: if defined $claims_error; 280: 281: $claims; 282: 283: } 284: 285: sub exchange_code_for_tokens { 286: 287: my ($self, %args) = @_; 288: 289: my $request = $args{request}; 290: my $session = $args{session}; 291: my $code = $args{code}; 292: 293: my $openid_conf = $self->openid_configuration; 294: my $token_endpoint = $openid_conf->{token_endpoint}; 295: my $token_endpoint_auth_methods_supported = $openid_conf->{token_endpoint_auth_methods_supported} // []; 296: $token_endpoint_auth_methods_supported = 297: is_array_ref($token_endpoint_auth_methods_supported) ? 298: $token_endpoint_auth_methods_supported : 299: [$token_endpoint_auth_methods_supported]; 300: 301: my $auth_sso_oidc_code_verifier = $session->get("auth_sso_oidc_code_verifier"); 302: 303: my $params = { 304: grant_type => "authorization_code", 305: client_id => $self->client_id, 306: code => $code, 307: code_verifier => $auth_sso_oidc_code_verifier, 308: redirect_uri => $self->redirect_uri($request), 309: }; 310: 311: my $headers = { 312: "Content-Type" => "application/x-www-form-urlencoded" 313: }; 314: my $client_id = $self->client_id; 315: my $client_secret = $self->client_secret; 316: 317: if ( grep { $_ eq "client_secret_basic" } @$token_endpoint_auth_methods_supported ) { 318: 319: $self->log->info("using client_secret_basic"); 320: $headers->{"Authorization"} = "Basic " . MIME::Base64::encode_base64("$client_id:$client_secret"); 321: 322: } 323: elsif ( grep { $_ eq "client_secret_post" } @$token_endpoint_auth_methods_supported ) { 324: 325: $self->log->info("using client_secret_post"); 326: $params->{client_secret} = $client_secret; 327: 328: } 329: else { 330: 331: die("token_endpoint $token_endpoint does not support client_secret_basic or client_secret_post"); 332: 333: } 334: 335: my $res = $self->ua->post( 336: $token_endpoint, 337: $params, 338: %$headers 339: ); 340: 341: die("$token_endpoint returned invalid content type ".$res->content_type) 342: unless $res->content_type =~ /json/o; 343: 344: $self->json->decode($res->content); 345: } 346: 347: sub to_app { 348: 349: my $self = $_[0]; 350: 351: sub { 352: 353: my $env = $_[0]; 354: my $log = $self->log(); 355: 356: my $request = Plack::Request->new($env); 357: my $session = Plack::Session->new($env); 358: my $query_params = $request->query_parameters(); 359: 360: if( $self->log->is_debug() ){ 361: 362: $self->log->debugf( "incoming query parameters: %s", [$query_params->flatten] ); 363: $self->log->debugf( "session: %s", $session->dump() ); 364: $self->log->debugf( "session_key for auth_sso: %s" . $self->session_key() ); 365: 366: } 367: 368: if ( $request->method ne "GET" ) { 369: 370: $self->log->errorf("invalid http method %s", $request->method); 371: return [400, [ "Content-Type" => "text/plain" ], ["invalid http method"]]; 372: 373: } 374: 375: my $state = $query_params->get("state"); 376: my $stored_state = $self->get_csrf_token($session); 377: 378: # remove auth_sso from possibly previous successfull authentication 379: # (allowing for reauthentication) 380: $session->remove($self->session_key); 381: 382: # redirect to authorization url 383: if ( !(is_string($stored_state) && is_string($state)) ) { 384: 385: $self->cleanup($session); 386: 387: my $authorization_uri = $self->generate_authorization_uri( 388: request => $request, 389: session => $session 390: ); 391: 392: return [302, [Location => $authorization_uri], []]; 393: 394: } 395: 396: # check csrf 397: if ( $stored_state ne $state ) { 398: 399: $self->cleanup($session); 400: $self->set_auth_sso_error( $session,{ 401: package => __PACKAGE__, 402: package_id => $self->id, 403: type => "CSRF_DETECTED", 404: content => "CSRF_DETECTED" 405: }); 406: return $self->redirect_to_error(); 407: 408: } 409: 410: # validate authorization returned from idp 411: my $error = $query_params->get("error"); 412: my $error_description = $query_params->get("error_description"); 413: 414: if ( is_string($error) ) { 415: 416: $self->cleanup($session); 417: $self->set_auth_sso_error($session, { 418: package => __PACKAGE__, 419: package_id => $self->id, 420: type => $error, 421: content => $error_description 422: }); 423: return $self->redirect_to_error(); 424: 425: } 426: 427: my $code = $query_params->get("code"); 428: 429: unless ( is_string($code) ) { 430: 431: $self->cleanup($session); 432: $self->set_auth_sso_error($session, { 433: package => __PACKAGE__, 434: package_id => $self->id, 435: type => "AUTH_SSO_OIDC_AUTHORIZATION_NO_CODE", 436: content => "oidc authorization endpoint did not return query parameter code" 437: }); 438: return $self->redirect_to_error(); 439: 440: } 441: 442: my $tokens = $self->exchange_code_for_tokens( 443: request => $request, 444: session => $session, 445: code => $code 446: ); 447: 448: $self->log->debugf("tokens: %s", $tokens) 449: if $self->log->is_debug(); 450: 451: if ( is_string($tokens->{error}) ) { 452: 453: $self->cleanup($session); 454: $self->set_auth_sso_error($session, { 455: package => __PACKAGE__, 456: package_id => $self->id, 457: type => $tokens->{error}, 458: content => $tokens->{error_description} 459: }); 460: return $self->redirect_to_error(); 461: 462: } 463: 464: my $claims = $self->extract_claims_from_id_token($tokens->{id_token}); 465: 466: $self->log->debugf("claims: %s", $claims) 467: if $self->log->is_debug(); 468: 469: $self->cleanup($session); 470: 471: $self->set_auth_sso( 472: $session, 473: { 474: extra => {}, 475: info => $claims, 476: uid => $claims->{ $self->uid_key() }, 477: package => __PACKAGE__, 478: package_id => $self->id, 479: response => { 480: content => $self->json->encode($tokens), 481: content_type => "application/json" 482: } 483: } 484: ); 485: 486: $self->log->debugf("auth_sso: %s", $self->get_auth_sso($session)) 487: if $self->log->is_debug(); 488: 489: return $self->redirect_to_authorization(); 490: 491: }; 492: 493: } 494: 495: 1; 496: 497: =pod 498: 499: =head1 NAME 500: 501: Plack::Auth::SSO::OIDC - implementation of OpenID Connect for Plack::Auth::SSO 502: 503: =begin markdown 504: 505: # STATUS 506: 507: [![Build Status](https://travis-ci.org/LibreCat/Plack-Auth-SSO-OIDC.svg?branch=main)](https://travis-ci.org/LibreCat/Plack-Auth-SSO-OIDC) 508: [![Coverage](https://coveralls.io/repos/LibreCat/Plack-Auth-SSO-OIDC/badge.png?branch=main)](https://coveralls.io/r/LibreCat/Plack-Auth-SSO-OIDC) 509: [![CPANTS kwalitee](http://cpants.cpanauthors.org/dist/Plack-Auth-SSO-OIDC.png)](http://cpants.cpanauthors.org/dist/Plack-Auth-SSO-OIDC) 510: 511: =end markdown 512: 513: =head1 DESCRIPTION 514: 515: This is an implementation of L<Plack::Auth::SSO> to authenticate against a openid connect server. 516: 517: It inherits all configuration options from its parent. 518: 519: =head1 SYNOPSIS 520: 521: # in your app.psi (Plack) 522: 523: use strict; 524: use warnings; 525: use Plack::Builder; 526: use JSON; 527: use Plack::Auth::SSO::OIDC; 528: use Plack::Session::Store::File; 529: 530: my $uri_base = "http://localhost:5000"; 531: 532: builder { 533: 534: # session middleware needed to store "auth_sso" and/or "auth_sso_error" 535: # in memory session store for testing purposes 536: enable "Session"; 537: 538: # for authentication, redirect your users to this path 539: mount "/auth/oidc" => Plack::Auth::SSO::OIDC->new( 540: 541: # plack application needs to know about the base url of this application 542: uri_base => $uri_base, 543: 544: # after successfull authentication, user is redirected to this path (uri_base is used!) 545: authorization_path => "/auth/callback", 546: 547: # when authentication fails at the identity provider 548: # user is redirected to this path with session key "auth_sso_error" (hash) 549: error_path => "/auth/error", 550: 551: # openid connect discovery url 552: openid_uri => "https://example.oidc.org/auth/oidc/.well-known/openid-configuration", 553: client_id => "my-client-id", 554: client_secret => "myclient-secret", 555: uid_key => "email" 556: 557: )->to_app(); 558: 559: # example psgi app that is called after successfull authentication at /auth/oidc (see above) 560: # it expects session key "auth_sso" to be present 561: # here you typically create a user session based on the uid in "auth_sso" 562: mount "/auth/callback" => sub { 563: 564: my $env = shift; 565: my $session = Plack::Session->new($env); 566: my $auth_sso= $session->get("auth_sso"); 567: my $user = MyUsers->get( $auth_sso->{uid} ); 568: $session->set("user_id", $user->{id}); 569: [ 200, [ "Content-Type" => "text/plain" ], [ 570: "logged in! ", $user->{name} 571: ]]; 572: 573: }; 574: 575: # example psgi app that is called after unsuccessfull authentication at /auth/oidc (see above) 576: # it expects session key "auth_sso_error" to be present 577: mount "/auth/error" => sub { 578: 579: my $env = shift; 580: my $session = Plack::Session->new($env); 581: my $auth_sso_error = $session->get("auth_sso_error"); 582: 583: [ 200, [ "Content-Type" => "text/plain" ], [ 584: "something happened during single sign on authentication: ", 585: $auth_sso_error->{content} 586: ]]; 587: 588: }; 589: }; 590: 591: =head1 CONSTRUCTOR ARGUMENTS 592: 593: =over 4 594: 595: =item C<< uri_base >> 596: 597: See L<Plack::Auth::SSO/uri_base> 598: 599: =item C<< id >> 600: 601: See L<Plack::Auth::SSO/id> 602: 603: =item C<< session_key >> 604: 605: See L<Plack::Auth::SSO/session_key> 606: 607: =item C<< authorization_path >> 608: 609: See L<Plack::Auth::SSO/authorization_path> 610: 611: =item C<< error_path >> 612: 613: See L<Plack::Auth::SSO/error_path> 614: 615: =item C<< openid_uri >> 616: 617: base url of the OIDC discovery url. 618: 619: typically an url that ends on C<< /.well-known/openid-configuration >> 620: 621: =item C<< client_id >> 622: 623: client-id as given by the OIDC service 624: 625: =item C<< client_secret >> 626: 627: client-secret as given by the OIDC service 628: 629: =item C<< scope >> 630: 631: Scope requested from the OIDC service. 632: 633: Space separated string containing all scopes 634: 635: Default: C<< "openid profile email" >> 636: 637: Please include scope C<< "openid" >> 638: 639: cf. L<https://openid.net/specs/openid-connect-basic-1_0.html#Scopes> 640: 641: =item C<< authorize_params >> 642: 643: Hash reference of parameters (values must be strings) that are added to 644: 645: the authorization url. Empty by default 646: 647: e.g. C<< { prompt => "login", "kc_idp_hint" => "orcid" } >> 648: 649: Note that some parameters are set internally 650: 651: and therefore will have no effect: 652: 653: =over 6 654: 655: =item C<< code_challenge >> 656: 657: =item C<< code_challenge_method >> 658: 659: =item C<< state >> 660: 661: =item C<< scope >> 662: 663: =item C<< client_id >> 664: 665: =item C<< response_type >> 666: 667: =item C<< redirect_uri >> 668: 669: =back 670: 671: =item C<< allowed_authorize_params >> 672: 673: Array reference of parameter names. 674: 675: When constructing the authorization url, 676: 677: these parameters are copied from the current url query 678: 679: to the authorization url. This allows to add some 680: 681: dynamic configuration, but should be used with caution. 682: 683: Note that parameters from C<< authorize_params >> always 684: 685: take precedence. 686: 687: =item C<< uid_key >> 688: 689: Attribute from claims to be used as uid 690: 691: Note that all claims are also stored in C<< $session->get("auth_sso")->{info} >> 692: 693: =back 694: 695: =head1 HOW IT WORKS 696: 697: =over 4 698: 699: =item the openid configuration is retrieved from C<< {openid_uri} >> 700: 701: =over 6 702: 703: =item key C<< authorization_endpoint >> must be present in openid configuration 704: 705: =item key C<< token_endpoint >> must be present in openid configuration 706: 707: =item key C<< jwks_uri >> must be present in openid configuration 708: 709: =item the user is redirected to the authorization endpoint with extra query parameters 710: 711: =back 712: 713: =item after authentication at the authorization endpoint, the user is redirected back to this url with query parameters C<< code >> and C<< state >>. When something happened at the authorization endpoint, query parameters C<< error >> and C<< error_description >> are returned, and no C<< code >>. 714: 715: =item C<< code >> is exchanged for a json string, using the token endpoint. This json string is a record that contains attributes like C<< id_token >> and C<< access_token >>. See L<https://openid.net/specs/openid-connect-core-1_0.html#TokenResponse> for more information. 716: 717: =item key C<< id_token >> in the token json string contains three parts: 718: 719: =over 6 720: 721: =item jwt jose header. Can be decoded with base64 into a json string 722: 723: =item jwt payload. Can be decoded with base64 into a json string 724: 725: =item jwt signature 726: 727: =back 728: 729: =item the jwt payload from the C<< id_token >> is decoded into a json string and then to a perl hash. All this data is stored C<< $session->{auth_sso}->{info} >>. One of these attributes will be the uid that will be stored at C<< $session->{auth_sso}->{uid} >>. This is determined by configuration key C<< uid_key >> (see above). e.g. "email" 730: 731: =back 732: 733: =head1 NOTES 734: 735: =over 4 736: 737: =item Can I reauthenticate when I visit the application? 738: 739: When this Plack application is for example mounted at 740: 741: C<< /auth/oidc >>, then you can reauthenticate by visiting 742: 743: it again, but it depends on your configuration what actually 744: 745: happens at the openid connect server. If C<< prompt >> is not 746: 747: set anywhere (neither in C<< authorize_params >> nor in the 748: 749: current url if that is allowed), then the external server 750: 751: will just sent you back with the same tokens. 752: 753: Note that C<< session("auth_sso") >> is removed at the start 754: 755: of every (re)authentication. 756: 757: =back 758: 759: =head1 LOGGING 760: 761: All subclasses of L<Plack::Auth::SSO> use L<Log::Any> 762: to log messages to the category that equals the current 763: package name. 764: 765: =head1 AUTHOR 766: 767: Nicolas Franck, C<< <nicolas.franck at ugent.be> >> 768: 769: =head1 LICENSE AND COPYRIGHT 770: 771: This program is free software; you can redistribute it and/or modify it 772: under the terms of either: the GNU General Public License as published 773: by the Free Software Foundation; or the Artistic License. 774: 775: See L<http://dev.perl.org/licenses/> for more information. 776: 777: =head1 SEE ALSO 778: 779: L<Plack::Auth::SSO> 780: 781: =cut 782: |