Skip to content

feat: sign locallibrary plugins in to services that want a client secret, through the website's token exchange - #261

Merged
botandbang merged 3 commits into
mainfrom
bnb/plugin-server-sign-in
Oct 26, 2026
Merged

botandbang merged 3 commits into
mainfrom
bnb/plugin-server-sign-in

Conversation

@botandbang

Copy link
Copy Markdown
Contributor

What and why

Mixcloud, SoundCloud and Google want a client secret when a sign-in's code is traded for tokens, and SoundCloud wants one at every refresh. A secret can't ship inside locallibrary, so the website now holds each one and makes the trade, and a plugin's account can name that exchange instead of the service's token endpoint.

Added:

  • POST /api/token-exchange/<service> (apps/web/src/routes/api/token-exchange/[service]/+server.ts, logic in apps/web/src/lib/server/token-exchange/exchange.ts). It takes the token request locallibrary would have sent the service, as JSON (authorization_code with code, redirect_uri and code_verifier, or refresh_token), adds the client id and secret from the environment, sends it to the service's token endpoint the way that service takes it, and answers what the service answered with the same status and cache-control: no-store. A success without token_type gets bearer. It sends only to the registry's token endpoints, follows no redirects, reads at most 64 KiB of a request or an answer, times out after 10 s, and logs the service, grant, status and timing but never a code, token or secret. It's JSON because SvelteKit's CSRF check refuses a form posted without an Origin header.
  • apps/web/src/lib/server/token-exchange/services.json, the services it knows: Google and SoundCloud as form POSTs, Mixcloud as a GET with its query (codes only, since Mixcloud documents no refresh). Each entry names its token URL, its request style and the environment variables for its client id and secret. services.schema.json is generated from the zod schema that reads it, and a test fails when it's stale.
  • Optional environment variables GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET, MIXCLOUD_CLIENT_ID/MIXCLOUD_CLIENT_SECRET and SOUNDCLOUD_CLIENT_ID/SOUNDCLOUD_CLIENT_SECRET. A service without both answers 503 temporarily_unavailable, "Local Network isn't set up to sign in to X yet". An unknown service (including __proto__) is a 404.
  • token_exchange on [[contributes.accounts]] (crates/loclib-plugin/src/hooks/accounts.rs), in place of token_url. It must be https://localnetwork.computer/api/token-exchange/<service> (or that path on this computer, for tests), since whatever it names is sent the user's codes and refresh tokens. An account names exactly one of the two.
  • The host sends the exchange the fields it would have sent a token endpoint, as JSON (crates/locallibrary/src/plugin/accounts.rs). Signing in, refreshing, refresh-token rotation and error messages work as they do with a token endpoint.

Changed:

  • token_url is optional in the manifest and the RPC contract (apps/locallibrary/docs/rpc/). Existing manifests stay valid.
  • apps/web/README.md (configuration, a token exchange section, security notes, layout), apps/web/.env.example, crates/loclib-plugin/README.md, apps/locallibrary/docs/plugins.md and the CHANGELOG.

Not covered here: Mixcloud takes its access token as a query parameter and SoundCloud's API expects Authorization: OAuth, while the host always sends Bearer, and SoundCloud matches its registered redirect URI exactly. Plugins for those services will need the host to say how an account's token is sent.

How it was tested

  • Web: apps/web/src/lib/server/token-exchange/exchange.test.ts and services.test.ts run the route against stand-in token endpoints at the registry's real addresses: the secret reaching the service, refresh and rotation, Mixcloud's GET, unknown services, the unset-environment answer, size limits, an unreachable or garbled service, and that logs never hold a code, token or secret.
  • Rust: manifest tests and a Hegel property for which exchange addresses are accepted; an integration test that signs in and refreshes through a mock exchange; the existing property for where tokens go now draws accounts that use the exchange.
  • Each new test was checked by breaking the code it covers.
  • Local: the web app's format, lint, check, knip, test, build tests and coverage (statements 92.22, branches 81.19, functions 92.95, lines 93.74); cargo fmt --check, workspace clippy with -D warnings, nextest for loclib-plugin, locallibrary and locallibrary-cli, and coverage over loclib-plugin and locallibrary (lines 97.36, functions 95.34, regions 94.87).

Checklist

  • Tests cover the change
  • For the website: Storybook stories cover any new UI states
  • For locallibrary: apps/locallibrary/CHANGELOG.md has a line under Unreleased, if people using it will notice
  • The READMEs, docs/ and --help still say what the code does (apps/locallibrary/docs/rpc/ too, for a sidecar change)

…'s client secrets

Mixcloud, SoundCloud and Google want a client secret when a code is
traded for tokens (SoundCloud at every refresh too), and a secret can't
ship inside an app people download. POST /api/token-exchange/<service>
takes the token request locallibrary would have sent the service, as
JSON, adds the client id and secret from the environment, sends it to
the service's token endpoint the way that service takes it, and answers
what the service answered.

The services live in src/lib/server/token-exchange/services.json, with
a schema generated from the zod schema that reads it. A service without
its two environment variables answers 503 temporarily_unavailable. The
exchange only sends to the token endpoints listed there, follows no
redirect, reads at most 64 KiB of a request or an answer, and logs the
service, grant and status, never a code, token or secret.
…'s token exchange

An account may name token_exchange in token_url's place: Local
Network's https://localnetwork.computer/api/token-exchange/<service>,
or the same path on this computer for a test server. Any other address
is refused, since the exchange is sent the user's codes and refresh
tokens, and an account names exactly one of the two.

The host sends the exchange the same fields it would have sent the
token endpoint, as a JSON object, because SvelteKit refuses a form
posted without an Origin header. Signing in, refreshing and what an
error says work as they do with a token endpoint. The RPC contract,
the crate README, the plugins guide and the changelog say so.
@botandbang
botandbang requested a review from bnb as a code owner October 26, 2026 19:59
@github-actions github-actions Bot added documentation Improvements or additions to documentation web The Local Network website (apps/web) locallibrary locallibrary and the loclib-* crates web: server Spaces, sync, HappyView, the database and the server around them locallibrary: sidecar The JSON-RPC sidecar and the api module it serves locallibrary: core The core crate, the data model and rekordbox's formats locallibrary: plugins The plugin protocol, its host and the first-party plugins tests Tests, fixtures and test harnesses labels Oct 26, 2026
@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Coverage Report for apps/web

Status Category Percentage Covered / Total
🟢 Lines 94.39% (🎯 93%)
⬆️ +0.05 pp
5941 / 6294
🟢 Statements 92.75% (🎯 91%)
⬆️ +0.05 pp
9152 / 9867
🟢 Functions 93.35% (🎯 92%)
⬆️ +0.01 pp
2317 / 2482
🟢 Branches 82.21% (🎯 80%)
⬆️ +0.11 pp
4231 / 5146
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
apps/web/src/lib/server/token-exchange/exchange.ts 100% 97.36% 100% 100%
apps/web/src/lib/server/token-exchange/services.ts 100% 100% 100% 100%
Generated in workflow #484 for commit c79954d by the Vitest Coverage Report Action

Size

Compared with main. KiB are 1,024 bytes.

This pull request main Change
Client JavaScript 619.2 KiB 619.2 KiB 0 B (0%)
Client JavaScript, brotli 204.5 KiB 204.5 KiB 0 B (0%)
Client CSS 87.6 KiB 87.6 KiB 0 B (0%)
Client CSS, brotli 12.5 KiB 12.5 KiB 0 B (0%)

@github-actions

Copy link
Copy Markdown

Rust coverage

From run 472. Both are held to the thresholds in .cargo/config.toml.

Tests Lines Functions Regions
All 97.49% (79334/81380) 96.65% (12648/13087) 95.79% (174303/181971)
Hegel property tests alone 95.43% (77660/81380) 93.96% (12296/13087) 93.49% (170119/181971)

@botandbang
botandbang merged commit 2c93a0b into main Oct 26, 2026
66 checks passed
@botandbang
botandbang deleted the bnb/plugin-server-sign-in branch October 26, 2026 21:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation locallibrary: core The core crate, the data model and rekordbox's formats locallibrary: plugins The plugin protocol, its host and the first-party plugins locallibrary: sidecar The JSON-RPC sidecar and the api module it serves locallibrary locallibrary and the loclib-* crates tests Tests, fixtures and test harnesses web: server Spaces, sync, HappyView, the database and the server around them web The Local Network website (apps/web)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants