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