Purchase links @readino.com: a suggestion
Hi,
In our OPDS 2.0 catalog, we've implemented http://opds-spec.org/acquisition/buy links to support purchases. However, these links currently do not work correctly in Thorium, due to our use of a non-standard step in the purchase flow.
Our goal is to allow users to initiate purchases by clicking the "Buy" button in clients like Thorium or FBReader. The purchase page is hosted on our website but redirects users to a Stripe checkout form. To make this seamless, the user must be authenticated on our site.
Initially, we considered embedding sign-in tokens directly in the feed links. However, this approach is insecure—if the feed content leaks, unauthorized parties could gain access to the user’s account.
Instead, we introduced an intermediate step:
- The feed contains a link to a secured document (available only to authenticated users, served with the MIME type
application/x-short-lived-link+json). - This document includes a short-lived purchase URL.
- The client fetches this document, authenticating if necessary.
- The client then opens the short-lived URL in a browser, where the user is seamlessly taken to the purchase page.
This approach provides both:
- Transparency for the end user (no manual authentication or password entry if the session is valid).
- Security (no long-lived or reusable credentials embedded in the feed).
We’re open to modifying our approach if there's a better way to meet these goals.
The feed with working links (Stripe integration is currently in test mode) is at https://dev.readino.com/api/v1/opds2
Best,
-- Nikolay
Hi Nikolay, how about regenerating a temporary session token (signed or not) for each "buy" link and replacing/invalidating it with a session cookie after the first request in the browser by the user ?
Like this : https://en.wikipedia.org/wiki/Session_fixation#Regenerate_SID_on_each_request
It seems to me enough secure and do not imply to edit the OPDS2 specification.
What do you think ?
What do you think ?
I’d like to describe the following scenario:
- A user receives a URL and opens it in a browser. This means the link will be sent to an external app.
- On some operating systems (e.g., Android), the user may be presented with a choice of external apps to handle the URL — for example, multiple browsers might be installed, and the default one is not defined.
- At this point, the user reconsiders: instead of opening the link in a browser, they decide to share it — say, to post a link to this great book on Facebook.
- As a result, the first person who clicks that shared link gains access to the user’s account.
There are other scenarios where the last opened feed leaks — and anyone can use the link to gain access.
This is why we prefer using short-lived URLs. If a link is only valid for one minute, the window of exposure is minimized and the risk is significantly reduced.
As a side note, we're not suggesting changes to the OPDS spec itself. Our only proposal is to handle links with specific MIME types in a custom way. For example, in FBReader, we already support the non-standard MIME type application/opds-profile+json to enable integration with library feeds.
@HadrienGardeur any thoughts?
One more consideration.
I’m not just concerned about the customer’s security — I’m also worried about the store’s reputation. If someone shares a book link with me that grants access to their account, I won’t misuse it. But I also won’t trust a store that allows such links to exist.
I don't think that an additional media type is necessary for what you're trying to achieve.
OPDS clients can already handle authentication either by:
- receiving a 401 with an Authentication Document
- or by detecting
authenticatein properties (not sure if Thorium Desktop supports that one yet)
Let's say that I encounter a buy link then, it could look like this (I'm dropping the price and indirectAcquisition on purpose to make it shorter):
"links": [
{
"rel": "http://opds-spec.org/acquisition/buy",
"type": "application/opds-publication+json",
"href": "https://example.com//api/v1/purchase/9781119701286/opds/",
"properties": {
"authenticate": "https://example.com/authentication.json"
}
}
]
When following this link, clients would receive a 401 with an Authentication Document, but if they support the authenticate property and know about this specific Authentication Document, they could avoid an unnecessary roundtrip and directly send the proper tokens.
Once authenticated, this would return a link to an HTML page with a one-time use token:
"links": [
{
"rel": "http://opds-spec.org/acquisition/buy",
"type": "application/opds-publication+json",
"href": "https://example.com//api/v1/purchase/9781119701286/html?token=abc123"
}
]
The client could then redirect automatically the user to a browser at that URL.
This is very similar to lending in OPDS where a borrow action returns an OPDS publication which either confirms:
- a hold
- or a loan
Plus to make things extra smoother, you could also include this same authenticate property on the self link of the publication. That way, you'd fetch the OPDS publication to display it to the user and it would already contain the right link to the HTML with a token, as long as the user is already authenticated.
I’m not just concerned about the customer’s security — I’m also worried about the store’s reputation. If someone shares a book link with me that grants access to their account, I won’t misuse it. But I also won’t trust a store that allows such links to exist.
You can make your link both one-time use AND extremely short-lived to handle that concern. Since the HTML link is immediately consumed upon generation, you can make it work for only a minute or so.
The client could then redirect automatically the user to a browser at that URL.
Hi Hadrien, Actually this is not implemented in Thorium-Reader and is not present in the specification AFAIK (https://drafts.opds.io/opds-2.0)
The authenticate property was proposed a while back (https://github.com/opds-community/drafts/discussions/43) and has been used by some catalogs and clients for some time now.
It'll be added to a future revision of the Authentication for OPDS spec along with updated OAuth grants and security best practices.
... but if you were focusing on expected behavior when buying an ebook in OPDS it's very much undefined because of 10+ years of Apple and Google making things impossible on mobile.
Now that we can finally sell content in the US on iOS, it's opening up these discussions all over again.
I don't think that an additional media type is necessary for what you're trying to achieve.
It seems that your approach is essentially the same as ours, just using a different MIME type and response format. I'm not insisting on our variant—we’re still in the development phase, so switching is easy for us.
The client could then redirect automatically the user to a browser at that URL.
That's the key point. If Thorium implements that behaviour, our goal is achieved.
Let me clarify a few points:
"links": [ { "rel": "http://opds-spec.org/acquisition/buy", "type": "application/opds-publication+json", "href": "https://example.com//api/v1/purchase/9781119701286/html?token=abc123" } ]
- For the response format, should it consist only of a single "links" property? Or should we include the metadata as well? Or something else?
- Regarding the
authenticateproperty: is it optional? If the client accesses the link from the main feed and receives a 401 response (along with an authentication document), should the client handle authentication and then restart the process?
It seems that your approach is essentially the same as ours, just using a different MIME type and response format. I'm not insisting on our variant—we’re still in the development phase, so switching is easy for us.
Indirect acquisition through an OPDS publication is how we handle lending, so it makes sense to remain consistent and use it for purchasing as well.
For the response format, should it consist only of a single "links" property? Or should we include the metadata as well? Or something else?
It should be the entire publication.
Some catalogs could skip HTML entirely if they already have a valid credit card saved for the user and directly return the publication that way. For example this could be a link to acquire a DRM free EPUB:
{
"rel": "http://opds-spec.org/acquisition/buy",
"type": "application/opds-publication+json",
"href": "https://example.com/purchase",
"properties": {
"authenticate": "https://example.com/authentication.json",
"indirectAcquisiton": [
{
"type": "application/epub+zip"
}
]
}
}
If they don't have a credit card saved or if it's no longer valid, then they could redirect the user to an HTML page as discussed above.
Also, given the media type (an OPDS publication), it would be invalid to just return a link.
Regarding the
authenticateproperty: is it optional? If the client accesses the link from the main feed and receives a 401 response (along with an authentication document), should the client handle authentication and then restart the process?
It's optional but streamlines things quite a bit.
Instead of GET (OPDS Publication) -> 401 -> GET (OPDS Publication) -> 200 you simply have GET (OPDS Publication) -> 200.
You can also optimize the process by including authenticate on your self link. This way, when the client fetches the OPDS publication prior to a purchase, it could already replace the buy link with a download link if you've already purchased that book.
If I can help in the debate : authenticate property is not planned to be on the Thorium-reader roadmap for the moment, like he said Hadrien there is still discussion about it, so I suggest to you to not use it. Same as The client could then redirect automatically the user to a browser at that URL. it seems controversial and thorium-reader should handle ALL opds feed, even badly implemented opds feed. We cannot do any feed logic on the thorium side, Thorium has a state less discovery process, and doesn't make any interpretation. Added a logic after acquisition buy link could handle infinite loop for example, that means a current state needed, and break the state less rule.
So what I suggest and already implemented in Thorium and should be implemented in either opds clients : it's to add the one time buy link only in the self link opds-publication, and remain a generic (authentication free) link in your opds-feed. So even if the opds-feed leaks and become public, there are no one time buy link available. On the opds client when the user choose a book, the opds-publication from the opds-feed is displayed first and then a update request is made to display the opds-publication self link info. 2 phases : the user click on the publication, then publication Info appears with only the opds-publication from the feed and then after the request made by thorium to the self link opds-publication, the new buy link with the ONE TIME authenticated token is updated to the href buy button. Finally the user can make his purchase on his web browser with his unique one time token info signed by the server with TTL. So there are very little chance, that this authentication token could be stollen. And moreover you can attribute limited right to this authentication token (and then cookie if needed) : for example only buy this specific book and not allow any viewing/editing settings parameter without new authentication requested. We are on the backend realm now, you can implement any secure measure what you want.
Summary :
opds-feed :
{
"metadata": {
"title": "Readino",
},
"links": [
{
"rel": "self",
"href": "/api/v1/opds2",
"type": "application/opds+json"
}
],
"groups": [
{
"metadata": {
"title": "Bestsellers"
},
"links": [
{
"rel": "self",
"type": "application/opds+json",
"href": "/api/v1/opds2/bestsellers"
}
],
"publications": [
{
"links": [
{
"rel": "http://opds-spec.org/acquisition/buy",
"type": "application/opds-publication+json",
"href": "/api/v1/purchase/9781119794615/6/Partner",
}
},
{
"rel": "self",
"type": "application/opds-publication+json",
"href": "/api/v1/opds2/book/9781119794615"
}
],
"metadata": {
"@type": "http://schema.org/Book",
"title": "Advanced Accounting",
"identifier": "urn:isbn:9781119794615",
}
}
]
}]}
The buy link from the opd-feed must be generic and potentially redirect to an opds-publication with the next authenticated buy link. The first buy link from an opds-feed will be in theory never requested by the user, because the opds-publication self link will be requested by thorium-reader before and automatically updated/displayed to the user.
/api/v1/opds2/book/9781119794615:
{
"links": [
{
"rel": "http://opds-spec.org/acquisition/buy",
"type": "text/html",
"href": "/api/v1/purchase/9781119794615/6/Partner?token=userSignedTokenFromTheServerToDoOneTimeBuyAuthentication",
}
},
{
"rel": "self",
"type": "application/opds-publication+json",
"href": "/api/v1/opds2/book/9781119794615"
}
],
"metadata": {
"@type": "http://schema.org/Book",
"title": "Advanced Accounting",
"identifier": "urn:isbn:9781119794615",
}
}
Steps:
1/ user choose bestsellers opds-feed 2/ user choose a publication 3/ publication info modal is displayed to the user with the content of the opds-publication from the opds-feed bestsellers 4/ a request is automatically made to the self link if provided to update the publication info modal 5/ publicationInfo modal is updated with the latest opds-publication information 6/ the user can now click to the buy link button and be authenticated for a time laps specified by the server signed token
I hope this is clear, I do my best to write spontaneously in english with my french thought :)
I will try to make an opds test server if I have time, to prove/test my proposal.
Edit: with this method like Hadrien suggest, you can lay down any opds-feed on CDN/Cache mechanism, It's become just a static file, I guess. It will be good for your saving :)
Indirect acquisition through an OPDS publication is how we handle lending, so it makes sense to remain consistent and use it for purchasing as well.
Agree.
It should be the entire publication.
Ok, we can update our dev catalogue shortly.
Some catalogs could skip HTML entirely if they already have a valid credit card saved for the user and directly return the publication that way.
This case is much more clear; it does not require any action on the client side. This is how borrow links work in the libraries we support at the moment.
If they don't have a credit card saved or if it's no longer valid, then they could redirect the user to an HTML page as discussed above.
This seems to be the most interesting part of the process. As I understand it, the flow is as follows:
- The client performs a
GETrequest to the buy link with typeapplication/opds-publication+json. - The client receives the publication entry, inspects its links, and assumes there will be exactly one link with
buyacquisition type. If this assumption fails, the client treats it as an error. - If that link has a type of
text/html, the client opens it in a browser. Is my understanding correct? Is this behaviour described in any draft document/suggestion? Is there a public catalogue that uses this approach?
As a side note, the approach seems clear to me overall, although returning the entire entry just to convey a single URL feels a bit like a workaround.
If that link has a type of text/html, the client opens it in a browser.
We cannot do that, opds is state less, we are not implementing a finite state machine on the client side. I regret.
If I can help in the debate :
authenticateproperty is not planned to be on the Thorium-reader roadmap for the moment, like he said Hadrien there is still discussion about it, so I suggest to you to not use it. Same asThe client could then redirect automatically the user to a browser at that URL.it seems controversial and thorium-reader should handle ALL opds feed, even badly implemented opds feed. We cannot do any feed logic on the thorium side, Thorium has a state less discovery process, and doesn't make any interpretation. Added a logic after acquisition buy link could handle infinite loop for example, that means a current state needed, and break the state less rule.
Thank you for this clarification. We are not concerned about the authenticate property. Can we do something to add the redirection feature to Thorium?
So what I suggest and already implemented in Thorium and should be implemented in either opds clients : it's to add the one time buy link only in the self link opds-publication, and remain a generic (authentication free) link in your opds-feed. So even if the opds-feed leaks and become public, there are no one time buy link available. On the opds client when the user choose a book, the opds-publication from the opds-feed is displayed first and then a update request is made to display the opds-publication self link info. 2 phases : the user click on the publication, then publication Info appears with only the opds-publication from the feed and then after the request made by thorium to the self link opds-publication, the new buy link with the ONE TIME authenticated token is updated to the href buy button. Finally the user can make his purchase on his web browser with his unique one time token info signed by the server with TTL. So there are very little chance, that this authentication token could be stollen. And moreover you can attribute limited right to this authentication token (and then cookie if needed) : for example only buy this specific book and not allow any viewing/editing settings parameter without new authentication requested. We are on the backend realm now, you can implement any secure measure what you want.
Yes, we can certainly explore something along these lines. Thank you for the detailed explanation of the idea.
Alternatively, we might simply provide an HTML link that requires authorization in the browser. This approach is less user-friendly but avoids compromising on security. However, it introduces a different issue: if the feed includes both an application/opds-publication+json link for purchasing books (which FBReader supports) and a text/html link (supported by Thorium), is there a way to explain to Thorium that the first link should be ignored?
In our current setup, where we use a custom application/x-... type, Thorium still opens the link in the browser, even though it doesn’t recognize the media type.
We cannot do that, opds is state less, we are not implementing a finite state machine on the client side. I regret.
Sorry, I didn’t quite follow—could you please elaborate a bit more?
Just to clarify, my comment was only meant to reflect my understanding of Hadrien’s idea. In this context, “the client” wasn’t referring to Thorium specifically.
opds invented by Hadrien himself is state less like hypermedia can be, breaking this rule imply a finite state machine on the client (a tool to handle the current state). We can obviously break this rule, but it become a common http API and not a standard OPDS1/2 API. It's important because many OPDS implementation has occurs since many years, with this concept. Breaking this concept imply adopting new rule/path/state interpretation on the algorithm in the client (not specifically Thorium), and potentially breaking the opds discoverability feature (aka hypermedia).
EDIT: https://en.wikipedia.org/wiki/HATEOAS
opds invented by Hadrien himself is state less like hypermedia can be, breaking this rule imply a finite state machine on the client
Yes, but I didn't catch what you meant by "breaking the stateless rule" in this context.
Look, It's like a web browser, opds in thorium is implemented like any web browser, regardless of the request, based on the MIME type/content type received. Each new request does not keep track of the previous one. So for example, the buy link, if you choose to return an opds-publication mime-type, the browser will display a feed specific to that publication with a publicationInfo modal. However, the user will click the buy button again. We cannot choose to have a mechanism to click instead of the user, because we would violate the "stateless" rule. (We have to keep a state of the previous request to known that we are specifically on this algorithm/rule and perform an action upon receipt, which could result in an error for the user if another feed implementation does not allow it, etc...
Look, It's like a web browser, opds in thorium is implemented like any web browser, regardless of the request, based on the MIME type/content type received. Each new request does not keep track of the previous one. So for example, the buy link, if you choose to return an
opds-publicationmime-type, the browser will display a feed specific to that publication with a publicationInfo modal. However, the user will click the buy button again. We cannot choose to have a mechanism to click instead of the user, because we would violate the "stateless" rule. (We have to keep a state of the previous request to known that we are specifically on this algorithm/rule and perform an action upon receipt, which could result in an error for the user if another feed implementation does not allow it, etc...
Ah, I think I see now. The issue is that the type of the "first" buy link is application/opds-publication+json, and you're expecting it to be a single-entry OPDS feed. So, my original suggestion (using a custom type with special handling) wouldn't break the rule, correct?
To clarify, I'm not advocating for implementing the original suggestion, only trying to confirm my understanding.
In our current setup, where we use a custom application/x-... type, Thorium still opens the link in the browser, even though it doesn’t recognize the media type.
It's a fallback rule to an unknown mime-type. It will open an external browser window with the href link to try to open it in a generic web browser.
https://github.com/edrlab/thorium-reader/blob/e217af580e265f05160c13a875af54a751f4eb0f/src/renderer/library/opds/handleLink.ts#L57
you're expecting it to be a single-entry OPDS feed
I'm expecting to be what the mime-type is (in this case an opds-publication and not an entire opds-feed) , in thorium internal implementation (specifically in Thorium-reader), we construct a small (fake) opds-feed around the opds-publication, but it's only for our convenience and how is implemented our opds browser (internally it can change without disturbing the OPDS standard).
I hope this answers your questions :) Many thanks for this debate, it's very interesting, to go deeper in the subject.
It's a fallback rule to an unknown mime-type. It will open an external browser window with the href link to try to open it in a generic web browser.
thorium-reader/src/renderer/library/opds/handleLink.ts
Line 57 in e217af5
await shell.openExternal(ln.url);
So, according to the code, there is no way to make our custom type link "invisible" for Thorium? Any chance to add such a feature?
link for purchasing books (which FBReader supports)
How FBReader solve the problem ?
So, according to the code, there is no way to make our custom type link "invisible" for Thorium? Any chance to add such a feature?
we can update the codebase and make a PR to redirect to an external web browser, only content visible mime-type interpretable, like : text/html, application/xhtml+xml ...
How FBReader solve the problem ?
We have still not implemented two buy links, so FBReader doesn't solve the problem at the moment. Most likely, when a book contains two buy links—one with our custom MIME type and one with "text/html"—we’ll implement logic to always prefer the custom type and ignore the "text/html" link.
we can update the codebase and make a PR to redirect to an external web browser, only content visible mime-type interpretable, like :
text/html,application/xhtml+xml...
That would be great for us, but I suspect that might break look of some third-party OPDS in Thorium.
As for FBReader, it ignores links with unknown/unsupported types.
We cannot do that, opds is state less, we are not implementing a finite state machine on the client side. I regret.
I'm not following you at all on that statement @panaC because what I'm describing is very much RESTful:
- progressive discovery through links
- awareness of specific
type,relandpropertieson the client side to provide interactions - the server's response impacts what's displayed to the user
Let's take another example: lending.
- The client encounters a publication that can be borrowed (because it's aware of the
rel) and currently available (as indicated in itsproperties) - The client is aware that this purchasing is neither idempotent nor safe, so it uses POST for that transaction
- Upon a successful request, it receives a 201 code with the publication returned in the response and a download link instead of a borrow link
I don't think it's accurate to say that a client is not stateless if it:
- automatically follows that download link
- and/or indicate to the user that the loan was successful, along with information ("Your loan will expire on August 1st, 2025") along with a download button
For the authenticate hint, I can safely say that this is on the roadmap for Readium Mobile and Thorium Reader on iOS.
We want to move beyond having just a simple OPDS parser in Readium Mobile, which means natively supporting authentication and interactions to make it easier for implementers.
While authenticate is mostly an optimization in the context that we were discussing (buying), it's necessary for serving custom feeds to the user without requiring authentication.
A good example of that would be a customized homepage:
- if you're not authenticated, you see a default selection of books
- whereas if you're authenticated, you'd see custom recommendations and subjects that you're interested in
You can't rely on a 401 response in this case, like you would for interactions (borrowing, purcharsing) or a bookshelf.
So, according to the code, there is no way to make our custom type link "invisible" for Thorium? Any chance to add such a feature?
Links with unknown media types should be ignored by clients, I don't think it's a good idea to still display an action for them.
For example, you could have:
- a single buy link for a book
- but that allows you to download the book as either LCP or ACS protected
In this case, it wouldn't make any sense to display two download links if you only support LCP.
You can't rely on a 401 response in this case, like you would for interactions (borrowing, purcharsing) or a bookshelf.
What about cookie like any web browser ? We do support cookies in Thorium since some (old) opds feed require them.
We cannot do that, opds is state less, we are not implementing a finite state machine on the client side. I regret.
I'm not following you at all on that statement @panaC because what I'm describing is very much RESTful:
- progressive discovery through links
- awareness of specific
type,relandpropertieson the client side to provide interactions- the server's response impacts what's displayed to the user
Let's take another example: lending.
- The client encounters a publication that can be borrowed (because it's aware of the
rel) and currently available (as indicated in itsproperties)- The client is aware that this purchasing is neither idempotent nor safe, so it uses POST for that transaction
- Upon a successful request, it receives a 201 code with the publication returned in the response and a download link instead of a borrow link
I don't think it's accurate to say that a client is not stateless if it:
- automatically follows that download link
- and/or indicate to the user that the loan was successful, along with information ("Your loan will expire on August 1st, 2025") along with a download button
What you said, if I follow you, is in fact totally "stateless" indeed and respect HATEOAS principle. What we cannot do (as the previous example discussion) is to automatically simulate a click to the download button from a previous action that returns an opds-publication. I'm really sorry if my writting is not clear, but i think we are on the line together, many thanks for your response Hadrien 🙏
EDIT: We can borrow it, but we should not force the user to download it, until he asks for it. Just we cannot "automatically follows that download link" do this
Links with unknown media types should be ignored by clients, I don't think it's a good idea to still display an action for them.
Let's improve that !
In this case, it wouldn't make any sense to display two download links if you only support LCP.
This is already the case for thorium-reader, this discussion is mainly about the "buy" button, I think.
EDIT: borrow link always display a button on unknown content-type, except for Adobe or the button is disabled https://github.com/edrlab/thorium-reader/blob/27a3ce93b5f7fab60cca21a97dcb9667b1657bca/src/renderer/library/components/dialog/publicationInfos/opdsControls/OpdsControls.tsx#L197