blib/lib/Plack/Auth/SSO/OIDC.pm | |||
---|---|---|---|
Criterion | Covered | Total | % |
statement | 45 | 176 | 25.5 |
branch | 0 | 44 | 0.0 |
condition | 0 | 5 | 0.0 |
subroutine | 15 | 29 | 51.7 |
pod | 0 | 7 | 0.0 |
total | 60 | 261 | 22.9 |
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.01"; 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_base64url("$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(); 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(); 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(); 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: my $claims = $self->extract_claims_from_id_token($tokens->{id_token}); 425: 426: $self->log->debugf("claims: %s", $claims) 427: if $self->log->is_debug(); 428: 429: $self->cleanup($session); 430: 431: $self->set_auth_sso( 432: $session, 433: { 434: extra => {}, 435: info => $claims, 436: uid => $claims->{ $self->uid_key() }, 437: package => __PACKAGE__, 438: package_id => $self->id, 439: response => { 440: content => $self->json->encode($tokens), 441: content_type => "application/json" 442: } 443: } 444: ); 445: 446: $self->log->debugf("auth_sso: %s", $self->get_auth_sso($session)) 447: if $self->log->is_debug(); 448: 449: return $self->redirect_to_authorization(); 450: 451: }; 452: 453: } 454: 455: 1; 456: 457: =pod 458: 459: =head1 NAME 460: 461: Plack::Auth::SSO::OIDC - implementation of OpenID Connect for Plack::Auth::SSO 462: 463: =begin markdown 464: 465: # STATUS 466: 467: [](https://travis-ci.org/LibreCat/Plack-Auth-SSO-OIDC) 468: [](https://coveralls.io/r/LibreCat/Plack-Auth-SSO-OIDC) 469: [](http://cpants.cpanauthors.org/dist/Plack-Auth-SSO-OIDC) 470: 471: =end markdown 472: 473: =head1 DESCRIPTION 474: 475: This is an implementation of L<Plack::Auth::SSO> to authenticate against a openid connect server. 476: 477: It inherits all configuration options from its parent. 478: 479: =head1 SYNOPSIS 480: 481: # in your app.psi (Plack) 482: 483: use strict; 484: use warnings; 485: use Plack::Builder; 486: use JSON; 487: use Plack::Auth::SSO::OIDC; 488: use Plack::Session::Store::File; 489: 490: my $uri_base = "http://localhost:5000"; 491: 492: builder { 493: 494: # session middleware needed to store "auth_sso" and/or "auth_sso_error" 495: # in memory session store for testing purposes 496: enable "Session"; 497: 498: # for authentication, redirect your users to this path 499: mount "/auth/oidc" => Plack::Auth::SSO::OIDC->new( 500: 501: # plack application needs to know about the base url of this application 502: uri_base => $uri_base, 503: 504: # after successfull authentication, user is redirected to this path (uri_base is used!) 505: authorization_path => "/auth/callback", 506: 507: # when authentication fails at the identity provider 508: # user is redirected to this path with session key "auth_sso_error" (hash) 509: error_path => "/auth/error", 510: 511: # openid connect discovery url 512: openid_uri => "https://example.oidc.org/auth/oidc/.well-known/openid-configuration", 513: client_id => "my-client-id", 514: client_secret => "myclient-secret", 515: uid_key => "email" 516: 517: )->to_app(); 518: 519: # example psgi app that is called after successfull authentication at /auth/oidc (see above) 520: # it expects session key "auth_sso" to be present 521: # here you typically create a user session based on the uid in "auth_sso" 522: mount "/auth/callback" => sub { 523: 524: my $env = shift; 525: my $session = Plack::Session->new($env); 526: my $auth_sso= $session->get("auth_sso"); 527: my $user = MyUsers->get( $auth_sso->{uid} ); 528: $session->set("user_id", $user->{id}); 529: [ 200, [ "Content-Type" => "text/plain" ], [ 530: "logged in! ", $user->{name} 531: ]]; 532: 533: }; 534: 535: # example psgi app that is called after unsuccessfull authentication at /auth/oidc (see above) 536: # it expects session key "auth_sso_error" to be present 537: mount "/auth/error" => sub { 538: 539: my $env = shift; 540: my $session = Plack::Session->new($env); 541: my $auth_sso_error = $session->get("auth_sso_error"); 542: 543: [ 200, [ "Content-Type" => "text/plain" ], [ 544: "something happened during single sign on authentication: ", 545: $auth_sso_error->{content} 546: ]]; 547: 548: }; 549: }; 550: 551: =head1 CONSTRUCTOR ARGUMENTS 552: 553: =over 4 554: 555: =item C<< uri_base >> 556: 557: See L<Plack::Auth::SSO/uri_base> 558: 559: =item C<< id >> 560: 561: See L<Plack::Auth::SSO/id> 562: 563: =item C<< session_key >> 564: 565: See L<Plack::Auth::SSO/session_key> 566: 567: =item C<< authorization_path >> 568: 569: See L<Plack::Auth::SSO/authorization_path> 570: 571: =item C<< error_path >> 572: 573: See L<Plack::Auth::SSO/error_path> 574: 575: =item C<< openid_uri >> 576: 577: base url of the OIDC discovery url. 578: 579: typically an url that ends on C<< /.well-known/openid-configuration >> 580: 581: =item C<< client_id >> 582: 583: client-id as given by the OIDC service 584: 585: =item C<< client_secret >> 586: 587: client-secret as given by the OIDC service 588: 589: =item C<< scope >> 590: 591: Scope requested from the OIDC service. 592: 593: Space separated string containing all scopes 594: 595: Default: C<< "openid profile email" >> 596: 597: Please include scope C<< "openid" >> 598: 599: cf. L<https://openid.net/specs/openid-connect-basic-1_0.html#Scopes> 600: 601: =item C<< uid_key >> 602: 603: Attribute from claims to be used as uid 604: 605: Note that all claims are also stored in C<< $session->get("auth_sso")->{info} >> 606: 607: =back 608: 609: =head1 HOW IT WORKS 610: 611: =over 4 612: 613: =item the openid configuration is retrieved from C<< {openid_uri} >> 614: 615: =over 6 616: 617: =item key C<< authorization_endpoint >> must be present in openid configuration 618: 619: =item key C<< token_endpoint >> must be present in openid configuration 620: 621: =item key C<< jwks_uri >> must be present in openid configuration 622: 623: =item the user is redirected to the authorization endpoint with extra query parameters 624: 625: =back 626: 627: =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 >>. 628: 629: =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. 630: 631: =item key C<< id_token >> in the token json string contains three parts: 632: 633: =over 6 634: 635: =item jwt jose header. Can be decoded with base64 into a json string 636: 637: =item jwt payload. Can be decoded with base64 into a json string 638: 639: =item jwt signature 640: 641: =back 642: 643: =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" 644: 645: =back 646: 647: =head1 LOGGING 648: 649: All subclasses of L<Plack::Auth::SSO> use L<Log::Any> 650: to log messages to the category that equals the current 651: package name. 652: 653: =head1 AUTHOR 654: 655: Nicolas Franck, C<< <nicolas.franck at ugent.be> >> 656: 657: =head1 LICENSE AND COPYRIGHT 658: 659: This program is free software; you can redistribute it and/or modify it 660: under the terms of either: the GNU General Public License as published 661: by the Free Software Foundation; or the Artistic License. 662: 663: See L<http://dev.perl.org/licenses/> for more information. 664: 665: =head1 SEE ALSO 666: 667: L<Plack::Auth::SSO> 668: 669: =cut 670: |