Skip to content

Include loanToken and licenseServices when authorizing a tethered eReader (fixes loaned books on Kobo) - #165

Open
Alexandre-Lessard wants to merge 2 commits into
Leseratte10:masterfrom
Alexandre-Lessard:fix-loaned-book-ereader-authorization
Open

Include loanToken and licenseServices when authorizing a tethered eReader (fixes loaned books on Kobo)#165
Alexandre-Lessard wants to merge 2 commits into
Leseratte10:masterfrom
Alexandre-Lessard:fix-loaned-book-ereader-authorization

Conversation

@Alexandre-Lessard

@Alexandre-Lessard Alexandre-Lessard commented Jul 24, 2026

Copy link
Copy Markdown

Problem

Authorize eReader over USB creates a valid device authorization, but
library loans can remain locked. On a Kobo, the error is:

Your eReader is not authorized to open this book

Cause and fix

Adding the operator's signed loanToken and licenseServices to the reader's
activation.xml made the tested loans open; removing them made the same books
stop opening. exportProxyAuth() previously wrote neither element.

The plugin already receives the token during fulfill(), but discarded it.
The patch now:

  • stores one signed token per operatorURL in the account's activation.xml;
  • replaces only the older token from the same operator; and
  • exports all stored tokens and licenseServices to the reader.

The license files suggest that loan permissions require this signed token, but
that explanation is inferred; the reader's internal behavior was not measured.

Multiple operators

The first version kept only one token. Testing confirmed that adding a second
operator could make earlier loans stop opening after re-authorization.

This can happen on one library platform: two books borrowed through the same
prêtnumérique.ca account used depot2.sogides.com and
acs4.edenlivres.fr. The revised version therefore keeps both tokens.

Testing

tests/main.py now passes 24 tests (up from 22):

  • test_tetheredDeviceAuthorizationIncludesLoanData checks that the exported
    authorization contains loanToken and licenseServices, and remains valid
    for an account with no loans.
  • test_loanTokenStorageIsPerOperator checks that adding a second operator
    does not evict the first.

Both fail on the pre-PR code, with Loan tokens missing from the device authorization
and Second operator's loan token evicted the first operator's respectively.
They require no reader, Adobe account, or network access.

I also ran the hardware checks below manually through the calibre 9.12 GUI on
a Kobo Libra Colour. No authorization files were hand-edited and no scripts
transferred books to the reader. The test set contained five active PDF loans
from two operators.

  • A new same-operator loan failed before refresh and opened afterward.
  • The first version reproduced the two-operator regression.
  • With the fix, four loans across two operators opened.
  • A fifth loan replaced only its operator's token; all five opened.
  • A purchase fulfillment did not disturb the five loan authorizations.

Each operator's token is cumulative, so one refresh after importing several
loans should cover them all. That exact batch sequence was not tested.

@bjmc also confirmed the core fix independently on a Kobo Clara 2E with an
OverDrive loan.

Not tested

  • Purchased ADEPT books after this change. An Adobe sample was inconclusive.
  • The proposed ADE manual-copy comparison.
  • EPUB loans in my tests; @bjmc did not report the format of their loan.
  • Older RMSDK 9.x Kobo devices.

Follow-up

Newly imported loans still require a manual reader refresh before they open.
A lighter refresh during Send to device would be a better workflow, but
belongs in a separate PR after this storage design is reviewed.

@Leseratte10

Copy link
Copy Markdown
Owner

Interesting finding, thanks for the pull request. I haven't really used this "authorize through USB" much, so I didn't know that.

So you are saying that each time a new book is loaned from a library, that loan data needs to be refreshed on the Reader? Or just the first time any book is loaned from a particular library?

Did you test if that's the same for ADE? What happens if you authorize your reader with ADE, disconnect the reader, get a book from a library, fulfill it through ADE, completely close/shutdown ADE, then plug in the reader and copy over the EPUB/PDF file through a file explorer? Would that also fail?

Also, your code seems to just take whatever loanToken has been returned from the fulfillment request, and stores that inside the loantoken.xml. What happens if you have different loans from multiple different operators? I don't see any code that merges that data so right now that would result in only some loans working on the eReader, or am I missing something?

@Alexandre-Lessard

Copy link
Copy Markdown
Author

Thanks for the careful review — you've found real gaps.

Multiple operators: you're right, that's a bug. fulfill() overwrites loantoken.xml on each fulfillment instead of merging, so a second library's loans would clobber the first. My two test loans were from the same operator, so I never hit it. I'll rework it to keep one loanToken per operator and write them all into the activation.

How often it needs refreshing: honestly, my testing was thin — two PDF loans, one operator, one device (Kobo Libra Colour). I inferred the "re-authorize after each new loan" behavior rather than testing that exact sequence, and I stated it too firmly in the note. That was an overreach on my part; I'll verify it properly instead of leaving an assumption in the PR.

ADE comparison: haven't tested it — I'll run the test you described. My working hypothesis is that ADE refreshes the loanToken during its device-transfer step (the plugin doesn't hook calibre's send-to-device, so I did it at authorize-time instead), but that's a guess until I check.

I'm without the reader for a bit, so the rework + retesting will take some time. I'll move this to a draft so it's clearly not merge-ready, and come back with actual results. Thanks again.

@Alexandre-Lessard
Alexandre-Lessard marked this pull request as draft July 24, 2026 19:27
@Leseratte10

Copy link
Copy Markdown
Owner

Also, please confirm if you've used a LLM to write this fix, and if so, if it's actually been tested by a human. Particularly your last comment sounds an awful lot like LLM-generated text.

@bjmc

bjmc commented Aug 1, 2026

Copy link
Copy Markdown

I believe I am running into this same problem - also with a Kobo device. I'm happy to act as a human tester for this fix.

@bjmc

bjmc commented Aug 1, 2026

Copy link
Copy Markdown

fulfill it through ADE, completely close/shutdown ADE, then plug in the reader and copy over the EPUB/PDF file through a file explorer? Would that also fail?

I don't have a working ADE at the moment, but in my past experience, yes that's the case: Library books need to be loaded onto the eReader via Digital Editions, not just copied as a file.

@bjmc

bjmc commented Aug 1, 2026

Copy link
Copy Markdown

I've just tested and can confirm this fix makes it possible to check out a library book from Overdrive, fulfill the ACSM using Calibre, transfer the book and read it on a Kobo Clara 2E running Kobo software version 2.38.23697 (2026-05-25 update)

@Alexandre-Lessard

Copy link
Copy Markdown
Author

I've just tested and can confirm this fix makes it possible to check out a library book from Overdrive, fulfill the ACSM using Calibre, transfer the book and read it on a Kobo Clara 2E running Kobo software version 2.38.23697 (2026-05-25 update)

Nice! Thanks for your feedback.
I've been a little busy last week, but I'll try to address the missing fixes @Leseratte10 Have mentionned.

P.S. You are right, @Leseratte10. It's written by an LLM but backed by human supervision and testing. I'll make sure to stay transparent on the test I made myself and what has been tested by the machine, haha.

@bjmc

bjmc commented Aug 1, 2026

Copy link
Copy Markdown

@Alexandre-Lessard let me know if you want me to test two ebooks checked out from two different libraries. I assume that because they are both using the Overdrive platform authenticated with the same Adobe ID that they will share the same loan tokens and not clobber one another, but worth double checking. I know with ADE I have had books out from different libraries at the same time.

IMHO the bigger pain point is this...

borrowing a new book later currently requires re-running "Authorize eReader
over USB" (which refreshes the token)

... it will quickly get irritating having to re-authorize the reader every time you borrow a new book. I don't know anything about Calibre plugin development but if we can hook into the "Send to device..." operation and push a fresh loan token along with the .epub, that seems like a much more natural workflow.

# "Your eReader is not authorized to open this book") even though the device is authorized.
# The loanToken returned by the operator during fulfillment covers all of the user's
# currently-active loans; fulfill() stores it as "loantoken.xml" next to the account data.
def _embed_adept(node):

@bjmc bjmc Aug 1, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick but instead of doing raw string manipulation with regex, if we need to remove this adobe adept namespace (are we sure we do? why?) I think it would be cleaner to make the changes as XML and keep working with the data as XML.

AI suggested this but I haven't verified it and it might be overkill:

def _embed_adept(node):
    ADEPT_NS = "http://ns.adobe.com/adept"
    
    # Find all elements with the Adobe namespace
    for elem in node.xpath(f'.//*[namespace-uri()="{ADEPT_NS}"]'):
        # Replace the tag with its local name
        elem.tag = etree.QName(elem).localname
    
    # Clean up namespace declarations for the Adobe namespace
    for elem in node.iter():
        if elem.nsmap:
            # Remove the Adobe namespace from the mapping
            elem.nsmap = {k: v for k, v in elem.nsmap.items() if v != ADEPT_NS}
    
    return node

@bjmc bjmc left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very happy to see this fix included in some form. 👍

…ader

A device authorization (activationToken) on its own is enough to open
*purchased* ADEPT books on a tethered eReader, but a library LOAN also
requires a signed loanToken (bound to user+loan, not to a device) and the
operator's licenseServices to be present in the device's activation.xml.
Without them the reader refuses loaned content even though the device is
correctly authorized -- e.g. a Kobo shows "Your eReader is not authorized
to open this book".

exportProxyAuth() previously wrote only the activationServiceInfo,
credentials and activationToken, so loaned books never opened on a device
authorized through "Authorize eReader over USB".

- fulfill(): persist the loanToken returned by the operator (it covers all
  of the user's currently-active loans) to "loantoken.xml" next to the
  account data, instead of discarding it.
- exportProxyAuth(): embed that loanToken and the account's licenseServices
  into the eReader's activation.xml.

Tested end-to-end on a factory-reset Kobo Libra Colour (fresh device
fingerprint): authorizing through the plugin now lets loaned library PDFs
open on the device, with no Adobe Digital Editions involved. Only that
model was verified; other current-generation Kobo models are expected to
behave the same (standard Adobe RMSDK), older RMSDK 9.x generations
unverified.
Each operator signs its own loanToken, and that token only covers the
loans that operator issued. Keeping just the most recent one meant a
fulfillment from a second operator silently dropped the first operator's
token, so the next "Authorize eReader over USB" wrote an activation that
no longer covered the earlier loans - books that had been working stopped
opening.

This is not an exotic case: a single library platform can route to
different operators depending on the book's distributor, so it happens
with one library card on one website.

Loan tokens now live in the account's activation.xml, next to
operatorURLList and licenseServices, which are already per-operator
collections maintained the same way. They are keyed by operatorURL, and
because a loanToken is a signed blob it is stored verbatim and never
edited: a newer token from an operator replaces that operator's entry and
leaves the others alone. There is no expiry field in a loanToken, so
superseding by operator is the only workable strategy.

Also adds test coverage for the tethered-device authorization, which had
none: one test checks that the authorization written to the device
carries the loan tokens and licenseServices a loaned book needs (and that
an account with no loans still produces a valid authorization), and one
checks that storing a second operator's token doesn't evict the first.
@Alexandre-Lessard
Alexandre-Lessard force-pushed the fix-loaned-book-ereader-authorization branch from 4b6cf17 to dc0c806 Compare August 3, 2026 15:11
@Alexandre-Lessard
Alexandre-Lessard marked this pull request as ready for review August 3, 2026 15:12
@Alexandre-Lessard

Copy link
Copy Markdown
Author

Thanks @Leseratte10 and @bjmc. I have updated the patch and PR description.

@Leseratte10, you were right about the multi-operator bug. The patch now keeps
one token per operator. Two offline regression tests cover exported loan data
and per-operator storage; both fail on the old code and pass here. Batching
several new loans into one refresh should work with cumulative tokens, but
remains untested. The ADE comparison is also still untested.

I also rebased onto the current master, which now runs CI for pull requests,
including Python 2.7; I could not test that version locally.

@bjmc, thanks for the independent Clara 2E test. I reproduced the multi-operator
case, so no extra two-library test is needed right now. The rebase will mark
your inline review as outdated; that is only a diff artifact. Since lxml's
nsmap is read-only, the suggested snippet cannot be used as written, so I
kept the file's existing approach. I agree that a Send to device refresh
should be handled separately.

With the bug fixed and the remaining gaps documented, I am taking the PR out
of draft.

LLM disclosure: The code and this comment were written with LLM assistance. I
reviewed this message, and all my reported tests were run manually.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants