LP#1842297: Implements patron sign-on to the OpenAthens service.
authorJulian Clementson <51331324+oajulianclementson@users.noreply.github.com>
Tue, 1 Oct 2019 15:17:53 +0000 (16:17 +0100)
committerJane Sandberg <sandbej@linnbenton.edu>
Wed, 24 Jun 2020 03:53:33 +0000 (20:53 -0700)
Allows global or local administrators to configure a connection to the
OpenAthens cloud-based single sign-on service. Allows patrons to connect
seamlessly to third party resources that use OpenAthens authentication.
For more detailed feature description visit
https://wiki.evergreen-ils.org/doku.php?id=dev%3Aproposal%3Aopenathens_integration

Signed-off-by: Julian Clementson <julian.clementson@openathens.net>
Signed-off-by: Chris Sharp <csharp@georgialibraries.org>
17 files changed:
Open-ILS/examples/fm_IDL.xml
Open-ILS/src/eg2/src/app/staff/admin/local/admin-local-splash.component.html
Open-ILS/src/eg2/src/app/staff/admin/local/admin-local.module.ts
Open-ILS/src/eg2/src/app/staff/admin/local/openathens-identity.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/local/openathens-identity.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/admin/local/routing.module.ts
Open-ILS/src/extras/install/Makefile.debian-buster
Open-ILS/src/extras/install/Makefile.debian-jessie
Open-ILS/src/extras/install/Makefile.debian-stretch
Open-ILS/src/extras/install/Makefile.fedora
Open-ILS/src/extras/install/Makefile.ubuntu-bionic
Open-ILS/src/extras/install/Makefile.ubuntu-xenial
Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader.pm
Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/OpenAthens.pm [new file with mode: 0644]
Open-ILS/src/sql/Pg/upgrade/XXXX.schema.openathens_identity.sql [new file with mode: 0644]
docs/RELEASE_NOTES_NEXT/Administration/OpenAthens_SignOn.adoc [new file with mode: 0644]
docs/installation/system_requirements.adoc

index 560d372..3d4ecc5 100644 (file)
@@ -13139,6 +13139,77 @@ SELECT  usr,
                </permacrud>
        </class>
 
+       <class id="coauf" 
+               controller="open-ils.cstore open-ils.pcrud"
+               oils_obj:fieldmapper="config::openathens_uid_field" 
+               oils_persist:tablename="config.openathens_uid_field" 
+               reporter:label="OpenAthens unique identifiers">
+               <fields oils_persist:primary="id" oils_persist:sequence="config.openathens_uid_field_id_seq">
+                       <field reporter:label="ID" name="id" reporter:selector="name" reporter:datatype="id" />
+                       <field reporter:label="Name" name="name" reporter:datatype="text" oils_obj:required="true"/>
+               </fields>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <retrieve/>
+                       </actions>
+               </permacrud>
+       </class>
+
+       <class id="coanf" 
+               controller="open-ils.cstore open-ils.pcrud"
+               oils_obj:fieldmapper="config::openathens_name_field" 
+               oils_persist:tablename="config.openathens_name_field" 
+               reporter:label="OpenAthens name fields">
+               <fields oils_persist:primary="id" oils_persist:sequence="config.openathens_name_field_id_seq">
+                       <field reporter:label="ID" name="id" reporter:selector="name" reporter:datatype="id" />
+                       <field reporter:label="Name" name="name" reporter:datatype="text" oils_obj:required="true"/>
+               </fields>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <retrieve/>
+                       </actions>
+               </permacrud>
+       </class>
+
+       <class id="coai" 
+               controller="open-ils.cstore open-ils.pcrud"
+               oils_obj:fieldmapper="config::openathens_identity" 
+               oils_persist:tablename="config.openathens_identity" 
+               reporter:label="Sign-on to OpenAthens">
+               <fields oils_persist:primary="id" oils_persist:sequence="config.openathens_identity_id_seq">
+                       <field reporter:label="ID" name="id" reporter:datatype="id" reporter:selector="name"/>
+                       <field reporter:label="Owner" name="org_unit" reporter:datatype="org_unit" oils_obj:required="true"/>
+                       <field reporter:label="Active" name="active" reporter:datatype="bool"/>
+                       <field reporter:label="API key" name="api_key" reporter:datatype="text" oils_obj:required="true"/>
+                       <field reporter:label="Connection ID" name="connection_id" reporter:datatype="text" oils_obj:required="true"/>
+                       <field reporter:label="Connection URI" name="connection_uri" reporter:datatype="text" oils_obj:required="true"/>
+                       <field reporter:label="Auto sign-on" name="auto_signon_enabled" reporter:datatype="bool"/>
+                       <field reporter:label="Auto sign-out" name="auto_signout_enabled" reporter:datatype="bool"/>
+                       <field reporter:label="Unique identifier field" name="unique_identifier" reporter:datatype="link" oils_obj:required="true"/>
+                       <field reporter:label="Display name field" name="display_name" reporter:datatype="link" oils_obj:required="true"/>
+                       <field reporter:label="Release prefix" name="release_prefix" reporter:datatype="bool"/>
+                       <field reporter:label="Release first name" name="release_first_given_name" reporter:datatype="bool"/>
+                       <field reporter:label="Release middle name" name="release_second_given_name" reporter:datatype="bool"/>
+                       <field reporter:label="Release surname" name="release_family_name" reporter:datatype="bool"/>
+                       <field reporter:label="Release suffix" name="release_suffix" reporter:datatype="bool"/>
+                       <field reporter:label="Release email" name="release_email" reporter:datatype="bool"/>
+                       <field reporter:label="Release home library" name="release_home_ou" reporter:datatype="bool"/>
+               </fields>
+               <links>
+                       <link field="org_unit" reltype="has_a" key="id" map="" class="aou"/>
+                       <link field="unique_identifier" reltype="has_a" key="id" map="" class="coauf"/>
+                       <link field="display_name" reltype="has_a" key="id" map="" class="coanf"/>
+               </links>
+               <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+                       <actions>
+                               <create permission="ADMIN_OPENATHENS" global_required="true"/>
+                               <retrieve/>
+                               <update permission="ADMIN_OPENATHENS" global_required="true"/>
+                               <delete permission="ADMIN_OPENATHENS" global_required="true"/>
+                       </actions>
+               </permacrud>
+       </class>
+
        <!-- ********************************************************************************************************************* -->
 </IDL>
 
index 223d181..a30a925 100644 (file)
@@ -44,6 +44,8 @@
       routerLink="/staff/admin/local/config/non_cataloged_type"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Notifications / Action Triggers" 
       url="/eg/staff/admin/local/action_trigger/event_definition"></eg-link-table-link>
+    <eg-link-table-link i18n-label label="OpenAthens Sign-on"
+      routerLink="/staff/admin/local/config/openathens_identity"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Patrons with Negative Balances" 
       url="/eg/staff/admin/local/circ/neg_balance_users"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Permission Tree Display Entries" 
index 9f70ab7..e76fdf5 100644 (file)
@@ -6,6 +6,7 @@ import {AdminCommonModule} from '@eg/staff/admin/common.module';
 import {AdminLocalSplashComponent} from './admin-local-splash.component';
 import {AddressAlertComponent} from './address-alert.component';
 import {AdminCarouselComponent} from './admin-carousel.component';
+import {OpenAthensIdentityComponent} from './openathens-identity.component';
 import {StandingPenaltyComponent} from './standing-penalty.component';
 
 @NgModule({
@@ -13,6 +14,7 @@ import {StandingPenaltyComponent} from './standing-penalty.component';
       AdminLocalSplashComponent,
       AddressAlertComponent,
       AdminCarouselComponent,
+      OpenAthensIdentityComponent,
       StandingPenaltyComponent
   ],
   imports: [
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/local/openathens-identity.component.html b/Open-ILS/src/eg2/src/app/staff/admin/local/openathens-identity.component.html
new file mode 100644 (file)
index 0000000..d0fb001
--- /dev/null
@@ -0,0 +1,61 @@
+<ng-template #successStrTmpl i18n>{{idlClassDef.label}} Update Succeeded</ng-template>
+<eg-string #successString [template]="successStrTmpl"></eg-string>
+
+<ng-template #updateFailedStrTmpl i18n>Update of {{idlClassDef.label}} failed</ng-template>
+<eg-string #updateFailedString [template]="updateFailedStrTmpl"></eg-string>
+
+<ng-template #deleteFailedStrTmpl i18n>Delete of {{idlClassDef.label}} failed or was not allowed</ng-template>
+<eg-string #deleteFailedString [template]="deleteFailedStrTmpl"></eg-string>
+
+<ng-template #deleteSuccessStrTmpl i18n>{{idlClassDef.label}} Successfully Deleted</ng-template>
+<eg-string #deleteSuccessString [template]="deleteSuccessStrTmpl"></eg-string>
+
+<ng-template #createStrTmpl i18n>{{idlClassDef.label}} Succeessfully Created</ng-template>
+<eg-string #createString [template]="createStrTmpl"></eg-string>
+
+<ng-template #createErrStrTmpl i18n>Failed to create new {{idlClassDef.label}}</ng-template>
+<eg-string #createErrString [template]="createErrStrTmpl"></eg-string>
+
+<eg-title i18n-prefix prefix="{{classLabel}} Administration">
+</eg-title>
+<eg-staff-banner bannerText="{{classLabel}} Configuration" i18n-bannerText>
+</eg-staff-banner>
+
+<ng-container *ngIf="orgField">
+  <eg-org-family-select
+    [limitPerms]="viewPerms"
+    [selectedOrgId]="contextOrg.id()"
+    [(ngModel)]="searchOrgs"
+    (ngModelChange)="grid.reload()">
+  </eg-org-family-select>
+  <hr/>
+</ng-container>
+
+<eg-grid #grid idlClass="{{idlClass}}" [dataSource]="dataSource" 
+    [sortable]="true" persistKey="{{persistKey}}" [showLinkSelectors]="true">
+  <eg-grid-toolbar-button [disabled]="!canCreate" 
+    label="New {{idlClassDef.label}}" i18n-label [action]="createNew">
+  </eg-grid-toolbar-button>
+  <eg-grid-toolbar-button [disabled]="translatableFields.length == 0" 
+    label="Apply Translations" i18n-label [action]="translate">
+  </eg-grid-toolbar-button>
+  <eg-grid-toolbar-action label="Edit Selected" i18n-label [action]="editSelected">
+  </eg-grid-toolbar-action>
+  <eg-grid-toolbar-action label="Delete Selected" i18n-label [action]="deleteSelected">
+  </eg-grid-toolbar-action>
+</eg-grid>
+
+<ng-template #orgTemplate
+    let-field="field" let-record="record">
+  <eg-multi-select idlClass="aou"
+    [startValue]="record['owning_lib_filter']()"
+    (onChange)="record['owning_lib_filter']($event)">
+  </eg-multi-select>
+</ng-template>
+
+<eg-fm-record-editor #editDialog
+    idlClass="{{idlClass}}" 
+    [preloadLinkedValues]="true"
+    [fieldOptions]="{owning_lib_filter:{customTemplate:{template:orgTemplate}}}"
+    fieldOrder="id,org_unit,active,api_key,connection_id,connection_uri,auto_signon_enabled,auto_signout_enabled,unique_identifier,display_name,release_prefix,release_first_given_name,release_second_given_name,release_family_name,release_suffix,release_email,release_home_ou"
+></eg-fm-record-editor>
diff --git a/Open-ILS/src/eg2/src/app/staff/admin/local/openathens-identity.component.ts b/Open-ILS/src/eg2/src/app/staff/admin/local/openathens-identity.component.ts
new file mode 100644 (file)
index 0000000..8ce84c0
--- /dev/null
@@ -0,0 +1,44 @@
+import {Component, OnInit} from '@angular/core';
+import {AdminPageComponent} from '@eg/staff/share/admin-page/admin-page.component';
+
+@Component({
+    templateUrl: './openathens-identity.component.html'
+})
+export class OpenAthensIdentityComponent extends AdminPageComponent implements OnInit {
+
+    idlClass = 'coai';
+    classLabel: string;
+
+    ngOnInit() {
+        super.ngOnInit();
+
+        this.classLabel = this.idlClassDef.label;
+        this.includeOrgDescendants = true;
+    }
+
+    createNew = () => {
+        this.editDialog.recordId = null;
+        this.editDialog.record = null;
+
+        const rec = this.idl.create('coai');
+        rec.active(true);
+        rec.auto_signon_enabled(true);
+        rec.unique_identifier(1);
+        rec.display_name(1);
+        this.editDialog.record = rec;
+
+        this.editDialog.open({size: this.dialogSize}).subscribe(
+            ok => {
+                this.createString.current()
+                    .then(str => this.toast.success(str));
+                this.grid.reload();
+            },
+            rejection => {
+                if (!rejection.dismissed) {
+                    this.createErrString.current()
+                        .then(str => this.toast.danger(str));
+                }
+            }
+        );
+    };
+}
\ No newline at end of file
index 15a9153..9b23194 100644 (file)
@@ -4,6 +4,7 @@ import {AdminLocalSplashComponent} from './admin-local-splash.component';
 import {BasicAdminPageComponent} from '@eg/staff/admin/basic-admin-page.component';
 import {AddressAlertComponent} from './address-alert.component';
 import {AdminCarouselComponent} from './admin-carousel.component';
+import {OpenAthensIdentityComponent} from './openathens-identity.component';
 import {StandingPenaltyComponent} from './standing-penalty.component';
 
 const routes: Routes = [{
@@ -20,6 +21,9 @@ const routes: Routes = [{
     path: 'container/carousel',
     component: AdminCarouselComponent
 }, {
+    path: 'config/openathens_identity',
+    component: OpenAthensIdentityComponent
+}, {
     path: 'config/standing_penalty',
     component: StandingPenaltyComponent
 }, {
index 4723734..7679630 100644 (file)
@@ -41,10 +41,12 @@ export DEBS = \
        libemail-mime-perl\
        libexcel-writer-xlsx-perl\
        libgd-graph3d-perl\
+       libhttp-async-perl\
        liblocale-maketext-lexicon-perl\
        liblog-log4perl-perl\
        libmarc-charset-perl \
        libncurses5-dev\
+       libnet-https-nb-perl\
        libnet-ip-perl\
        libnet-ldap-perl \
        libnet-server-perl\
index d30fa4d..257e75a 100644 (file)
@@ -41,10 +41,12 @@ export DEBS = \
        libemail-mime-perl\
        libexcel-writer-xlsx-perl\
        libgd-graph3d-perl\
+       libhttp-async-perl\
        liblocale-maketext-lexicon-perl\
        liblog-log4perl-perl\
        libmarc-charset-perl \
        libncurses5-dev\
+       libnet-https-nb-perl\
        libnet-ip-perl\
        libnet-ldap-perl \
        libnet-server-perl\
index d16e1a5..1f9d0b5 100644 (file)
@@ -41,10 +41,12 @@ export DEBS = \
        libemail-mime-perl\
        libexcel-writer-xlsx-perl\
        libgd-graph3d-perl\
+       libhttp-async-perl\
        liblocale-maketext-lexicon-perl\
        liblog-log4perl-perl\
        libmarc-charset-perl \
        libncurses5-dev\
+       libnet-https-nb-perl\
        libnet-ip-perl\
        libnet-ldap-perl \
        libnet-server-perl\
index e858f98..4e14e94 100644 (file)
@@ -46,12 +46,14 @@ FEDORA_RPMS = \
        perl-Email-Simple \
        perl-Email-MIME \
        perl-GDGraph3d \
+       perl-HTTP-Async \
        perl-JSON-XS \
        perl-LDAP \
        perl-Locale-Codes \
        perl-Locale-Maketext-Lexicon \
        perl-MARC-Charset \
        perl-Module-Pluggable \
+       perl-Net-HTTPS-NB \
        perl-Net-IP \
        perl-Net-SSH2 \
        perl-OLE-Storage_Lite \
index f1478a3..06087a3 100644 (file)
@@ -40,9 +40,11 @@ export DEBS = \
        libemail-mime-perl\
        libexcel-writer-xlsx-perl\
        libgd-graph3d-perl\
+       libhttp-async-perl\
        liblocale-maketext-lexicon-perl\
        liblog-log4perl-perl\
        libncurses5-dev\
+       libnet-https-nb-perl\
        libnet-ip-perl\
        libnet-ldap-perl \
        libnet-server-perl\
index e8251b7..dbdfed4 100644 (file)
@@ -41,10 +41,12 @@ export DEBS = \
        libemail-mime-perl\
        libexcel-writer-xlsx-perl\
        libgd-graph3d-perl\
+       libhttp-async-perl\
        liblocale-maketext-lexicon-perl\
        liblog-log4perl-perl\
        libmarc-charset-perl \
        libncurses5-dev\
+       libnet-https-nb-perl\
        libnet-ip-perl\
        libnet-ldap-perl \
        libnet-server-perl\
index 7388d79..492454d 100644 (file)
@@ -26,6 +26,7 @@ use OpenILS::WWW::EGCatLoader::Record;
 use OpenILS::WWW::EGCatLoader::Container;
 use OpenILS::WWW::EGCatLoader::SMS;
 use OpenILS::WWW::EGCatLoader::Register;
+use OpenILS::WWW::EGCatLoader::OpenAthens;
 
 my $U = 'OpenILS::Application::AppUtils';
 
@@ -166,6 +167,7 @@ sub load {
     return $self->load_password_reset if $path =~ m|opac/password_reset|;
     return $self->load_logout if $path =~ m|opac/logout|;
     return $self->load_patron_reg if $path =~ m|opac/register|;
+    return $self->load_openathens_logout if $path =~ m|opac/sso/openathens/logout$|;
 
     $self->load_simple("myopac") if $path =~ m:opac/myopac:; # A default page for myopac parts
 
@@ -232,6 +234,7 @@ sub load {
     return $self->load_myopac_prefs if $path =~ m|opac/myopac/prefs|;
     return $self->load_myopac_reservations if $path =~ m|opac/myopac/reservations|;
     return $self->load_sms_cn if $path =~ m|opac/sms_cn|;
+    return $self->load_openathens_sso if $path =~ m|opac/sso/openathens$|;
 
     return Apache2::Const::OK;
 }
@@ -595,10 +598,14 @@ sub load_login {
         );
     }
 
-    return $self->generic_redirect(
-        $cgi->param('redirect_to') || $acct,
-        $cookie_list
-    );
+    my $redirect_to = $cgi->param('redirect_to') || $acct;
+
+    return
+        $self->_perform_any_sso_required($response, $redirect_to, $cookie_list)
+        || $self->generic_redirect(
+            $redirect_to,
+            $cookie_list
+        );
 }
 
 # -----------------------------------------------------------------------------
@@ -606,7 +613,8 @@ sub load_login {
 # -----------------------------------------------------------------------------
 sub load_logout {
     my $self = shift;
-    my $redirect_to = shift || $self->cgi->param('redirect_to');
+    my $redirect_to = shift || $self->cgi->param('redirect_to')
+        || $self->ctx->{home_page};
 
     # If the user was adding anyting to an anonymous cache 
     # while logged in, go ahead and clear it out.
@@ -620,23 +628,54 @@ sub load_logout {
         );
     } catch Error with {};
 
-    return $self->generic_redirect(
-        $redirect_to || $self->ctx->{home_page},
-        [
-            # clear value of and expire both of these login-related cookies
-            $self->cgi->cookie(
-                -name => COOKIE_SES,
-                -path => '/',
-                -value => '',
-                -expires => '-1h'
-            ),
-            $self->cgi->cookie(
-                -name => COOKIE_LOGGEDIN,
-                -path => '/',
-                -value => '',
-                -expires => '-1h'
-            )
-        ]
+    # clear value of and expire both of these login-related cookies
+    my $cookie_list = [
+        $self->cgi->cookie(
+            -name => COOKIE_SES,
+            -path => '/',
+            -value => '',
+            -expires => '-1h'
+        ),
+        $self->cgi->cookie(
+            -name => COOKIE_LOGGEDIN,
+            -path => '/',
+            -value => '',
+            -expires => '-1h'
+        )
+    ];
+
+    return 
+        $self->_perform_any_sso_signout_required($redirect_to, $cookie_list)
+        || $self->generic_redirect(
+            $redirect_to,
+            $cookie_list
+        );
+}
+
+# -----------------------------------------------------------------------------
+# Signs the user in to any third party services that their org unit is
+# configured for.
+# -----------------------------------------------------------------------------
+sub _perform_any_sso_required {
+    my ($self, $auth_response, $redirect_to, $cookie_list) = @_;
+
+    return $self->perform_openathens_sso_if_required(
+        $auth_response,
+        $redirect_to,
+        $cookie_list
+    );
+}
+
+# -----------------------------------------------------------------------------
+# Signs the user out of any third party services that their org unit is
+# configured for.
+# -----------------------------------------------------------------------------
+sub _perform_any_sso_signout_required {
+    my ($self, $redirect_to, $cookie_list) = @_;
+
+    return $self->perform_openathens_signout_if_required(
+        $redirect_to,
+        $cookie_list
     );
 }
 
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/OpenAthens.pm b/Open-ILS/src/perlmods/lib/OpenILS/WWW/EGCatLoader/OpenAthens.pm
new file mode 100644 (file)
index 0000000..8e338e7
--- /dev/null
@@ -0,0 +1,365 @@
+package OpenILS::WWW::EGCatLoader;
+
+use strict; use warnings;
+use Apache2::Const -compile => qw(HTTP_BAD_REQUEST);
+use HTTP::Async;
+use HTTP::Request;
+use XML::Simple;
+
+my $U = 'OpenILS::Application::AppUtils';
+
+use constant OA_API_AUTH_TYPE => 'OAApiKey';
+use constant OA_API_WAIT_SECONDS => 2;
+use constant OA_ATTR_PREFIX => 'prefix';
+use constant OA_ATTR_FIRST_GIVEN_NAME => 'first_given_name';
+use constant OA_ATTR_SECOND_GIVEN_NAME => 'second_given_name';
+use constant OA_ATTR_FAMILY_NAME => 'family_name';
+use constant OA_ATTR_SUFFIX => 'suffix';
+use constant OA_ATTR_EMAIL => 'email';
+use constant OA_ATTR_HOME_OU => 'home_ou';
+use constant OA_SIGNOUT_URL => 'https://login.openathens.net/signout';
+use constant OA_SESSION_REQUEST_TYPE =>
+    'application/vnd.eduserv.iam.auth.localAccountSessionRequest+json';
+
+my @oa_config_fields = qw/active api_key connection_id connection_uri
+    auto_signon_enabled auto_signout_enabled release_prefix
+    release_first_given_name release_second_given_name release_family_name
+    release_suffix release_email release_home_ou/;
+
+
+# -----------------------------------------------------------------------------
+# If sign in to OpenAthens is enabled, redirects to the local OpenAthens
+# sign-in handler, including the original redirect as a parameter.
+# -----------------------------------------------------------------------------
+sub perform_openathens_sso_if_required {
+    my ($self, $auth_response, $redirect_to, $cookie_list) = @_;
+    my $ctx = $self->ctx;
+    my $e = $self->editor;
+
+    # Don't generate a new redirect to the OpenAthens handler if that's where
+    # we came from.
+    if (index(
+        $redirect_to,
+        $ctx->{opac_root} . '/sso/openathens'
+    ) == 0) {
+        return;
+    }
+
+    # Use the auth_token to establish the context user and load the relevant
+    # OpenAthens config. This is needed because the OpenAthens behaviour
+    # depends on the org unit, but the user context has not yet been loaded.
+    if ($e->authtoken($auth_response->{payload}->{authtoken})
+        && $e->checkauth
+    ) {
+        $ctx->{user} = $e->requestor;
+    }
+
+    return unless $ctx->{user};
+
+    my $openathens_config =
+        $self->_get_openathens_config_for_org($ctx->{user}->home_ou);
+
+    if ($openathens_config
+        && $U->is_true($openathens_config->{active})
+        && $U->is_true($openathens_config->{auto_signon_enabled})
+    ) {
+        my $redirect = $ctx->{opac_root} . '/sso/openathens?redirect_to='
+            . uri_escape_utf8($redirect_to);
+
+        if ($redirect) {
+            return $self->generic_redirect($redirect, $cookie_list);
+        }
+    }
+}
+
+# -----------------------------------------------------------------------------
+# If sign out of OpenAthens is enabled, redirects to the local OpenAthens
+# sign-out handler, including the original redirect as a parameter.
+# -----------------------------------------------------------------------------
+sub perform_openathens_signout_if_required {
+    my ($self, $redirect_to, $cookie_list) = @_;
+    my $ctx = $self->ctx;
+
+    return unless $ctx->{user};
+
+    my $openathens_config =
+        $self->_get_openathens_config_for_org($ctx->{user}->home_ou);
+
+    if ($openathens_config
+        && $U->is_true($openathens_config->{active})
+        && $U->is_true($openathens_config->{auto_signout_enabled})
+    ) {
+        my $redirect = $ctx->{opac_root}
+            . '/sso/openathens/logout?redirect_to='
+            . uri_escape_utf8($redirect_to);
+
+        if ($redirect) {
+            return $self->generic_redirect($redirect, $cookie_list);
+        }
+    }
+
+    return undef;
+}
+
+# -----------------------------------------------------------------------------
+# Handler for /eg/opac/sso/openathens. Establishes single-sign-on session on 
+# OpenAthens, if configured. Implements
+# http://docs.openathens.net/display/public/MD/Implementing+the+API+connector+in+your+code
+#
+# There are two flows supported:
+#
+# 1. The user just logged in locally, and we want to sign them on to
+# OpenAthens as well (if this feature is enabled for the user's org unit).
+#
+# In this case 'redirect_to' will be set and will be the local URL that
+# initiated login, e.g. /eg/opac/myopac/main. We will send the user to
+# OpenAthens with a token that will establish their sign-on session, and with a
+# redirect parameter instructing OpenAthens to send them back to the original
+# local URL afterwards.
+#
+# 2. The user tried to access an OpenAthens-protected resource and chose to
+# sign on via their account with us.
+#
+# In this case, 'returnData' will be supplied by OpenAthens and is opaque to
+# us. We will send the user back to OpenAthens with a token that will
+# establish their sign-on session, together with the returnData. OpenAthens
+# can then forward the user on to whichever resource they were originally
+# requesting.
+# -----------------------------------------------------------------------------
+sub load_openathens_sso {
+    my $self = shift;
+    my $cgi = $self->cgi;
+    my $ctx = $self->ctx;
+
+    my $redirect_to = $cgi->param('redirect_to') || '';
+    my $return_data = $cgi->param('returnData') || '';
+    my $status = $cgi->param('status') || '';
+
+    # 'redirect_to' must be empty or a local URL
+    return Apache2::Const::HTTP_BAD_REQUEST unless $redirect_to =~ m:^($|/):;
+
+    # 'redirect_to' and 'returnData' are mutually exclusive
+    return Apache2::Const::HTTP_BAD_REQUEST if ($redirect_to && $return_data);
+
+    my $openathens_config =
+        $self->_get_openathens_config_for_org($ctx->{user}->home_ou);
+
+    if (!$openathens_config
+        || !$U->is_true($openathens_config->{active})
+    ) {
+        return $self->generic_redirect();
+    }
+
+    if ($redirect_to) {
+        # OpenAthens sign-on has been initiated by local login.
+
+        if ($status) {
+            # User has already been redirected to OpenAthens and back again.
+            # Status will indicate success/failure, but ignore: we don't want
+            # to show the user any errors because it's a non-interactive flow.
+            return $self->generic_redirect($redirect_to);
+        } else {
+            # Request has not yet gone to OpenAthens; initiate now by making
+            # API call then redirecting.
+            my $return_url = $ctx->{proto} . '://' . $ctx->{hostname}
+                . $ctx->{opac_root} . '/sso/openathens?redirect_to='
+                . uri_escape_utf8($redirect_to);
+
+            my $oa_redirect = $self->_get_openathens_session_initiator_url(
+                $return_url
+            );
+
+            return $self->generic_redirect($oa_redirect);
+        }
+    } elsif ($return_data) {
+        # OpenAthens has initiaited sign-on; make API call using supplied data,
+        # then redirect back.
+        my $oa_redirect = $self->_get_openathens_session_initiator_url(
+            undef,
+            $return_data
+        );
+
+        return $self->generic_redirect($oa_redirect);
+    } else {
+        # Page called with no relevant parameters; go to home.
+        return $self->generic_redirect();
+    }
+}
+
+# -----------------------------------------------------------------------------
+# Hanlder for /eg/opac/sso/openathens/logout. Ends OpenAthens session.
+# Optionally called after local logout.
+# -----------------------------------------------------------------------------
+sub load_openathens_logout {
+    my $self = shift;
+    my $ctx = $self->ctx;
+
+    $self->generic_redirect(OA_SIGNOUT_URL);
+}
+
+# -----------------------------------------------------------------------------
+# Retrieves the relevant OpenAthens config for the given org unit. If not set,
+# searches up the org hierarchy to find one, or returns undef. If an org unit
+# has multiple configs, only the first is used.
+# -----------------------------------------------------------------------------
+sub _get_openathens_config_for_org {
+    my ($self, $org_id) = @_;
+    my $e = new_editor();
+
+    my @org_ancestors = reverse @{$U->get_org_ancestors($org_id, 1)};
+    my $parent_org = $org_ancestors[1];
+
+    my $configs = $e->json_query({
+        select => {
+            coai => \@oa_config_fields,
+            coauf => [
+                { column => 'name', alias => 'id_field' }
+            ],
+            coanf => [
+                { column => 'name', alias => 'dn_field' }
+            ]
+        },
+        from => {
+            coai => {
+                coauf => {},
+                coanf => {}
+            }
+        },
+        where => {
+            '+coai' => { org_unit => $org_id }
+        },
+        order_by => { 'coai' => ['id'] }
+    });
+
+    if (@$configs) {
+        return $configs->[0];
+    } elsif ($parent_org) {
+        return $self->_get_openathens_config_for_org($parent_org);
+    } else {
+        return undef;
+    }
+}
+
+# -----------------------------------------------------------------------------
+# Makes POST to OpenAthens local-auth API. Returns URL to which the user should
+# be redirected to establish OpenAthens SSO session.
+# -----------------------------------------------------------------------------
+sub _get_openathens_session_initiator_url {
+    my $self = shift;
+    my ($return_url, $return_data) = @_;
+    my $ctx = $self->ctx;
+    my $user = $ctx->{user};
+
+    my $openathens_config =
+        $self->_get_openathens_config_for_org($user->home_ou);
+
+    # must have either returnUrl or returnData but not both
+    return undef if $return_url && $return_data;
+    return undef if !$return_url && !$return_data;
+
+    # Select the chosen unique identifier attribute
+    my $unique_user_identifier;
+    if ($openathens_config->{id_field} eq 'id') {
+        $unique_user_identifier = $user->id;
+    } elsif ($openathens_config->{id_field} eq 'usrname') {
+        $unique_user_identifier = $user->usrname;
+    }
+
+    # Select the chosen display name attribute
+    my $display_name;
+    if ($openathens_config->{dn_field} eq 'id') {
+        $display_name = $user->id;
+    } elsif ($openathens_config->{dn_field} eq 'usrname') {
+        $display_name = $user->usrname;
+    } elsif ($openathens_config->{dn_field} eq 'fullname') {
+        $display_name =
+            ($user->pref_first_given_name || $user->first_given_name)
+                . ' ' . ($user->pref_family_name || $user->family_name);
+    }
+
+    # Build object to POST to OpenAthens
+    my $request_obj = {
+        'connectionID' => $openathens_config->{connection_id},
+        'uniqueUserIdentifier' => $unique_user_identifier,
+        'displayName' => $display_name,
+        'attributes' => {}
+    };
+
+    # Optional attributes
+    if ($U->is_true($openathens_config->{release_prefix})) {
+        $request_obj->{attributes}->{OA_ATTR_PREFIX} = $user->prefix;
+    }
+
+    if ($U->is_true($openathens_config->{release_first_given_name})) {
+        $request_obj->{attributes}->{OA_ATTR_FIRST_GIVEN_NAME} =
+            $user->pref_first_given_name || $user->first_given_name;
+    }
+
+    if ($U->is_true($openathens_config->{release_second_given_name})) {
+        $request_obj->{attributes}->{OA_ATTR_SECOND_GIVEN_NAME} =
+            $user->pref_second_given_name || $user->second_given_name;
+    }
+
+    if ($U->is_true($openathens_config->{release_family_name})) {
+        $request_obj->{attributes}->{OA_ATTR_FAMILY_NAME} =
+            $user->pref_family_name || $user->family_name;
+    }
+
+    if ($U->is_true($openathens_config->{release_suffix})) {
+        $request_obj->{attributes}->{OA_ATTR_SUFFIX} = $user->suffix;
+    }
+
+    if ($U->is_true($openathens_config->{release_email})) {
+        $request_obj->{attributes}->{OA_ATTR_EMAIL} = $user->email;
+    }
+
+    my $ou_id = $user->home_ou;
+    if ($ou_id && $U->is_true($openathens_config->{release_home_ou})) {
+        my $ou = $ctx->{get_aou}->($ou_id);
+        if ($ou) {
+            $request_obj->{attributes}->{OA_ATTR_HOME_OU} = $ou->shortname;
+        }
+    }
+
+    if ($return_url) {
+        $request_obj->{returnUrl} = $return_url;
+    } elsif ($return_data) {
+        $request_obj->{returnData} = $return_data;
+    }
+
+    # Execute OpenAthens API request
+    my $auth_header = OA_API_AUTH_TYPE . ' ' . $openathens_config->{api_key};
+    my $body = JSON::XS->new->utf8->encode($request_obj);
+    my $async = HTTP::Async->new;
+    $async->add(HTTP::Request->new(
+        'POST',
+        $openathens_config->{connection_uri},
+        [
+            'Authorization' => $auth_header,
+            'Content-type' => OA_SESSION_REQUEST_TYPE
+        ],
+        $body
+    ));
+
+    my $response = $async->wait_for_next_response(OA_API_WAIT_SECONDS);
+    if ($response->is_error) {
+        $self->apache->log->error('Error POSTing to OpenAthens API: '
+            . $response->code . ' ' . $response->message);
+
+        return undef;
+    }
+
+    # JSON response should contain the sessionInitiatorUrl
+    my $response_obj = JSON::XS->new->utf8->decode($response->content);
+    my $session_initiator_url = $response_obj->{sessionInitiatorUrl};
+    if (!$session_initiator_url) {
+        $self->apache->log->error(
+            'No sessionInitiatorUrl included in response from OpenAthens');
+
+        return undef;
+    }
+
+    return $session_initiator_url;
+}
+
+1;
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.openathens_identity.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.openathens_identity.sql
new file mode 100644 (file)
index 0000000..e43010d
--- /dev/null
@@ -0,0 +1,54 @@
+BEGIN;
+
+SELECT evergreen.upgrade_deps_block_check('XXXX', :eg_version);
+
+CREATE TABLE config.openathens_uid_field (
+    id      SERIAL  PRIMARY KEY,
+    name    TEXT    NOT NULL
+);
+
+INSERT INTO config.openathens_uid_field
+    (id, name)
+VALUES
+    (1,'id'),
+    (2,'usrname')
+;
+
+SELECT SETVAL('config.openathens_uid_field_id_seq'::TEXT, 100);
+
+CREATE TABLE config.openathens_name_field (
+    id      SERIAL  PRIMARY KEY,
+    name    TEXT    NOT NULL
+);
+
+INSERT INTO config.openathens_name_field
+    (id, name)
+VALUES
+    (1,'id'),
+    (2,'usrname'),
+    (3,'fullname')
+;
+
+SELECT SETVAL('config.openathens_name_field_id_seq'::TEXT, 100);
+
+CREATE TABLE config.openathens_identity (
+    id                          SERIAL  PRIMARY KEY,
+    active                      BOOL    NOT NULL DEFAULT true,
+    org_unit                    INT     NOT NULL REFERENCES actor.org_unit (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED,
+    api_key                     TEXT    NOT NULL,
+    connection_id               TEXT    NOT NULL,
+    connection_uri              TEXT    NOT NULL,
+    auto_signon_enabled         BOOL    NOT NULL DEFAULT true,
+    auto_signout_enabled        BOOL    NOT NULL DEFAULT false,
+    unique_identifier           INT     NOT NULL REFERENCES config.openathens_uid_field (id) DEFAULT 1,
+    display_name                INT     NOT NULL REFERENCES config.openathens_name_field (id) DEFAULT 1,
+    release_prefix              BOOL    NOT NULL DEFAULT false,
+    release_first_given_name    BOOL    NOT NULL DEFAULT false,
+    release_second_given_name   BOOL    NOT NULL DEFAULT false,
+    release_family_name         BOOL    NOT NULL DEFAULT false,
+    release_suffix              BOOL    NOT NULL DEFAULT false,
+    release_email               BOOL    NOT NULL DEFAULT false,
+    release_home_ou             BOOL    NOT NULL DEFAULT false
+);
+
+COMMIT;
diff --git a/docs/RELEASE_NOTES_NEXT/Administration/OpenAthens_SignOn.adoc b/docs/RELEASE_NOTES_NEXT/Administration/OpenAthens_SignOn.adoc
new file mode 100644 (file)
index 0000000..1c526d3
--- /dev/null
@@ -0,0 +1,150 @@
+Configuring sign-on to OpenAthens
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+If your institution uses OpenAthens single sign-on, you can configure Evergreen 
+to link with OpenAthens. This will let patrons connect to OpenAthens resources 
+seamlessly once they have logged in to Evergreen. Patrons are automatically 
+assigned an OpenAthens identity dynamically based on their Evergreen login, 
+and do not need accounts created manually in OpenAthens.
+
+Registering your Evergreen installation with the OpenAthens service
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
+Using your OpenAthens administrator account at https://admin.openathens.net/, 
+complete the following steps:
+
+. Register a local authentication connection for Evergreen:
+  .. Go to *Management* -> *Connections*.
+  .. Under *Local authentication* click *Add*.
+  .. In the wizard that appears, select *Evergreen* as the local authentication 
+  system type (or *API* if Evergreen is not listed) and click *Configure*.
+  .. For *Display name*, enter the name of your Evergreen portal that your 
+  patrons will be familiar with. They will need to be able to recognise and 
+  select this name from a list of sign-in options on OpenAthens.
+  .. For *Callback URL* enter *https://<HOSTNAME>/eg/opac/sso/openathens* where 
+  <HOSTNAME> is the public hostname of your Evergreen installation, and click 
+  *Save*. (If you have installed Evergreen somewhere other than /eg, modify the
+  URL accordingly.)
+  .. On the details page that appears, take a copy of the *Connection ID* and 
+  *Connection URI* that have been generated. You will need these when 
+  configuring Evergreen.
+. Generate an API key:
+  .. Go to *Management* -> *API keys* and click *Add*.
+  .. For *Name*, enter 'Evergreen' or whatever name you use for your Evergreen 
+  portal internally, and click *Save*.
+  .. Take a copy of the 36-character key that has been generated. You will need 
+  this when configuring Evergreen.
+
+Full OpenAthens documentation for local authentication API connections is 
+available at http://docs.openathens.net/display/public/MD/API+connector.
+
+Configuring Evergreen
++++++++++++++++++++++
+OpenAthens sign-on is configured in the staff client under *Local 
+Administration* -> *OpenAthens Sign-on*. To make a connection, select *New 
+Sign-on to OpenAthens*, and set the values as follows:
+
+* *Owner* - the organisation within your library hierarchy that owns the 
+connection to OpenAthens. If your whole consortium has signed up to OpenAthens 
+as a single customer, then you would select the top-level. If only one 
+regional library system or branch is the OpenAthens customer, select that. 
+Whichever organisation you select, the OpenAthens connection will take effect 
+for all libraries below it in your organisational hierarchy. A single 
+OpenAthens sign-on configuration normally equates to a single *domain* in the 
+OpenAthens service. If in doubt refer to your OpenAthens account manager or 
+implementation partner.
+* *Active* - Enable this connection (enabled by default).
+* *API key* - the 36-character OpenAthens *API key* that was generated in step 
+  2 above.
+* *Connection ID* - the numerical *Connection ID* that was generated for the 
+  OpenAthens local authentication connection in step 1 above.
+* *Connection URI* - the *Connection URI* that was generated for the 
+  OpenAthens local authentication connection in step 1 above.
+* *Auto sign-on* - controls _when_ patrons are signed on to OpenAthens:
+  ** *enabled* (recommended) - As soon as a patron logs in to Evergreen, they 
+  are signed in to OpenAthens. This happens via a quick redirect that the user 
+  should not notice.
+  ** *disabled* - The patron is not signed in to OpenAthens to start with. When 
+  they first access an OpenAthens-protected resource, they will need to search 
+  for your institution at the OpenAthens log-in page and choose your Evergreen 
+  portal as the sign-in method (they will see the name you entered as the 
+  *Display name* in step 1 above). Evergreen will then prompt for log-in if 
+  they have not already logged in. After that, they are signed in to OpenAthens 
+  and OpenAthens redirects them to the resource.
+* *Auto sign-out* - controls whether the patron is signed out of OpenAthens 
+  when they log out of Evergreen. If *enabled* the patron will be sent to the 
+  OpenAthens sign-out page when they log out of Evergreen. You can optionally 
+  configure the OpenAthens service to send them back to your home page again 
+  after this; the setting can be found at https://admin.openathens.net/ under 
+  *Preferences* -> *Domain* -> *After sign out*.
+* *Unique identifier field* - controls which attribute of patron accounts is 
+  used as the unique identifier in OpenAthens. The supported values are 'id' 
+  and 'usrname', but you should leave this set to the default value of 'id' 
+  unless you have a reason to do otherwise. It is important that this attribute 
+  does not change during the lifetime of a patron account, otherwise they would 
+  lose any personalised settings they have saved on third party resources. It 
+  is also important that you do not re-use old patron accounts for new users, 
+  otherwise a new user could see personalised settings saved by an old user.
+* *Display name field* - controls which attribute of patron accounts is 
+  displayed in the OpenAthens portal at https://admin.openathens.net/. (This 
+  is where you can see which accounts have been used, and what use patrons are 
+  making of third party resources.) The supported values are 'id', 'usrname' 
+  and 'fullname'. Whichever you choose, OpenAthens will only use it within 
+  your portal view; it won't be released to third-party resources.
+* *Release X* - one setting for each of the attributes that it is possible to 
+  release to OpenAthens. Depending on your user privacy policy, you can 
+  configure any of these attributes to be released to OpenAthens as part of 
+  the sign-on process. None are enabled by default. OpenAthens in turn doesn't 
+  store or release any of these attributes to third party resources, unless 
+  you configure that separately in the OpenAthens portal. You have to 
+  configure this in two stages. Firstly, mapping Evergreen attributes to 
+  OpenAthens attributes, and secondly releasing OpenAthens attributes to third 
+  party resources. See the OpenAthens documenation pages at 
+  http://docs.openathens.net/display/public/MD/Attribute+mapping and 
+  http://docs.openathens.net/display/public/MD/Attribute+release. You will need 
+  to know the exact names of the attributes that are released. These are listed 
+  in the following table:
+
+|===
+|Setting|Attribute released|Description
+
+|Release prefix
+|prefix
+|the patron's prefix, overriden by the preferred prefix if that is set
+
+|Release first name
+|first_given_name
+|the patron's first name, overriden by the preferred first name if that is set
+
+|Release middle name
+|second_given_name
+|the patron's middle name, overriden by the preferred middle name if that is set
+
+|Release surname
+|family_name
+|the patron's last name, overriden by the preferred last name if that is set
+
+|Release suffix
+|suffix
+|the patron's suffix, overriden by the preferred suffix if that is set
+
+|Release email
+|email
+|the patron's email address
+
+|Release home library
+|home_ou
+|the _shortcode_ of the patron's home library (e.g. 'BR1' in the Concerto 
+sample data set)
+|===
+
+Network access
+++++++++++++++
+As part of the sign-on process, Evergreen makes a connection to the OpenAthens
+service to transfer details of the user that is signing on. This data does not
+go via the user's browser, to avoid revealing the private API key and to avoid
+the risk of spoofing. You need to open up port 443 outbound in your firewall,
+from your Evergreen server to login.openathens.net.
+
+Admin permissions
++++++++++++++++++
+To delegate OpenAthens configuration to other staff users, assign the 
+*ADMIN_OPENATHENS* permission.
index 85ac7cd..3f81594 100644 (file)
@@ -11,6 +11,14 @@ The following are the base requirements setting Evergreen up on a test server:
  * Linux Operating System (community supports Debian, Ubuntu, or Fedora)
  * Ports 80 and 443 should be opened in your firewall for TCP connections to allow OPAC and staff client connections to the Evergreen server.
 
+Optional Feature Requirements
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+If you will be configuring Evergreen to sign users on to OpenAthens:
+
+ * Port 443 should be opened _outbound_ in your firewall to allow the
+   Evergreen server to connect to https://login.openathens.net.
+
 Web Client Requirements
 ~~~~~~~~~~~~~~~~~~~~~~~