def test_decode_short_client_token_from_another_library(self): # Here's the AuthdataUtility used by another library. foreign_authdata = AuthdataUtility( vendor_id = "The Vendor ID", library_uri = "http://your-library.org/", library_short_name = "you", secret = "Your library secret", ) patron_identifier = "Patron identifier" vendor_id, token = foreign_authdata.encode_short_client_token( patron_identifier ) # Because we know the other library's secret, we're able to # decode the authdata. decoded = self.authdata.decode_short_client_token(token) eq_(("http://your-library.org/", "Patron identifier"), decoded) # If our secret for a library doesn't match the other # library's short token signing key, we can't decode the # authdata. foreign_authdata.short_token_signing_key = 'A new secret' vendor_id, token = foreign_authdata.encode_short_client_token( patron_identifier ) assert_raises_regexp( ValueError, "Invalid signature for", self.authdata.decode_short_client_token, token )
class TestAuthdataUtility(VendorIDTest): def setup(self): super(TestAuthdataUtility, self).setup() self.authdata = AuthdataUtility( vendor_id = "The Vendor ID", library_uri = "http://my-library.org/", library_short_name = "MyLibrary", secret = "My library secret", other_libraries = { "http://your-library.org/": ("you", "Your library secret") }, ) def test_from_config(self): library = self._default_library library2 = self._library() self.initialize_adobe(library, [library2]) library_url = library.setting(Configuration.WEBSITE_URL).value library2_url = library2.setting(Configuration.WEBSITE_URL).value utility = AuthdataUtility.from_config(library) registry = ExternalIntegration.lookup( self._db, ExternalIntegration.OPDS_REGISTRATION, ExternalIntegration.DISCOVERY_GOAL, library=library ) eq_(library.short_name + "token", ConfigurationSetting.for_library_and_externalintegration( self._db, ExternalIntegration.USERNAME, library, registry).value) eq_(library.short_name + " token secret", ConfigurationSetting.for_library_and_externalintegration( self._db, ExternalIntegration.PASSWORD, library, registry).value) eq_(self.TEST_VENDOR_ID, utility.vendor_id) eq_(library_url, utility.library_uri) eq_( {library2_url : "%s token secret" % library2.short_name, library_url : "%s token secret" % library.short_name}, utility.secrets_by_library_uri ) eq_( {"%sTOKEN" % library.short_name.upper() : library_url, "%sTOKEN" % library2.short_name.upper() : library2_url }, utility.library_uris_by_short_name ) # If an integration is set up but incomplete, from_config # raises CannotLoadConfiguration. setting = ConfigurationSetting.for_library_and_externalintegration( self._db, ExternalIntegration.USERNAME, library, registry) old_short_name = setting.value setting.value = None assert_raises( CannotLoadConfiguration, AuthdataUtility.from_config, library ) setting.value = old_short_name setting = library.setting(Configuration.WEBSITE_URL) old_value = setting.value setting.value = None assert_raises( CannotLoadConfiguration, AuthdataUtility.from_config, library ) setting.value = old_value setting = ConfigurationSetting.for_library_and_externalintegration( self._db, ExternalIntegration.PASSWORD, library, registry) old_secret = setting.value setting.value = None assert_raises( CannotLoadConfiguration, AuthdataUtility.from_config, library ) setting.value = old_secret # If other libraries are not configured, that's fine. We'll # only have a configuration for ourselves. self.adobe_vendor_id.set_setting( AuthdataUtility.OTHER_LIBRARIES_KEY, None ) authdata = AuthdataUtility.from_config(library) eq_({library_url : "%s token secret" % library.short_name}, authdata.secrets_by_library_uri) eq_({"%sTOKEN" % library.short_name.upper(): library_url}, authdata.library_uris_by_short_name) # Short library names are case-insensitive. If the # configuration has the same library short name twice, you # can't create an AuthdataUtility. self.adobe_vendor_id.set_setting( AuthdataUtility.OTHER_LIBRARIES_KEY, json.dumps({ "http://a/" : ("a", "secret1"), "http://b/" : ("A", "secret2"), }) ) assert_raises(ValueError, AuthdataUtility.from_config, library) # If there is no Adobe Vendor ID integration set up, # from_config() returns None. self._db.delete(registry) eq_(None, AuthdataUtility.from_config(library)) def test_decode_round_trip(self): patron_identifier = "Patron identifier" vendor_id, authdata = self.authdata.encode(patron_identifier) eq_("The Vendor ID", vendor_id) # We can decode the authdata with our secret. decoded = self.authdata.decode(authdata) eq_(("http://my-library.org/", "Patron identifier"), decoded) def test_decode_round_trip_with_intermediate_mischief(self): patron_identifier = "Patron identifier" vendor_id, authdata = self.authdata.encode(patron_identifier) eq_("The Vendor ID", vendor_id) # A mischievious party in the middle decodes our authdata # without telling us. authdata = base64.decodestring(authdata) # But it still works. decoded = self.authdata.decode(authdata) eq_(("http://my-library.org/", "Patron identifier"), decoded) def test_encode(self): """Test that _encode gives a known value with known input.""" patron_identifier = "Patron identifier" now = datetime.datetime(2016, 1, 1, 12, 0, 0) expires = datetime.datetime(2018, 1, 1, 12, 0, 0) authdata = self.authdata._encode( self.authdata.library_uri, patron_identifier, now, expires ) eq_( base64.encodestring('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwOi8vbXktbGlicmFyeS5vcmcvIiwiaWF0IjoxNDUxNjQ5NjAwLjAsInN1YiI6IlBhdHJvbiBpZGVudGlmaWVyIiwiZXhwIjoxNTE0ODA4MDAwLjB9.n7VRVv3gIyLmNxTzNRTEfCdjoky0T0a1Jhehcag1oQw'), authdata ) def test_decode_from_another_library(self): # Here's the AuthdataUtility used by another library. foreign_authdata = AuthdataUtility( vendor_id = "The Vendor ID", library_uri = "http://your-library.org/", library_short_name = "you", secret = "Your library secret", ) patron_identifier = "Patron identifier" vendor_id, authdata = foreign_authdata.encode(patron_identifier) # Because we know the other library's secret, we're able to # decode the authdata. decoded = self.authdata.decode(authdata) eq_(("http://your-library.org/", "Patron identifier"), decoded) # If our secret doesn't match the other library's secret, # we can't decode the authdata foreign_authdata.secret = 'A new secret' vendor_id, authdata = foreign_authdata.encode(patron_identifier) assert_raises_regexp( DecodeError, "Signature verification failed", self.authdata.decode, authdata ) def test_decode_from_unknown_library_fails(self): # Here's the AuthdataUtility used by a library we don't know # about. foreign_authdata = AuthdataUtility( vendor_id = "The Vendor ID", library_uri = "http://some-other-library.org/", library_short_name = "SomeOther", secret = "Some other library secret", ) vendor_id, authdata = foreign_authdata.encode("A patron") # They can encode, but we cna't decode. assert_raises_regexp( DecodeError, "Unknown library: http://some-other-library.org/", self.authdata.decode, authdata ) def test_cannot_decode_token_from_future(self): future = datetime.datetime.utcnow() + datetime.timedelta(days=365) authdata = self.authdata._encode( "Patron identifier", iat=future ) assert_raises( InvalidIssuedAtError, self.authdata.decode, authdata ) def test_cannot_decode_expired_token(self): expires = datetime.datetime(2016, 1, 1, 12, 0, 0) authdata = self.authdata._encode( "Patron identifier", exp=expires ) assert_raises( ExpiredSignatureError, self.authdata.decode, authdata ) def test_cannot_encode_null_patron_identifier(self): assert_raises_regexp( ValueError, "No patron identifier specified", self.authdata.encode, None ) def test_cannot_decode_null_patron_identifier(self): authdata = self.authdata._encode( self.authdata.library_uri, None, ) assert_raises_regexp( DecodeError, "No subject specified", self.authdata.decode, authdata ) def test_short_client_token_round_trip(self): """Encoding a token and immediately decoding it gives the expected result. """ vendor_id, token = self.authdata.encode_short_client_token("a patron") eq_(self.authdata.vendor_id, vendor_id) library_uri, patron = self.authdata.decode_short_client_token(token) eq_(self.authdata.library_uri, library_uri) eq_("a patron", patron) def test_short_client_token_encode_known_value(self): """Verify that the encoding algorithm gives a known value on known input. """ value = self.authdata._encode_short_client_token( "a library", "a patron identifier", 1234.5 ) # Note the colon characters that replaced the plus signs in # what would otherwise be normal base64 text. Similarly for # the semicolon which replaced the slash, and the at sign which # replaced the equals sign. eq_('a library|1234.5|a patron identifier|YoNGn7f38mF531KSWJ;o1H0Z3chbC:uTE:t7pAwqYxM@', value ) # Dissect the known value to show how it works. token, signature = value.rsplit("|", 1) # Signature is base64-encoded in a custom way that avoids # triggering an Adobe bug ; token is not. signature = AuthdataUtility.adobe_base64_decode(signature) # The token comes from the library name, the patron identifier, # and the time of creation. eq_("a library|1234.5|a patron identifier", token) # The signature comes from signing the token with the # secret associated with this library. expect_signature = self.authdata.short_token_signer.sign( token, self.authdata.short_token_signing_key ) eq_(expect_signature, signature) def test_decode_short_client_token_from_another_library(self): # Here's the AuthdataUtility used by another library. foreign_authdata = AuthdataUtility( vendor_id = "The Vendor ID", library_uri = "http://your-library.org/", library_short_name = "you", secret = "Your library secret", ) patron_identifier = "Patron identifier" vendor_id, token = foreign_authdata.encode_short_client_token( patron_identifier ) # Because we know the other library's secret, we're able to # decode the authdata. decoded = self.authdata.decode_short_client_token(token) eq_(("http://your-library.org/", "Patron identifier"), decoded) # If our secret for a library doesn't match the other # library's short token signing key, we can't decode the # authdata. foreign_authdata.short_token_signing_key = 'A new secret' vendor_id, token = foreign_authdata.encode_short_client_token( patron_identifier ) assert_raises_regexp( ValueError, "Invalid signature for", self.authdata.decode_short_client_token, token ) def test_decode_client_token_errors(self): """Test various token errors""" m = self.authdata._decode_short_client_token # A token has to contain at least two pipe characters. assert_raises_regexp( ValueError, "Invalid client token", m, "foo|", "signature" ) # The expiration time must be numeric. assert_raises_regexp( ValueError, 'Expiration time "a time" is not numeric', m, "library|a time|patron", "signature" ) # The patron identifier must not be blank. assert_raises_regexp( ValueError, 'Token library|1234| has empty patron identifier', m, "library|1234|", "signature" ) # The library must be a known one. assert_raises_regexp( ValueError, 'I don\'t know how to handle tokens from library "LIBRARY"', m, "library|1234|patron", "signature" ) # We must have the shared secret for the given library. self.authdata.library_uris_by_short_name['LIBRARY'] = 'http://a-library.com/' assert_raises_regexp( ValueError, 'I don\'t know the secret for library http://a-library.com/', m, "library|1234|patron", "signature" ) # The token must not have expired. assert_raises_regexp( ValueError, 'Token mylibrary|1234|patron expired at 1970-01-01 00:20:34', m, "mylibrary|1234|patron", "signature" ) # Finally, the signature must be valid. assert_raises_regexp( ValueError, 'Invalid signature for', m, "mylibrary|99999999999|patron", "signature" ) def test_adobe_base64_encode_decode(self): """Test our special variant of base64 encoding designed to avoid triggering an Adobe bug. """ value = "!\tFN6~'Es52?X!#)Z*_S" encoded = AuthdataUtility.adobe_base64_encode(value) eq_('IQlGTjZ:J0VzNTI;WCEjKVoqX1M@', encoded) # This is like normal base64 encoding, but with a colon # replacing the plus character, a semicolon replacing the # slash, an at sign replacing the equal sign and the final # newline stripped. eq_( encoded.replace(":", "+").replace(";", "/").replace("@", "=") + "\n", base64.encodestring(value) ) # We can reverse the encoding to get the original value. eq_(value, AuthdataUtility.adobe_base64_decode(encoded)) def test__encode_short_client_token_uses_adobe_base64_encoding(self): class MockSigner(object): def sign(self, value, key): """Always return the same signature, crafted to contain a plus sign, a slash and an equal sign when base64-encoded. """ return "!\tFN6~'Es52?X!#)Z*_S" self.authdata.short_token_signer = MockSigner() token = self.authdata._encode_short_client_token("lib", "1234", 0) # The signature part of the token has been encoded with our # custom encoding, not vanilla base64. eq_('lib|0|1234|IQlGTjZ:J0VzNTI;WCEjKVoqX1M@', token) def test_decode_two_part_short_client_token_uses_adobe_base64_encoding(self): # The base64 encoding of this signature has a plus sign in it. signature = 'LbU}66%\\-4zt>R>_)\n2Q' encoded_signature = AuthdataUtility.adobe_base64_encode(signature) # We replace the plus sign with a colon. assert ':' in encoded_signature assert '+' not in encoded_signature # Make sure that decode_two_part_short_client_token properly # reverses that change when decoding the 'password'. class MockAuthdataUtility(AuthdataUtility): def _decode_short_client_token(self, token, supposed_signature): eq_(supposed_signature, signature) self.test_code_ran = True utility = MockAuthdataUtility( vendor_id = "The Vendor ID", library_uri = "http://your-library.org/", library_short_name = "you", secret = "Your library secret", ) utility.test_code_ran = False utility.decode_two_part_short_client_token( "username", encoded_signature ) # The code in _decode_short_client_token ran. Since there was no # test failure, it ran successfully. eq_(True, utility.test_code_ran) # Tests of code that is used only in a migration script. This can # be deleted once # 20161102-adobe-id-is-delegated-patron-identifier.py is run on # all affected instances. def test_migrate_adobe_id_noop(self): patron = self._patron() self.authdata.migrate_adobe_id(patron) # Since the patron has no adobe ID, nothing happens. eq_([], patron.credentials) eq_([], self._db.query(DelegatedPatronIdentifier).all()) def test_migrate_adobe_id_success(self): from api.opds import CirculationManagerAnnotator patron = self._patron() # This patron has a Credential containing their Adobe ID data_source = DataSource.lookup(self._db, DataSource.ADOBE) adobe_id = Credential( patron=patron, data_source=data_source, type=AdobeVendorIDModel.VENDOR_ID_UUID_TOKEN_TYPE, credential="My Adobe ID" ) # Run the migration. new_credential, delegated_identifier = self.authdata.migrate_adobe_id(patron) # The patron now has _two_ Credentials -- the old one # containing the Adobe ID, and a new one. eq_(set([new_credential, adobe_id]), set(patron.credentials)) # The new credential contains an anonymized patron identifier # used solely to connect the patron to their Adobe ID. eq_(AuthdataUtility.ADOBE_ACCOUNT_ID_PATRON_IDENTIFIER, new_credential.type) # We can use that identifier to look up a DelegatedPatronIdentifier # def explode(): # This method won't be called because the # DelegatedPatronIdentifier already exists. raise Exception() identifier, is_new = DelegatedPatronIdentifier.get_one_or_create( self._db, self.authdata.library_uri, new_credential.credential, DelegatedPatronIdentifier.ADOBE_ACCOUNT_ID, explode ) eq_(delegated_identifier, identifier) eq_(False, is_new) eq_("My Adobe ID", identifier.delegated_identifier) # An integration-level test: # AdobeVendorIDModel.to_delegated_patron_identifier_uuid works # now. model = AdobeVendorIDModel(self._default_library, None, None) uuid, label = model.to_delegated_patron_identifier_uuid( self.authdata.library_uri, new_credential.credential ) eq_("My Adobe ID", uuid) eq_('Delegated account ID My Adobe ID', label) # If we run the migration again, nothing new happens. new_credential_2, delegated_identifier_2 = self.authdata.migrate_adobe_id(patron) eq_(new_credential, new_credential_2) eq_(delegated_identifier, delegated_identifier_2) eq_(2, len(patron.credentials)) uuid, label = model.to_delegated_patron_identifier_uuid( self.authdata.library_uri, new_credential.credential ) eq_("My Adobe ID", uuid) eq_('Delegated account ID My Adobe ID', label)