Skip to content

OAuth 2.0 authorization server: let Open Library act for a patron without holding credentials it shouldn't #209

Description

@openlibrary-bot

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:

  1. OL redirects the browser to Lenny's /oauth/authorize with client_id, redirect_uri, response_type=code, scope, state, code_challenge
  2. Patron authenticates (existing OTP flow) and consents
  3. Lenny redirects back with ?code=…&state=…
  4. OL's server exchanges the code at /oauth/token with its code_verifieraccess_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

  • POST /oauth/token — authorization code exchange, refresh_token grant
  • Authorization codes: short-lived, single-use, bound to client + redirect_uri + PKCE challenge
  • Client records: client_id, client_secret, registered redirect_uris, allowed scopes
  • Scopes — at least loans:read and borrow
  • Refresh tokens + a revocation endpoint
  • POST /v1/api/ol/loans (or equivalent) as a scoped resource endpoint
  • A per-patron API token that is not IP-bound — the current session cookie is (core/auth.py:143-144), so it is rejected when a backend presents it

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.

  • RFC 8414 — publish /.well-known/oauth-authorization-server (endpoints, scopes, supported grants)
  • RFC 7591 — dynamic client registration, so a consumer self-registers with no human coordination

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.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions