Lenny needs to be a real OAuth 2.0 authorization server so Open Library (and any other consumer) can act on a patron's behalf — read their loans, borrow for them — without either side holding credentials it should not have.
This came out of a design discussion with Mek on 2026-09-04. Full reasoning in docs/OL_BORROW_HANDOFF.md.
The central problem
Lenny stores "logged in" as a cookie on lennyforlibraries.org. Open Library cannot POST to Lenny and receive that cookie — Set-Cookie would land on OL's HTTP client, not the patron's browser, and the cookie is Domain-scoped, HttpOnly, SameSite=Lax. So OL cannot obtain a session it can use to answer "what does this patron have on loan?"
Every workaround we considered was worse than the standard:
- Forward the patron's IA S3 keys to Lenny — expands the blast radius of a credential that is not scoped to lending. A MITM or a Lenny compromise leaks the patron's general archive.org API credential.
- A service key that reads any patron's loans — one leak exposes every patron's reading history. Bulk-extraction credential.
- OL signs assertions as an identity provider — at one node this is tempting; across many Lenny nodes it is the worst option, because every node would trust one OL key and a single compromise becomes a federation-wide breach.
- Lenny POSTs a token into the patron's OL account — unsolicited push. OL cannot verify the patron consented, nothing correlates the push to a request OL made, and at N nodes every node can write into any patron's account.
The canonical answer
Lenny is the Authorization Server and Resource Server. Open Library is a confidential client. Authorization Code flow with PKCE, plus refresh tokens.
First authorization:
- OL redirects the browser to Lenny's
/oauth/authorize with client_id, redirect_uri, response_type=code, scope, state, code_challenge
- Patron authenticates (existing OTP flow) and consents
- Lenny redirects back with
?code=…&state=…
- OL's server exchanges the code at
/oauth/token with its code_verifier → access_token + refresh_token
Every borrow after that needs no redirect and no OTP — OL already holds the tokens.
Why this is the right shape:
- No bulk key. Every token is per-patron, granted by that patron.
- No S3 keys cross the boundary.
- The token never appears in a URL — the exchange is back-channel, which is what makes an authorization code safe to put in a redirect.
- Revocation and scopes are per-patron and per-client.
Scope of work
Federation: many orgs will run Lenny nodes
Assume OL must interop with N independent Lenny nodes. Manual client registration does not scale — a new library standing up a node should not require work at OL.
This is the Mastodon topology: thousands of independent authorization servers, clients that must work with all of them and have no prior relationship with any. It is a solved problem with a proven precedent.
On OL's side, feed_registry is already the trust anchor — a node is trusted because it is registered there, which is already how _save_acquisitions gates writes — and its data jsonb column is the natural home for discovered endpoints and issued client credentials.
What exists already
/oauth/authorize accepts redirect_uri, state, client_id (the last is read into context but unused)
LENNY_OPDS_ALLOWED_HOSTS is effectively a redirect_uri allowlist — the security-relevant half of client registration, already built
build_oauth_fragment returns a token to the caller — this is the OAuth 2.0 implicit grant, which OAuth 2.1 removes precisely because of token-in-URL exposure. Code + PKCE replaces it.
lenny/core/external_auth.py is a full OIDC client (discovery, JWKS, PKCE) — useful if a node delegates patron auth elsewhere, and a good reference for the server side
Not blocking
Borrowing works today without any of this: OL can link to /v1/api/oauth/authorize?redirect_uri=/v1/api/items/{id}/borrow and the existing OTP flow completes the borrow. That path gives OL no token, so it cannot display loans — but it needs no auth work at all. This issue is what unlocks loans display and removes the OTP prompt.
Lenny needs to be a real OAuth 2.0 authorization server so Open Library (and any other consumer) can act on a patron's behalf — read their loans, borrow for them — without either side holding credentials it should not have.
This came out of a design discussion with Mek on 2026-09-04. Full reasoning in
docs/OL_BORROW_HANDOFF.md.The central problem
Lenny stores "logged in" as a cookie on
lennyforlibraries.org. Open Library cannot POST to Lenny and receive that cookie —Set-Cookiewould land on OL's HTTP client, not the patron's browser, and the cookie isDomain-scoped,HttpOnly,SameSite=Lax. So OL cannot obtain a session it can use to answer "what does this patron have on loan?"Every workaround we considered was worse than the standard:
The canonical answer
Lenny is the Authorization Server and Resource Server. Open Library is a confidential client. Authorization Code flow with PKCE, plus refresh tokens.
First authorization:
/oauth/authorizewithclient_id,redirect_uri,response_type=code,scope,state,code_challenge?code=…&state=…/oauth/tokenwith itscode_verifier→access_token+refresh_tokenEvery borrow after that needs no redirect and no OTP — OL already holds the tokens.
Why this is the right shape:
Scope of work
POST /oauth/token— authorization code exchange,refresh_tokengrantredirect_uri+ PKCE challengeclient_id,client_secret, registeredredirect_uris, allowed scopesloans:readandborrowPOST /v1/api/ol/loans(or equivalent) as a scoped resource endpointcore/auth.py:143-144), so it is rejected when a backend presents itFederation: many orgs will run Lenny nodes
Assume OL must interop with N independent Lenny nodes. Manual client registration does not scale — a new library standing up a node should not require work at OL.
/.well-known/oauth-authorization-server(endpoints, scopes, supported grants)This is the Mastodon topology: thousands of independent authorization servers, clients that must work with all of them and have no prior relationship with any. It is a solved problem with a proven precedent.
On OL's side,
feed_registryis already the trust anchor — a node is trusted because it is registered there, which is already how_save_acquisitionsgates writes — and itsdata jsonbcolumn is the natural home for discovered endpoints and issued client credentials.What exists already
/oauth/authorizeacceptsredirect_uri,state,client_id(the last is read into context but unused)LENNY_OPDS_ALLOWED_HOSTSis effectively a redirect_uri allowlist — the security-relevant half of client registration, already builtbuild_oauth_fragmentreturns a token to the caller — this is the OAuth 2.0 implicit grant, which OAuth 2.1 removes precisely because of token-in-URL exposure. Code + PKCE replaces it.lenny/core/external_auth.pyis a full OIDC client (discovery, JWKS, PKCE) — useful if a node delegates patron auth elsewhere, and a good reference for the server sideNot blocking
Borrowing works today without any of this: OL can link to
/v1/api/oauth/authorize?redirect_uri=/v1/api/items/{id}/borrowand the existing OTP flow completes the borrow. That path gives OL no token, so it cannot display loans — but it needs no auth work at all. This issue is what unlocks loans display and removes the OTP prompt.