LP#1842297: Implements patron sign-on to the OpenAthens service.
authoroajulianclementson <51331324+oajulianclementson@users.noreply.github.com>
Wed, 30 Mar 2022 11:51:59 +0000 (12:51 +0100)
committerJane Sandberg <sandbergja@gmail.com>
Fri, 23 Sep 2022 03:01:04 +0000 (20:01 -0700)
For libraries who are OpenAthens customers, they can configure Evergreen to sign their patrons on to OpenAthens
either immediately when they sign on to Evergreen, or on demand when they select their library as their method
to sign on to OpenAthens-protected resources.

Signed-off-by: oajulianclementson <51331324+oajulianclementson@users.noreply.github.com>
Signed-off-by: Jane Sandberg <js7389@princeton.edu>
24 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-bullseye
Open-ILS/src/extras/install/Makefile.debian-buster
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-focal
Open-ILS/src/perlmods/Build.PL
Open-ILS/src/perlmods/lib/OpenILS/Application/AppUtils.pm
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/perlmods/t/19-OpenILS-WWW-EGCatLoader.t
Open-ILS/src/perlmods/t/25-OpenILS-WWW-EGCatLoader-OpenAthens.t [new file with mode: 0644]
Open-ILS/src/sql/Pg/002.schema.config.sql
Open-ILS/src/sql/Pg/800.fkeys.sql
Open-ILS/src/sql/Pg/950.data.seed-values.sql
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/modules/installation/pages/system_requirements.adoc

index 61753df..832011b 100644 (file)
@@ -15434,6 +15434,78 @@ SELECT  usr,
         </fields>
     </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"/>
+                       <field reporter:label="Release barcode" name="release_barcode" 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 9188bde..7ea6416 100644 (file)
@@ -52,6 +52,8 @@
       routerLink="/staff/admin/local/config/non_cataloged_type"></eg-link-table-link>
     <eg-link-table-link i18n-label label="Notifications / Action Triggers" 
       routerLink="/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 2fff294..cb134f1 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 {ClonePortalEntriesDialogComponent} from './staff_portal_page/clone-portal-entries-dialog.component';
 import {AdminStaffPortalPageComponent} from './staff_portal_page/staff-portal-page.component';
 import {StandingPenaltyComponent} from './standing-penalty.component';
@@ -17,7 +18,8 @@ import {StandingPenaltyComponent} from './standing-penalty.component';
       AdminCarouselComponent,
       StandingPenaltyComponent,
       ClonePortalEntriesDialogComponent,
-      AdminStaffPortalPageComponent
+      AdminStaffPortalPageComponent,
+      OpenAthensIdentityComponent
   ],
   imports: [
     AdminCommonModule,
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..974a386
--- /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 (onClick)="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 (onClick)="editSelected($event)">
+  </eg-grid-toolbar-action>
+  <eg-grid-toolbar-action label="Delete Selected" i18n-label (onClick)="deleteSelected($event)">
+  </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,release_barcode"
+></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..6c075b9
--- /dev/null
@@ -0,0 +1,71 @@
+import {Component, OnInit} from '@angular/core';
+import {Location} from '@angular/common';
+import {ActivatedRoute} from '@angular/router';
+import {FormatService} from '@eg/core/format.service';
+import {AdminPageComponent} from '@eg/staff/share/admin-page/admin-page.component';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {OrgService} from '@eg/core/org.service';
+import {PermService} from '@eg/core/perm.service';
+import {AuthService} from '@eg/core/auth.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+
+@Component({
+    templateUrl: './openathens-identity.component.html'
+})
+export class OpenAthensIdentityComponent extends AdminPageComponent implements OnInit {
+
+    idlClass = 'coai';
+    classLabel: string;
+
+    constructor(
+        route: ActivatedRoute,
+        ngLocation: Location,
+        format: FormatService,
+        idl: IdlService,
+        org: OrgService,
+        auth: AuthService,
+        pcrud: PcrudService,
+        perm: PermService,
+        toast: ToastService,
+    ) {
+        super(route, ngLocation, format, idl, org, auth, pcrud, perm, toast);
+    }
+
+    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));
+                }
+            }
+        );
+    }
+
+    deleteSelected = (entries: IdlObject[]) => {
+        super.deleteSelected(entries);
+    }
+}
index e619f6e..7b8550f 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 {AdminStaffPortalPageComponent} from './staff_portal_page/staff-portal-page.component';
 import {StandingPenaltyComponent} from './standing-penalty.component';
 import {CourseTermMapComponent} from './course-reserves/course-term-map.component';
@@ -52,6 +53,9 @@ const routes: Routes = [{
     loadChildren: () =>
       import('./circ_limit_set/circ_limit_set.module').then(m => m.CircLimitSetModule)
 }, {
+    path: 'config/openathens_identity',
+    component: OpenAthensIdentityComponent
+}, {
     path: 'config/standing_penalty',
     component: StandingPenaltyComponent
 }, {
index 0cc634d..1274a6b 100644 (file)
@@ -42,11 +42,13 @@ export DEBS = \
        libexcel-writer-xlsx-perl\
        libgd-graph3d-perl\
        libgeo-coder-osm-perl\
+       libhttp-async-perl\
        libhttp-oai-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\
@@ -64,6 +66,8 @@ export DEBS = \
        libsru-perl\
        libssh2-1-dev\
        libtemplate-plugin-posix-perl\
+       libtest-mockmodule-perl\
+       libtest-mockobject-perl\
        libtest-warn-perl\
        libtest-output-perl\
        libtext-aspell-perl\
index 57fae84..65c1333 100644 (file)
@@ -43,11 +43,13 @@ export DEBS = \
        libgd-graph3d-perl\
        libhttp-oai-perl\
        libgeo-coder-osm-perl\
+       libhttp-async-perl\
        libhttp-oai-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\
@@ -65,6 +67,7 @@ export DEBS = \
        libsru-perl\
        libssh2-1-dev\
        libtemplate-plugin-posix-perl\
+       libtest-mockobject-perl\
        libtest-warn-perl\
        libtest-output-perl\
        libtext-aspell-perl\
@@ -101,6 +104,7 @@ export CPAN_MODULES = \
        Geo::Coder::Google \
        Business::OnlinePayment::PayPal \
        String::KeyboardDistance \
+       Test::MockModule \
        Text::Levenshtein::Damerau::XS \
        Email::Send
 
index d242aa6..f1e46b3 100644 (file)
@@ -42,11 +42,13 @@ export DEBS = \
        libexcel-writer-xlsx-perl\
        libgd-graph3d-perl\
        libgeo-coder-osm-perl\
+       libhttp-async-perl\
        libhttp-oai-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\
@@ -64,6 +66,7 @@ export DEBS = \
        libsru-perl\
        libssh2-1-dev\
        libtemplate-plugin-posix-perl\
+       libtest-mockobject-perl\
        libtest-warn-perl\
        libtest-output-perl\
        libtext-aspell-perl\
@@ -100,6 +103,7 @@ export CPAN_MODULES = \
        Geo::Coder::Google \
        Business::OnlinePayment::PayPal \
        String::KeyboardDistance \
+       Test::MockModule \
        Text::Levenshtein::Damerau::XS \
        Email::Send
 
index 907bd99..986e5b8 100644 (file)
@@ -46,18 +46,22 @@ 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 \
        perl-Parse-RecDescent \
        perl-RPC-XML \
        perl-SOAP-Lite \
+       perl-Test-MockModule \
+       perl-Test-MockObject \
        perl-Test-Warn \
        perl-Test-Output \
        perl-Text-Aspell \
index 11a2ff5..ab696f2 100644 (file)
@@ -42,9 +42,11 @@ export DEBS = \
        libgd-graph3d-perl\
        libhttp-oai-perl\
        libgeo-coder-osm-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\
@@ -61,6 +63,7 @@ export DEBS = \
        libsru-perl\
        libssh2-1-dev\
        libtemplate-plugin-posix-perl\
+       libtest-mockobject-perl\
        libtest-warn-perl\
        libtest-output-perl\
        libtext-aspell-perl\
@@ -98,6 +101,7 @@ export CPAN_MODULES = \
        Email::Send \
        MARC::Charset \
        String::KeyboardDistance \
+       Test::MockModule \
        Text::Levenshtein::Damerau::XS \
        Net::Z3950::Simple2ZOOM
 
index 74badd8..0190a05 100644 (file)
@@ -42,9 +42,11 @@ export DEBS = \
        libgd-graph3d-perl\
        libhttp-oai-perl\
        libgeo-coder-osm-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\
@@ -61,6 +63,8 @@ export DEBS = \
        libsru-perl\
        libssh2-1-dev\
        libtemplate-plugin-posix-perl\
+       libtest-mockmodule-perl\
+       libtest-mockobject-perl\
        libtest-warn-perl\
        libtest-output-perl\
        libtext-aspell-perl\
index 5c32308..078b099 100644 (file)
@@ -41,6 +41,7 @@ my $build = Module::Build->new(
         'File::stat' => '0',
         'File::Temp' => '0',
         'Getopt::Long' => '0',
+        'HTTP::Async' => '0',
         'IO::Scalar' => '0',
         'List::Util' => '0',
         'Locale::Country' => '0',
@@ -51,6 +52,7 @@ my $build = Module::Build->new(
         'MARC::Record' => '0',
         'MIME::Base64' => '0',
         'Net::FTP' => '0',
+        'Net::HTTPS::NB' => '0',
         'Net::SSH2' => '0',
         'OpenSRF::Application' => '0',
         'OpenSRF::AppSession' => '0',
@@ -78,6 +80,8 @@ my $build = Module::Build->new(
         'Sys::Syslog' => '0',
         'Template' => '0',
         'Template::Plugin' => '0',
+        'Test::MockModule' => '0',
+        'Test::MockObject' => '0',
         'Test::More' => '0',
         'Text::Aspell' => '0',
         'Text::CSV' => '0',
index 3a86a89..566342b 100644 (file)
@@ -1574,6 +1574,21 @@ sub org_unit_ancestor_at_depth {
     return ($resp) ? $resp->{id} : undef;
 }
 
+# returns the ID of the org unit ancestor at the specified distance
+sub get_org_unit_ancestor_at_distance {
+    my ($class, $org_id, $distance) = @_;
+    my $ancestors = OpenILS::Utils::CStoreEditor->new->json_query(
+        { from => ['actor.org_unit_ancestors_distance', $org_id] });
+    my @match = grep { $_->{distance} == $distance } @{$ancestors};
+    return (@match) ? $match[0]->{id} : undef;
+}
+
+# returns the ID of the org unit parent
+sub get_org_unit_parent {
+    my ($class, $org_id) = @_;
+    return $class->get_org_unit_ancestor_at_distance($org_id, 1);
+}
+
 # Returns the proximity value between two org units.
 sub get_org_unit_proximity {
     my ($class, $e, $from_org, $to_org) = @_;
index 0f8557b..e94b829 100644 (file)
@@ -29,6 +29,7 @@ use OpenILS::WWW::EGCatLoader::Course;
 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';
 
@@ -180,6 +181,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
 
@@ -281,6 +283,7 @@ sub load {
     return $self->load_myopac_prefs_my_lists if $path =~ m|opac/myopac/prefs_my_lists|;
     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_openathens_sso if $path =~ m|opac/sso/openathens$|;
 
     return Apache2::Const::OK;
 }
@@ -709,10 +712,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
+        );
 }
 
 sub load_manual_shib_login {
@@ -747,7 +754,8 @@ sub load_manual_shib_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};
     my $active_logout = $self->cgi->param('active_logout');
 
     my $sso_org = $ENV{sso_loc} || $self->get_physical_loc || $self->_get_search_lib();
@@ -774,35 +782,66 @@ 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'
-            ),
-            ($active_logout ? ($self->cgi->cookie(
-                -name => COOKIE_SHIB_LOGGEDOUT,
-                -path => '/',
-                -value => '1',
-                -expires => '2147483647'
-            )) : ()),
-            $self->cgi->cookie(
-                -name => COOKIE_SHIB_LOGGEDIN,
-                -path => '/',
-                -value => '0',
-                -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'
+        ),
+        ($active_logout ? ($self->cgi->cookie(
+            -name => COOKIE_SHIB_LOGGEDOUT,
+            -path => '/',
+            -value => '1',
+            -expires => '2147483647'
+        )) : ()),
+        $self->cgi->cookie(
+            -name => COOKIE_SHIB_LOGGEDIN,
+            -path => '/',
+            -value => '0',
+            -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..d22065e
--- /dev/null
@@ -0,0 +1,468 @@
+# -----------------------------------------------------------------------------
+# Submodule for handling patron sign-in and sign-out of the OpenAthens service
+# -----------------------------------------------------------------------------
+
+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_ATTR_BARCODE => 'barcode';
+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 release_barcode/;
+
+# -----------------------------------------------------------------------------
+# sub perform_openathens_sso_if_required
+# -----------------------------------------------------------------------------
+#
+# This method is called by EGCatLoader as part of the patron login process. It
+# is called after the login credentials have been checked, but before the
+# patron is redirected back to the page they originally requested.
+#
+# If the patron's home library is configured to sign patrons in to OpenAthens
+# automatically when they log in to Evergreen, then this method issues a
+# redirect to the OpenAthens sign-in handler at <OPAC_ROOT>/sso/openathens,
+# which is responsible for establishing an OpenAthens user session. The
+# ?redirect_to query string parameter is passed forward to the OpenAthens
+# sign-in handler so that it can in turn issue a redirect back to the
+# originally requested page once it has done its work.
+#
+# If the home library is not configured for automatic OpenAthens sign-in, this
+# method does nothing, and leaves the rest of EGCatLoader to complete its
+# normal redirect back to the originally requested page.
+#
+# In the case where a patron who is not already logged in has arrived at the
+# OpenAthens sign-in handler from an external website, EGCatLoader will ask
+# them to log in, and this method will be called as a result. In this case we
+# don't construct a new redirect to the OpenAthens handler with a &redirect_to
+# parameter, otherwise we could cause a redirect loop. We just leave
+# EGCatLoader to complete its normal redirect back to the originally requested
+# page after login. (We can identify this case by ?redirect_to matching the URL
+# of the OpenAthens handler.) The flow that occurs in this case is described in
+# more detail in the comment on sub load_openathens_sso, case 2.
+#
+# -----------------------------------------------------------------------------
+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->{auto_signon_enabled})
+    ) {
+        # Remove scheme and hostname from redirect_to (this may have been set
+        # by the login form, but isn't allowed by the OpenAthens SSO page)
+        if ($redirect_to =~ m#^https?://\Q$ctx->{hostname}\E(.+)#) {
+            $redirect_to = $1;
+        }
+
+        my $redirect = $ctx->{opac_root} . '/sso/openathens?redirect_to='
+            . uri_escape_utf8($redirect_to);
+
+        if ($redirect) {
+            return $self->generic_redirect($redirect, $cookie_list);
+        }
+    }
+}
+
+# -----------------------------------------------------------------------------
+# sub perform_openathens_signout_if_required
+# -----------------------------------------------------------------------------
+#
+# This method is called by EGCatLoader as part of the patron logout process. It
+# is called while the patron's identity is still in session as $ctx->{user},
+# and before the patron is redirected back to the home page.
+#
+# If the patron's home library is configured to sign patrons out of OpenAthens
+# when they log out of Evergreen, then this method issues a redirect to the
+# OpenAthens sign-out handler at <OPAC_ROOT>/sso/openathens/logout, which is
+# responsible for destroying the OpenAthens session. The redirect_to parameter
+# (usually set to the home page when logging out) is passed forward to the
+# OpenAthens sign-out handler so that it can in turn issue a redirect back to
+# home page once it has done its work.
+#
+# If the home library is not configured for OpenAthens sign-out, this method
+# does nothing, and leaves the rest of EGCatLoader to complete its normal
+# redirect back to the home page.
+#
+# -----------------------------------------------------------------------------
+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;
+}
+
+# -----------------------------------------------------------------------------
+# sub load_openathens_sso
+# -----------------------------------------------------------------------------
+#
+# This is the handler for <OPAC_ROOT>/sso/openathens. Its job is to establish a
+# single-sign-on (SSO) session on OpenAthens for an Evergreen patron. It works
+# by calling the OpenAthens API to obtain a unique session-initiation URL
+# for the patron, and then issuing a redirect to that URL. The logic
+# follows the instructions for OpenAthens API-based sign-in at:
+# http://docs.openathens.net/display/public/MD/Implementing+the+API+connector+in+your+code
+#
+# There are two flows supported:
+#
+# 1. The patron just logged in locally, and we want to sign them in to
+#    OpenAthens as well (if this feature is enabled for the patron's home
+#    library).
+#
+#    In this case the redirect_to parameter will have been provided in the
+#    query string (by the code in sub load_openathens_sso_if_reuired), and will
+#    be the local URL that initiated login, for example /eg/opac/myopac/main.
+#
+#    We will call the OpenAthens API via a back channel, supplying the patron's
+#    unique identifier and the redirect URL. The OpenAthens API response will
+#    contain a URL that can be used to establish the OpenAthens session for the
+#    patron, and we redirect the patron to this URL. (The URL will contain a
+#    redirect parameter instructing OpenAthens to send the patron back to the
+#    originally requested local URL afterwards.
+#
+# 2. The patron tried to access an external website that requires an OpenAthens
+#    session, and chose our Evergreen instance as their identity provider.
+#    OpenAthens will therefore redirect the patron to this handler.
+#
+#    (This is a protected page, so if the patron is not already logged in to
+#    Evergreen, EGCatLoader will request login first, and then redirect back
+#    here, in the same way as any other page that requires login.)
+#
+#    In this case, OpenAthens will supply a returnData query string paramemter.
+#    This parameter contains information about which website the patron is
+#    trying to access but it is opaque to us.
+#
+#    We will call the OpenAthens API via a back channel, supplying the patron's
+#    unique identifier and the returnData. The OpenAthens API response will
+#    contain a URL that can be used to establish the OpenAthens session for the
+#    patron, and we redirect the patron to this URL. The URL will contain the
+#    original returnData parameter, instructing OpenAthens to send the patron
+#    onward to the website they were originally trying to access, after it has
+#    established their session.
+#
+# We do not expect to receive both redirect_to and returnData in the same
+# request. This is an invalid request and results in a 400 status error. If we
+# don't receive either redirect_to or returnData, that's also unexpected, but
+# silently ignored by redirecting to the OPAC home. Any error calling the 
+# OpenAthens API is logged for diagnostic purposes, but the patron is
+# redirected to the OPAC home page rather than displaying the error. The system
+# will alway try again next time they access a website that requires an
+# OpenAthens session.
+#
+# -----------------------------------------------------------------------------
+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);
+
+    # Page called with no relevant parameters; go to home.
+    return $self->generic_redirect() unless ($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);
+    }
+}
+
+# -----------------------------------------------------------------------------
+# sub load_openathens_logout
+# -----------------------------------------------------------------------------
+#
+# Hanlder for <OPAC_ROOT>/sso/openathens/logout. Its job is to terminate the
+# patron's OpenAthens session. It does this by redirecting the patron to the
+# standard OpenAthens sign-out URL.
+#
+# The patron will only get here if their home library is configured to sign
+# patrons out of OpenAthens when they log out of Evergreen. See the comment on
+# sub load_openathens_signout_if_required above.
+#
+# The OpenAthens sign-out URL does not accept a redirect parameter. However
+# the library's OpenAthens administrator can configure a fixed post-sign-out
+# redirect in their OpenAthens administrator dashboard. This could be used to
+# send patrons back to Evergreen after their OpenAthens session has ended.
+#
+# -----------------------------------------------------------------------------
+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 $parent_org = $U->get_org_unit_parent($org_id);
+
+    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, active => 't' }
+        },
+        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 ($U->is_true($openathens_config->{release_barcode})) {
+        $request_obj->{attributes}->{&OA_ATTR_BARCODE} = $ctx->{active_card};
+    }
+
+    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;
index e8c1a0b..e538908 100644 (file)
@@ -1,6 +1,6 @@
 #!perl -T
 
-use Test::More tests => 11;
+use Test::More tests => 12;
 use CGI;
 
 BEGIN {
@@ -8,6 +8,7 @@ BEGIN {
 }
 use_ok( 'OpenILS::WWW::EGCatLoader::Account' );
 use_ok( 'OpenILS::WWW::EGCatLoader::Container' );
+use_ok( 'OpenILS::WWW::EGCatLoader::OpenAthens' );
 use_ok( 'OpenILS::WWW::EGCatLoader::Record' );
 use_ok( 'OpenILS::WWW::EGCatLoader::Search' );
 use_ok( 'OpenILS::WWW::EGCatLoader::Util' );
diff --git a/Open-ILS/src/perlmods/t/25-OpenILS-WWW-EGCatLoader-OpenAthens.t b/Open-ILS/src/perlmods/t/25-OpenILS-WWW-EGCatLoader-OpenAthens.t
new file mode 100644 (file)
index 0000000..ece6540
--- /dev/null
@@ -0,0 +1,526 @@
+#!perl -T
+
+# -----------------------------------------------------------------------------
+# Unit tests for OpenILS::WWW::EGCatLoader::OpenAthens
+# -----------------------------------------------------------------------------
+#
+# These are strict unit tests of this module in isolation. Lower layers are
+# mocked:
+#
+# * The Evergreen context is mocked to provide a dummy base URL etc.
+# * Apache is mocked to capture redirects being generated
+# * CGI is mocked to simulate query string input
+# * HTTP:Request is mocked to capture requests that would be sent to the
+#   OpenAthens API
+# * HTTP:Async is mocked to simulate a response from the OpenAthens API
+#
+# -----------------------------------------------------------------------------
+
+use strict;
+use Test::MockModule;
+use Test::MockObject 0.171;
+use Test::More tests => 35;
+use OpenILS::WWW::EGCatLoader;
+
+use constant OA_SIGNOUT_URL => qr/https:\/\/login\.openathens\.net\/signout/;
+
+BEGIN {
+       use_ok('OpenILS::WWW::EGCatLoader::OpenAthens');
+}
+
+# set up an arbitrary global context
+my $ctx = {
+    proto => 'https',
+    hostname => 'test.org',
+    opac_root => '/mytesteg/opac',
+    home_page => '/mytesteg/opac/home'
+};
+
+# capture output printed to Apache
+my $apache_capture;
+my $apache = Test::MockObject->new()
+    ->mock(print => sub {
+        $apache_capture = @_[1];
+    });
+
+# -----------------------------------------------------------------------------
+# method under test:    perform_openathens_sso_if_required
+#
+# test case:            patron is not logged in
+#
+# expected outcome:     does nothing
+# -----------------------------------------------------------------------------
+{
+    my $auth_response = {};
+    my $redirect_to = '/mytesteg/opac/home';
+    $apache_capture = undef;
+
+    my $mut = OpenILS::WWW::EGCatLoader->new($apache, { %$ctx });
+    $mut->perform_openathens_sso_if_required($auth_response, $redirect_to);
+    
+    is($apache_capture, undef, 'OpenAthens: no patron: no redirect');
+}
+
+# -----------------------------------------------------------------------------
+# method under test:    perform_openathens_sso_if_required
+#
+# test case:            patron is logged in but home OU is not configured for
+#                       OpenAthens
+#
+# expected outcome:     does nothing
+# -----------------------------------------------------------------------------
+{
+    my $patron = Test::MockObject->new()
+        ->mock(home_ou => sub { return 123; });
+
+    my $editor = Test::MockModule->new('OpenILS::Utils::CStoreEditor')
+        ->redefine(authtoken => 1)
+        ->redefine(checkauth => 1)
+        ->redefine(requestor => $patron)
+        ->redefine(json_query => [ ]);
+
+    my $utils = Test::MockModule->new('OpenILS::Application::AppUtils')
+        ->redefine(get_org_unit_parent => undef);
+
+    my $auth_response = { payload => { auth_token => 'abc123' } };
+    my $redirect_to = '/mytesteg/opac/home';
+    $apache_capture = undef;
+
+    my $mut = OpenILS::WWW::EGCatLoader->new($apache, { %$ctx });
+    $mut->perform_openathens_sso_if_required($auth_response, $redirect_to);
+
+    is($apache_capture, undef, 'OpenAthens: no OA config: no redirect');
+}
+
+# -----------------------------------------------------------------------------
+# method under test:    perform_openathens_sso_if_required
+#
+# test case:            patron is logged in and their home OU is configured
+#                       to sign in to OpenAthens automatically when logging
+#                       in to Evergreen
+#
+# expected outcome:     issues a redirect to our local OpenAthens sign-on
+#                       handler at <OPAC_ROOT>/sso/openathens
+# -----------------------------------------------------------------------------
+{
+    my $patron = Test::MockObject->new()
+        ->mock(home_ou => sub { return 123; });
+
+    my $oa_config = {
+        active => 1,
+        auto_signon_enabled => 1
+    };
+
+    my $editor = Test::MockModule->new('OpenILS::Utils::CStoreEditor')
+        ->redefine(authtoken => 1)
+        ->redefine(checkauth => 1)
+        ->redefine(requestor => $patron)
+        ->redefine(json_query => [ $oa_config ]);
+
+    my $utils = Test::MockModule->new('OpenILS::Application::AppUtils')
+        ->redefine(get_org_unit_parent => undef);
+
+    my $auth_response = { payload => { auth_token => 'abc123' } };
+    my $redirect_to = '/mytesteg/opac/home';
+
+    $apache_capture = undef;
+    my $mut = OpenILS::WWW::EGCatLoader->new($apache, { %$ctx });
+    my $result =
+        $mut->perform_openathens_sso_if_required($auth_response, $redirect_to);
+
+    my $expected_path = qr/$ctx->{opac_root}\/sso\/openathens/;
+    my $expected_redirect = qr/%2Fmytesteg%2Fopac%2Fhome/;
+    is($result, Apache2::Const::REDIRECT, 'OpenAthens: login: redirects');
+    like($apache_capture, qr/Status: 302/, 'OpenAthens: login: issues 302');
+    like(
+        $apache_capture,
+        qr/Location: ${expected_path}\?redirect_to=${$expected_redirect}/,
+        'OpenAthens: login: correct URL'
+    );
+}
+
+# -----------------------------------------------------------------------------
+# method under test:    perform_openathens_sso_if_required
+#
+# test case:            login has been initiated from an incoming request via
+#                       the OpenAthens handler
+#
+# expected outcome:     does not issue a new redirect, otherwise it would cause
+#                       a redirect loop
+{
+    my $patron = Test::MockObject->new()
+        ->mock(home_ou => sub { return 123; });
+
+    my $oa_config = {
+        active => 1,
+        auto_signon_enabled => 1
+    };
+
+    my $editor = Test::MockModule->new('OpenILS::Utils::CStoreEditor')
+        ->redefine(authtoken => 1)
+        ->redefine(checkauth => 1)
+        ->redefine(requestor => $patron)
+        ->redefine(json_query => [ $oa_config ]);
+
+    my $utils = Test::MockModule->new('OpenILS::Application::AppUtils')
+        ->redefine(get_org_unit_parent => undef);
+
+    my $auth_response = { payload => { auth_token => 'abc123' } };
+    my $redirect_to = '/mytesteg/opac/sso/openathens?returnData=37580gwev';
+
+    $apache_capture = undef;
+    my $mut = OpenILS::WWW::EGCatLoader->new($apache, { %$ctx });
+    my $result =
+        $mut->perform_openathens_sso_if_required($auth_response, $redirect_to);
+
+    is($apache_capture, undef, 'OpenAthens: login: no redirect loop');
+}
+
+# -----------------------------------------------------------------------------
+# method under test:    perform_openathens_signout_if_required
+#
+# test case:            patron is not logged in
+#
+# expected outcome:     does nothing
+# -----------------------------------------------------------------------------
+{
+    my $redirect_to = '/mytesteg/opac/home';
+    $apache_capture = undef;
+
+    my $mut = OpenILS::WWW::EGCatLoader->new($apache, { %$ctx });
+    $mut->perform_openathens_signout_if_required($redirect_to);
+    
+    is($apache_capture, undef, 'OpenAthens: logout, no patron: no redirect');
+}
+
+# -----------------------------------------------------------------------------
+# method under test:    perform_openathens_signout_if_required
+#
+# test case:            patron is logged in but home OU is not configured for
+#                       OpenAthens
+#
+# expected outcome:     does nothing
+# -----------------------------------------------------------------------------
+{
+    my $patron = Test::MockObject->new()
+        ->mock(home_ou => sub { return 123; });
+
+    $ctx->{user} = $patron;
+
+    my $editor = Test::MockModule->new('OpenILS::Utils::CStoreEditor')
+        ->redefine(json_query => [ ]);
+
+    my $utils = Test::MockModule->new('OpenILS::Application::AppUtils')
+        ->redefine(get_org_unit_parent => undef);
+
+    my $redirect_to = '/mytesteg/opac/home';
+    $apache_capture = undef;
+
+    my $mut = OpenILS::WWW::EGCatLoader->new($apache, { %$ctx });
+    $mut->perform_openathens_signout_if_required($redirect_to);
+
+    is($apache_capture, undef, 'OpenAthens: logout no OA config: no redirect');
+}
+
+# -----------------------------------------------------------------------------
+# method under test:    perform_openathens_signout_if_required
+#
+# test case:            patron is logged in and their home OU is configured
+#                       to sign out of OpenAthens automatically when logging
+#                       out of Evergreen
+#
+# expected outcome:     issues a redirect to our local OpenAthens sign-out
+#                       handler at <OPAC_ROOT>/sso/openathens/logout
+# -----------------------------------------------------------------------------
+{
+    my $patron = Test::MockObject->new()
+        ->mock(home_ou => sub { return 123; });
+
+    my $oa_config = {
+        active => 1,
+        auto_signout_enabled => 1
+    };
+
+    $ctx->{user} = $patron;
+
+    my $editor = Test::MockModule->new('OpenILS::Utils::CStoreEditor')
+        ->redefine(json_query => [ $oa_config ]);
+
+    my $utils = Test::MockModule->new('OpenILS::Application::AppUtils')
+        ->redefine(get_org_unit_parent => undef);
+
+    my $redirect_to = '/mytesteg/opac/home';
+
+    $apache_capture = undef;
+    my $mut = OpenILS::WWW::EGCatLoader->new($apache, { %$ctx });
+    my $result =
+        $mut->perform_openathens_signout_if_required($redirect_to);
+
+    my $expected_path = qr/$ctx->{opac_root}\/sso\/openathens\/logout/;
+    my $expected_redirect = qr/%2Fmytesteg%2Fopac%2Fhome/;
+    is($result, Apache2::Const::REDIRECT, 'OpenAthens: logout: redirects');
+    like($apache_capture, qr/Status: 302/, 'OpenAthens: logout: issues 302');
+    like(
+        $apache_capture,
+        qr/Location: ${expected_path}\?redirect_to=${$expected_redirect}/,
+        'OpenAthens: logout: correct URL'
+    );
+}
+
+# -----------------------------------------------------------------------------
+# method under test:    load_openathens_sso - for OPAC_HOME/sso/openathens
+#
+# test case:            1) initiated by Evergreen - ?redirect_to= is present
+#
+# expected outcome:     queries the OpenAthens API to obtain a unique session
+#                       creation URL for the logged in patron, then issues a
+#                       redirect to that URL
+# -----------------------------------------------------------------------------
+{
+    my $patron = Test::MockObject->new()
+        ->mock(id => sub { return 42; })
+        ->mock(home_ou => sub { return 123; });
+
+    my $api_endpoint = 'https://login.openathens.net/api/etc';
+    my $oa_config = {
+        active => 1,
+        auto_signon_enabled => 1,
+        id_field => 'id',
+        dn_field => 'id',
+        connection_uri => $api_endpoint,
+        connection_id => '123456',
+        api_key => 'abc123'
+    };
+
+    $ctx->{user} = $patron;
+
+    my $editor = Test::MockModule->new('OpenILS::Utils::CStoreEditor')
+        ->redefine(json_query => [ $oa_config ]);
+
+    my $utils = Test::MockModule->new('OpenILS::Application::AppUtils')
+        ->redefine(get_org_unit_parent => undef);
+
+    # mock the query string
+    my $redirect_to = '/mytesteg/opac/home';
+    my $cgi = Test::MockModule->new('CGI')
+        ->redefine(param => sub {
+            my $key = @_[1];
+            return $redirect_to if ($key eq 'redirect_to');
+            return undef;
+        });
+
+    # the object we expect to be posted to the OpenAthens API
+    my $expected_api_request = {
+        connectionID => '123456',
+        uniqueUserIdentifier => 42,
+        displayName => 42,
+        attributes => {},
+        returnUrl => 'https://test.org/mytesteg/opac/sso/openathens'
+            . '?redirect_to=%2Fmytesteg%2Fopac%2Fhome'
+    };
+
+    # create a mock OpenAthens API JSON response
+    my $sso_url = 'https://login.openathens.net/account/sso?t=eyj0e';
+    my $openathens_response_body = "{\"sessionInitiatorUrl\":\"$sso_url\"}";
+    my $openathens_response = Test::MockObject->new()
+        ->mock(is_error => sub { return 0; })
+        ->mock(content => sub { return $openathens_response_body; });
+
+    # mock the web request to the API
+    my $http_request_capture;
+    my $async = Test::MockModule->new('HTTP::Async')
+        ->redefine(add => sub {
+            # capture the HTTP request that is built, to check later
+            $http_request_capture = @_[1];
+        })
+        # mock the async behaviour to return our mocked response
+        # without using the network
+        ->redefine(wait_for_next_response => $openathens_response);
+
+    $apache_capture = undef;
+    my $mut = OpenILS::WWW::EGCatLoader->new($apache, { %$ctx });
+    my $result = $mut->load_openathens_sso();
+
+    # check the API HTTP request that was built
+    my $method = $http_request_capture->method;
+    my $uri = $http_request_capture->uri;
+    my $auth_header = $http_request_capture->header('Authorization');
+    my $content_type = $http_request_capture->header('Content-type');
+    my $content = JSON::XS->new->utf8->decode($http_request_capture->content);
+    my $expected_content_type
+        = 'application/vnd.eduserv.iam.auth.localAccountSessionRequest+json';
+    is($method, 'POST', 'OpenAthens: SSO 1: uses POST to API');
+    is($uri, $api_endpoint, 'OpenAthens: SSO 1: posts to correct URI');
+    is($auth_header, 'OAApiKey abc123', 'OpenAthens: SSO 1: uses API key');
+    is($content_type, $expected_content_type, 'OpenAthens: SSO 1: type ok');
+    is_deeply($content, $expected_api_request, 'OpenAthens: SSO 1: data ok');
+
+    # check the resulting redirect
+    my $expected_redirect
+        = qr/https:\/\/login\.openathens\.net\/account\/sso\?t=eyj0e/;
+    is($result, Apache2::Const::REDIRECT, 'OpenAthens: SSO 1: redirects');
+    like($apache_capture, qr/Status: 302/, 'OpenAthens: SSO 1: issues 302');
+    like($apache_capture, $expected_redirect, 'OpenAthens: SSO 1: URL ok');
+}
+
+# -----------------------------------------------------------------------------
+# method under test:    load_openathens_sso - for OPAC_HOME/sso/openathens
+#
+# test case:            2) initiated by OpenAthens - ?returnData= is present
+#
+# expected outcome:     queries the OpenAthens API to obtain a unique session
+#                       creation URL for the logged in patron, then issues a
+#                       redirect to that URL
+# -----------------------------------------------------------------------------
+{
+    my $patron = Test::MockObject->new()
+        ->mock(id => sub { return 42; })
+        ->mock(home_ou => sub { return 123; });
+
+    my $api_endpoint = 'https://login.openathens.net/api/etc';
+    my $oa_config = {
+        active => 1,
+        auto_signon_enabled => 1,
+        id_field => 'id',
+        dn_field => 'id',
+        connection_uri => $api_endpoint,
+        connection_id => '123456',
+        api_key => 'abc123'
+    };
+
+    $ctx->{user} = $patron;
+
+    my $editor = Test::MockModule->new('OpenILS::Utils::CStoreEditor')
+        ->redefine(json_query => [ $oa_config ]);
+
+    my $utils = Test::MockModule->new('OpenILS::Application::AppUtils')
+        ->redefine(get_org_unit_parent => undef);
+
+    # mock the query string
+    my $return_data = 'jk46gubeuvpweb';
+    my $cgi = Test::MockModule->new('CGI')
+        ->redefine(param => sub {
+            my $key = @_[1];
+            return $return_data if ($key eq 'returnData');
+            return undef;
+        });
+
+    # the object we expect to be posted to the OpenAthens API
+    my $expected_api_request = {
+        connectionID => '123456',
+        uniqueUserIdentifier => 42,
+        displayName => 42,
+        attributes => {},
+        returnData => 'jk46gubeuvpweb'
+    };
+
+    # create a mock OpenAthens API JSON response
+    my $sso_url = 'https://login.openathens.net/account/sso?t=eyj0e';
+    my $openathens_response_body = "{\"sessionInitiatorUrl\":\"$sso_url\"}";
+    my $openathens_response = Test::MockObject->new()
+        ->mock(is_error => sub { return 0; })
+        ->mock(content => sub { return $openathens_response_body; });
+
+    # mock the web request to the API
+    my $http_request_capture;
+    my $async = Test::MockModule->new('HTTP::Async')
+        ->redefine(add => sub {
+            # capture the HTTP request that is built, to check later
+            $http_request_capture = @_[1];
+        })
+        # mock the async behaviour to return our mocked response
+        # without using the network
+        ->redefine(wait_for_next_response => $openathens_response);
+
+    $apache_capture = undef;
+    my $mut = OpenILS::WWW::EGCatLoader->new($apache, { %$ctx });
+    my $result = $mut->load_openathens_sso();
+
+    # check the API HTTP request that was built
+    my $method = $http_request_capture->method;
+    my $uri = $http_request_capture->uri;
+    my $auth_header = $http_request_capture->header('Authorization');
+    my $content_type = $http_request_capture->header('Content-type');
+    my $content = JSON::XS->new->utf8->decode($http_request_capture->content);
+    my $expected_content_type
+        = 'application/vnd.eduserv.iam.auth.localAccountSessionRequest+json';
+    is($method, 'POST', 'OpenAthens: SSO 2: uses POST to API');
+    is($uri, $api_endpoint, 'OpenAthens: SSO 2: posts to correct URI');
+    is($auth_header, 'OAApiKey abc123', 'OpenAthens: SSO 2: uses API key');
+    is($content_type, $expected_content_type, 'OpenAthens: SSO 2: type ok');
+    is_deeply($content, $expected_api_request, 'OpenAthens: SSO 2: data ok');
+
+    # check the resulting redirect
+    my $expected_redirect
+        = qr/https:\/\/login\.openathens\.net\/account\/sso\?t=eyj0e/;
+    is($result, Apache2::Const::REDIRECT, 'OpenAthens: SSO 2: redirects');
+    like($apache_capture, qr/Status: 302/, 'OpenAthens: SSO 2: issues 302');
+    like($apache_capture, $expected_redirect, 'OpenAthens: SSO 2: URL ok');
+}
+
+# -----------------------------------------------------------------------------
+# method under test:    load_openathens_sso - for OPAC_HOME/sso/openathens
+#
+# test case:            3) both ?redirect_to and ?returnData= are present
+#
+# expected outcome:     returns 400 status
+# -----------------------------------------------------------------------------
+{
+    # mock the query string
+    my $redirect_to = '/mytesteg/opac/home';
+    my $return_data = 'jk46gubeuvpweb';
+    my $cgi = Test::MockModule->new('CGI')
+        ->redefine(param => sub {
+            my $key = @_[1];
+            return $redirect_to if ($key eq 'redirect_to');
+            return $return_data if ($key eq 'returnData');
+            return undef;
+        });
+
+    $apache_capture = undef;
+    my $mut = OpenILS::WWW::EGCatLoader->new($apache, { %$ctx });
+    my $result = $mut->load_openathens_sso();
+
+    is($result, Apache2::Const::HTTP_BAD_REQUEST, 'OpenAthens: SSO 3: badreq');
+}
+
+# -----------------------------------------------------------------------------
+# method under test:    load_openathens_sso - for OPAC_HOME/sso/openathens
+#
+# test case:            4) neither ?redirect_to or ?returnData= are present
+#
+# expected outcome:     redirects to OPAC home
+# -----------------------------------------------------------------------------
+{
+    # mock the empty query string
+    my $cgi = Test::MockModule->new('CGI')
+        ->redefine(param => sub {
+            return undef;
+        });
+
+    $apache_capture = undef;
+    my $mut = OpenILS::WWW::EGCatLoader->new($apache, { %$ctx });
+    my $result = $mut->load_openathens_sso();
+
+    is($result, Apache2::Const::REDIRECT, 'OpenAthens: SSO 4: redirects');
+    like($apache_capture, qr/Status: 302/, 'OpenAthens: SSO 4: issues 302');
+    like(
+        $apache_capture,
+        qr/Location: \/mytesteg\/opac\/home/,
+        'OpenAthens: SSO 4: redirects to OPAC home'
+    );
+}
+
+# -----------------------------------------------------------------------------
+# method under test:    load_openathens_logout
+#
+# expected outcome:     Issues a redirect to the OpenAthens sign-out URL.
+# -----------------------------------------------------------------------------
+{
+    my $mut = OpenILS::WWW::EGCatLoader->new($apache, { %$ctx });
+    my $result = $mut->load_openathens_logout;
+
+    is($result, Apache2::Const::REDIRECT, 'OpenAthens: logout: redirects');
+    like($apache_capture, qr/Status: 302/, 'OpenAthens: logout: issues 302');
+    like($apache_capture, OA_SIGNOUT_URL, 'OpenAthens: logout: correct URL');
+}
index 626e611..32bac0e 100644 (file)
@@ -1400,4 +1400,55 @@ CREATE TABLE config.ui_staff_portal_page_entry (
     owner       INT NOT NULL -- REFERENCES actor.org_unit (id)
 );
 
+-- Add OpenAthens Integration
+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,
+    release_barcode             BOOL    NOT NULL DEFAULT false
+);
+
 COMMIT;
index 0fe4598..e8de05e 100644 (file)
@@ -278,6 +278,10 @@ ALTER TABLE asset.copy_template ADD CONSTRAINT asset_copy_template_floating_fkey
 ALTER TABLE config.marc_field ADD CONSTRAINT config_marc_field_owner_fkey FOREIGN KEY (owner) REFERENCES actor.org_unit(id) DEFERRABLE INITIALLY DEFERRED;
 ALTER TABLE config.marc_subfield ADD CONSTRAINT config_marc_subfield_owner_fkey FOREIGN KEY (owner) REFERENCES actor.org_unit(id) DEFERRABLE INITIALLY DEFERRED;
 
+ALTER TABLE config.openathens_identity ADD CONSTRAINT config_openathens_identity_ou_fkey
+FOREIGN KEY (org_unit) REFERENCES actor.org_unit (id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED;
+
+
 ALTER TABLE config.copy_tag_type ADD CONSTRAINT copy_tag_type_owner_fkey FOREIGN KEY (owner) REFERENCES  actor.org_unit(id) DEFERRABLE INITIALLY DEFERRED;
 
 ALTER TABLE config.print_template ADD CONSTRAINT cpt_owner_fkey 
index c99fa50..ae6cdd3 100644 (file)
@@ -1970,7 +1970,9 @@ INSERT INTO permission.perm_list ( id, code, description ) VALUES
  ( 637, 'UPLOAD_COVER_IMAGE', oils_i18n_gettext(637,
     'Upload local cover images for added content.', 'ppl', 'description')),
  ( 638, 'RUN_SIMPLE_REPORTS', oils_i18n_gettext(638,
-    'Build and run simple reports', 'ppl', 'description'))
+    'Build and run simple reports', 'ppl', 'description')),
+ ( 639, 'ADMIN_OPENATHENS', oils_i18n_gettext(639,
+    'Allow a user to administer OpenAthens authentication service', 'ppl', 'description'))
 ;
 
 SELECT SETVAL('permission.perm_list_id_seq'::TEXT, 1000);
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..d27dc4f
--- /dev/null
@@ -0,0 +1,60 @@
+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,
+    release_barcode             BOOL    NOT NULL DEFAULT false
+);
+
+
+INSERT INTO permission.perm_list ( id, code, description) VALUES 
+  ( 639, 'ADMIN_OPENATHENS', oils_i18n_gettext(639,
+     'Allow a user to administer OpenAthens authentication service', 'ppl', 'description'));
+
+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..a4a9a2e
--- /dev/null
@@ -0,0 +1,175 @@
+= Configuring sign-on to OpenAthens =
+:toc:
+
+== Purpose ==
+
+If your institution uses OpenAthens, you can configure Evergreen to sign 
+patrons in to OpenAthens using their Evergreen account. This will let them 
+connect to OpenAthens resources seamlessly once they have logged in to 
+Evergreen. Patrons are 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 *Create*.
+  .. 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 *Create*.
+  .. 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). N.B. Evergreen
+  does not support more than one active connection to OpenAthens at a time per 
+  organisation. If more than one connection is added per organisation, 
+  Evergreen will use only the _first_ connection that has *Active* enabled.
+* *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)
+
+|Release barcode
+|barcode
+|the patron's barcode
+|===
+
+Click 'Save' to finish creating the connection. (If you can't see the 
+connection you just created for a branch library, enable the "+ Descendants" 
+option.)
+
+== Network access - server ==
+
+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.
+
+== Network access - web client ==
+
+If you restrict internet access for your web client machines, you need to open
+up port 443 outbound in your firewall, from your web clients to the following
+three domains:
+
+* connect.openathens.net
+* login.openathens.net
+* wayfinder.openathens.net
+
+== Admin permissions ==
+
+To delegate OpenAthens configuration to other staff users, assign the 
+*ADMIN_OPENATHENS* permission.
index 31cbd72..a10e0f8 100644 (file)
@@ -10,6 +10,16 @@ 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.
+ * If you are providing web client machines with restricted internet access, then port 443 should be
+   opened _outbound_ to allow your web clients to connect to https://connect.openathens.net,
+   https://login.openathens.net, and https://wayfinder.openathens.net.
+
 == Web Client Requirements ==
 
 The current stable release of Firefox or Chrome is required to run the web