From 13319c03b30dd2179aa125de18b1a1afbf637754 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 25 Sep 2026 14:28:04 -0700 Subject: [PATCH] chore(release): 0.2.0 The README leads with what the package is and where it plugs into Interchange, shows loginWithProvider as the quickstart, and lists the 0.1 upgrade steps. Development notes move to CONTRIBUTING.md. --- CONTRIBUTING.md | 13 +++ README.md | 296 ++++++++++++++++-------------------------------- package.json | 2 +- 3 files changed, 111 insertions(+), 200 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8017eaa..4b332a5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,18 @@ # Contributing +```sh +git clone https://github.com/corbitsdev/corbits-oauth-core.git +cd corbits-oauth-core +bun install +bun run check +``` + +`bun run check` runs typecheck, lint, format check and tests. `bun run format` rewrites the tree. + +## How it works + +`startOAuthLogin` stages the exchanged profile behind `commit()`. The redirect carrying the code is cleartext HTTP on loopback. The hub entry runs the same login in-process behind `mountOAuthLogin` and writes a stock `oauth_token` credential encrypted under the host's `CredentialCipher`. `createOAuthTokenRefresher` claims due credentials with `FOR UPDATE SKIP LOCKED`, so two hubs never refresh the same credential at once. + ## Commit messages Commit subjects and PR titles follow [Conventional Commits](https://www.conventionalcommits.org): `feat`, `fix`, `refactor`, `test`, `docs`, `build`, `ci`, `perf`, and `chore(release): x.y.z` for releases. diff --git a/README.md b/README.md index 71d74fe..078e4dc 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,105 @@ # @corbits/oauth-core -PKCE + loopback OAuth for an Interchange host: mint an OAuth token the host stores as a credential, which an `InferenceSource` names by `credentialId` (the harness reads the current secret at send time). A loopback callback server, token exchange/refresh, and an expiring-token session that refreshes ahead of expiry and coalesces concurrent refreshes. Endpoints and client id come from the caller, and persistence stays with the host. +OAuth 2.0 authorization-code login with PKCE and a loopback callback, in the Corbits auth & credentials bucket: it mints the tokens an Interchange hub (the multi-tenant control plane that holds tenants, principals and credentials) stores as `oauth_token` credentials. It also works standalone in any CLI or desktop host, with token refresh and MCP server discovery. -## Runtime support +## Why @corbits/oauth-core? -Bun >= 1.2 and Node >= 24 consume the published compiled `dist/` output. +1. **Provider-agnostic.** A provider is a config plus an `exchange` function the host supplies. The package never names or depends on one. +2. **One call to sign in.** `loginWithProvider` binds the provider's registered redirect URI, opens the browser, exchanges the code, and saves the profile. +3. **Tokens stay fresh.** The token session refreshes ahead of expiry and coalesces concurrent refreshes; on a hub, a background refresher renews stored credentials under a row lock. +4. **Hub-ready.** The `/hub` entry mounts login routes on a hub tenant router, gated by the host's grants (a principal's permission on a resource), and writes tokens through the host's `CredentialCipher`. -## Quickstart +## Install ```sh -npm add @corbits/oauth-core -pnpm add @corbits/oauth-core -yarn add @corbits/oauth-core bun add @corbits/oauth-core ``` -One flow shape: public client, PKCE S256, fixed-port loopback, no client secret. +This installs `@intx/db`, `@intx/hub-api`, `@intx/hub-common`, `@intx/types`, `drizzle-orm` and `hono` as dependencies. Only the `@corbits/oauth-core/hub` entry imports them, so browser and CLI bundles built from the root entry do not include them. + +## Quickstart + +```ts +import { + baseTokensFromResponse, + exchangeCode, + loginWithProvider, + openInBrowser, +} from "@corbits/oauth-core"; + +const oauthConfig = { + clientId: "my-cli", + authorizeUrl: "https://auth.example.com/authorize", + tokenUrl: "https://auth.example.com/token", + redirectUri: "http://127.0.0.1:1455/callback", + scopes: ["openid", "offline_access"], + tokenTimeoutMs: 10_000, +}; + +const profile = await loginWithProvider( + { + oauthConfig, + exchange: async (code, verifier, now) => + baseTokensFromResponse( + await exchangeCode(oauthConfig, code, verifier), + now, + undefined, + ), + }, + { + profile: "default", + signal: AbortSignal.timeout(120_000), + openInBrowser, + save: async (saved) => console.log(`saved ${saved.name}`), + }, +); +console.log(profile.tokens.expiresAt); +``` + +The callback server binds only loopback addresses (`127.0.0.0/8` or `::1`) on the port in `redirectUri`, which must match the client registration exactly. + +## Where it fits -A provider is a package the host supplies — `@corbits/xai-provider`, -`@corbits/codex-provider`, or one of its own — pairing an `OAuthClientConfig` -with an `exchange`/`refresh` implementation. This library never names one -itself. +- **CLI or desktop host:** the root entry. The host keeps profiles in its own store (an OS keychain, an encrypted file). +- **Interchange hub** (`@intx/hub-api`, `@intx/db`, `@intx/types`): the `/hub` entry mounts login routes and refreshes `oauth_token` credentials that an `InferenceSource` names by `credentialId`. +- **Providers:** a provider package or the host itself supplies an `OAuthLoginProvider`. +- **MCP servers:** `discoverMcpLoginEntry` builds the `OAuthClientConfig` for servers that publish their authorization server instead of a fixed client. -### Hub-hosted browser login +## Reference -The production path: a tenant router gets browser-driven "Continue with -<provider>" login, and a background ticker keeps issued tokens fresh. -Tokens never leave this process as plaintext — `mountOAuthLogin` writes them -through the host's `CredentialCipher` into a stock `oauth_token` credential, -and `createOAuthTokenRefresher` reads them back the same way. +### Root entry (`@corbits/oauth-core`) + +| Export | Purpose | +| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | +| `loginWithProvider(provider, opts)` | Sign in with a provider and save the profile. | +| `startOAuthLogin(opts, deps)` | Lower-level login; returns the authorize URL and a staged profile to `commit()`. Use it for custom callback pages. | +| `createTokenSession(deps)` | `getValidToken(profile)` refreshes ahead of expiry and coalesces concurrent refreshes. | +| `startCallbackServer(state, config)` | The loopback callback listener. | +| `buildAuthorizeUrl`, `exchangeCode`, `refreshTokenRequest`, `baseTokensFromResponse` | Token endpoint helpers. | +| `callbackTargetFor(config)` | The host, port and path a `redirectUri` binds. | +| `discoverMcpLoginEntry`, `registerMcpClient`, `mcpClientConfig`, `selectMcpScopes` | MCP authorization discovery and dynamic client registration. | +| `generatePkce`, `generateState`, `openInBrowser` | Primitives. | + +### Hub entry (`@corbits/oauth-core/hub`) + +| Export | Purpose | +| --------------------------------- | --------------------------------------------------------- | +| `mountOAuthLogin(app, opts)` | Adds the login routes below to a tenant router. | +| `createOAuthTokenRefresher(opts)` | Background refresher with `start()` and `stop()`. | +| `persistOAuthCredential` | Writes tokens into an encrypted `oauth_token` credential. | + +| Route | | +| ------------------------------- | --------------------------------------------------------------------------------------- | +| `GET /oauth-logins/providers` | Provider names the host offers. | +| `POST /oauth-logins` | Start a login (`provider`, `credentialName`); returns the authorize URL and a login id. | +| `GET /oauth-logins/:loginId` | Poll for completion; returns the credential id when done. | +| `DELETE /oauth-logins/:loginId` | Cancel. | + +The PKCE verifier and the callback listener never leave the hub process. + +## Using with Interchange + +Mount the routes under the tenant prefix, gate them with the host's grant middleware (a grant is a principal's permission on a resource), and run the refresher: ```ts import { @@ -47,27 +118,17 @@ export function installOAuthLogin( opts: { db: DB["db"]; cipher: CredentialCipher; - // The host's own grant middleware, e.g. from `createRequireGrant` — - // checked once, the host's way, before a login can start. requireGrant: MiddlewareHandler; - // Provider packages the host has installed, e.g. `@corbits/xai-provider`. providers: OAuthLoginProviders; - // Required: a refresh only reaches the DB. A sidecar already running - // with the old token keeps using it until something tells it to reload, - // so the host must push or notify its running consumers here — see - // "Keeping running consumers in sync" below. onRefreshed: (context: { tenantId: string; credentialId: string }) => void; - onError: (error: unknown) => void; }, ): OAuthTokenRefresher { const oauthLoginApi = new Hono(); - mountOAuthLogin(oauthLoginApi, { db: opts.db, cipher: opts.cipher, requireGrant: opts.requireGrant, providers: opts.providers, - onError: (error) => opts.onError(error), }); app.route("/api/tenants/:tenantId", oauthLoginApi); @@ -75,185 +136,22 @@ export function installOAuthLogin( db: opts.db, cipher: opts.cipher, providers: opts.providers, - intervalMs: 60_000, - onRefreshed: (context) => opts.onRefreshed(context), - onError: (error) => opts.onError(error), + onRefreshed: opts.onRefreshed, }); refresher.start(); - return refresher; // host calls .stop() on shutdown + return refresher; } ``` -`mountOAuthLogin` adds `POST /oauth-logins` (starts a login and returns an -authorize URL and a login id — the PKCE verifier and the loopback callback -listener never leave this process), `GET /oauth-logins/:loginId` (poll for -completion), and `DELETE /oauth-logins/:loginId` (cancel). The browser only -ever learns the id of the credential the tokens landed in. - -#### Keeping running consumers in sync - -`createOAuthTokenRefresher` only writes the renewed token to the credential -row; it does not know what, if anything, is holding a live copy of the old -one. A host that runs long-lived processes against a credential — sidecars, -workers, anything that loaded the token into memory rather than re-reading it -per call — has to push or notify those processes itself from `onRefreshed`, -or they keep using the stale token until they happen to restart or redeploy, -even though the database already has the fresh one. That is why `onRefreshed` -is a required callback here rather than an optional log hook. - -### Lower-level: a CLI/desktop login +The refresher only updates the credential row. A process that loaded the old token into memory, such as a running sidecar (the agent runtime), keeps it until told to reload, so notify those processes from `onRefreshed`. -Outside a hub, a CLI or desktop app drives `startOAuthLogin` itself. Each -dependency is where that host plugs in its own piece: its callback page, the -provider package's code exchange, and its own credential store. +The hub entry will move to a separate `@corbits/oauth-hub` package in a later release. -```ts -import { - createTokenSession, - buildAuthorizeUrl, - startCallbackServer, - startOAuthLogin, - type BaseTokens, - type CallbackServer, - type OAuthLoginDeps, - type OAuthLoginProvider, - type TokenSessionDeps, -} from "@corbits/oauth-core"; - -// The host's own callback page, served on the provider's fixed redirect_uri. -function startBrandedCallbackServer( - provider: OAuthLoginProvider, - expectedState: string, -): Promise { - const redirect = new URL(provider.oauthConfig.redirectUri); - return startCallbackServer(expectedState, { - host: redirect.hostname, - port: Number(redirect.port), - path: redirect.pathname, - doneHtml: "

Signed in. You can close this tab.

", - failedHtml: (reason) => `

Sign-in failed: ${reason}

`, - }); -} - -// Where this host keeps secrets at rest: an OS keychain, an encrypted file. -export type ProfileStore = { - save: OAuthLoginDeps["saveProfile"]; - load: TokenSessionDeps["loadProfile"]; - update: TokenSessionDeps["updateTokens"]; -}; - -// `provider` comes from a provider package, e.g. `@corbits/xai-provider`, -// which supplies the OAuth config, code exchange and refresh. -export async function signIn( - provider: OAuthLoginProvider, - store: ProfileStore, - signal: AbortSignal, -): Promise { - const login = await startOAuthLogin( - { profile: "default", signal }, - { - startCallbackServer: (state) => - startBrandedCallbackServer(provider, state), - buildAuthorizeUrl: (pkce, state) => - buildAuthorizeUrl(provider.oauthConfig, pkce, state), - exchangeCode: provider.exchange, - saveProfile: store.save, - }, - ); - const staged = await login.completed; - await staged.commit(); - - const refresh = provider.refresh; - if (refresh === undefined) { - return staged.profile.tokens.access; - } - const session = createTokenSession({ - skewMs: 30_000, - loadProfile: store.load, - updateTokens: store.update, - refreshTokens: (refreshToken, now) => refresh(refreshToken, now), - toAccess: (tokens) => tokens.access, - }); - return session.getValidToken("default"); -} -``` - -`startOAuthLogin` opens the browser, waits for the callback, exchanges the -code, and stages the profile; `commit()` saves it. The token session then -refreshes ahead of expiry and coalesces concurrent refreshes. - -### Lower-level: an MCP server with no fixed client - -An MCP server such as Linear's publishes its authorization server instead of -a fixed client id. Discover it, register a public client, and build the same -`OAuthClientConfig` the flows above take: - -```ts -import { - discoverMcpLoginEntry, - mcpClientConfig, - registerMcpClient, - type OAuthClientConfig, -} from "@corbits/oauth-core"; - -export async function mcpOAuthConfig( - resourceUrl: string, - redirectUri: string, - clientName: string, -): Promise<{ config: OAuthClientConfig; clientId: string }> { - const entry = await discoverMcpLoginEntry({ resourceUrl }); - const { registrationEndpoint } = entry.authorizationServer; - if (registrationEndpoint === undefined) { - throw new Error(`${resourceUrl} offers no dynamic client registration`); - } - const { clientId } = await registerMcpClient({ - registrationEndpoint, - redirectUris: [redirectUri], - clientName, - }); - return { - config: mcpClientConfig(entry, { clientId, redirectUri }), - clientId, - }; -} -``` - -Store `clientId` with the credential: refresh needs the same client that -signed in. `registerMcpClient` also returns the `grantTypes` and `scope` the -server actually granted, which can be narrower than what was asked for. -Discovery refuses a server without PKCE S256 or public-client support, and -`mcpClientConfig` picks scopes the way the MCP spec orders them, adding -`offline_access` when the server offers it (pass `scopes` to override). The config carries the RFC 8707 `resource` parameter, so the token -is bound to that one MCP server. - -## How it works - -Nothing here names a provider — config and callback HTML are caller-supplied; -persistence is a host callback (`mountOAuthLogin`'s `db`/`cipher`, or a CLI -host's own `persist`/`load`/`update`). `startOAuthLogin` stages the exchanged -profile behind `commit()`. Callback binds are loopback only (`127.0.0.0/8` or -`::1`); the redirect carrying the code is cleartext HTTP. The token session -coalesces concurrent refreshes for the same profile. The hub subpath runs -that same login loop in-process behind `mountOAuthLogin` and writes a stock -`oauth_token` credential, encrypted under the host's `CredentialCipher`; -`createOAuthTokenRefresher` walks those credentials ahead of expiry and -refreshes them under a row lock (`FOR UPDATE SKIP LOCKED`), so two hubs -ticking at once never both refresh the same credential. - -## Development - -```sh -git clone https://github.com/corbitsdev/corbits-oauth-core.git -cd corbits-oauth-core -bun install -bun run typecheck -bun run lint -bun run format:check -bun run test -bun run check -``` +## Upgrading from 0.1 -`bun run format` rewrites the tree. `bun run check` is typecheck + lint + format:check + test. +- `OAuthLoginProvider` and `callbackTargetFor` import from `@corbits/oauth-core`, not `@corbits/oauth-core/hub`. +- `CallbackServer.port` is required. A custom `startCallbackServer` must return the bound port. +- Stored profiles and `oauth_token` credentials are unchanged and keep working. ## License diff --git a/package.json b/package.json index 83c697a..6682b53 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@corbits/oauth-core", - "version": "0.1.0", + "version": "0.2.0", "description": "Provider-agnostic OAuth 2.0 authorization-code + PKCE login for desktop/CLI hosts.", "license": "LGPL-2.1-only", "author": "Sawyer Cutler ",