From 6ef092d34cad908393183fd7f5a1622af19a1da5 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 25 Sep 2026 14:26:24 -0700 Subject: [PATCH 1/2] refactor!: move provider types and callback pages to the root entry OAuthLoginProvider and callbackTargetFor need neither hono nor @intx, so they move to the root entry and leave the hub entry. The hub mount's callback pages move with them and drop the workbench-specific copy. --- README.md | 2 +- src/hub/index.ts | 6 +----- src/hub/mount.ts | 17 ++++++++--------- src/hub/refresh.test.ts | 2 +- src/hub/refresh.ts | 2 +- src/index.ts | 2 ++ src/{hub/registry.test.ts => provider.test.ts} | 17 ++++++----------- src/{hub/registry.ts => provider.ts} | 16 ++++++++++++---- 8 files changed, 32 insertions(+), 32 deletions(-) rename src/{hub/registry.test.ts => provider.test.ts} (70%) rename src/{hub/registry.ts => provider.ts} (71%) diff --git a/README.md b/README.md index 2d39b5a..2f303ca 100644 --- a/README.md +++ b/README.md @@ -116,9 +116,9 @@ import { type BaseTokens, type CallbackServer, type OAuthLoginDeps, + type OAuthLoginProvider, type TokenSessionDeps, } from "@corbits/oauth-core"; -import type { OAuthLoginProvider } from "@corbits/oauth-core/hub"; // The host's own callback page, served on the provider's fixed redirect_uri. function startBrandedCallbackServer( diff --git a/src/hub/index.ts b/src/hub/index.ts index aa90216..e8d5863 100644 --- a/src/hub/index.ts +++ b/src/hub/index.ts @@ -1,11 +1,7 @@ // Server-only entry point: the hub-hosted login mount. The root export // stays free of `hono` and `@intx/*` so browser bundles never reach them. export { mountOAuthLogin, type MountOAuthLoginOpts } from "./mount.js"; -export { - callbackTargetFor, - type OAuthLoginProvider, - type OAuthLoginProviders, -} from "./registry.js"; +export type { OAuthLoginProviders } from "../provider.js"; export { persistOAuthCredential, OAUTH_PROVIDER_METADATA_KEY, diff --git a/src/hub/mount.ts b/src/hub/mount.ts index 015dedd..31c4038 100644 --- a/src/hub/mount.ts +++ b/src/hub/mount.ts @@ -11,7 +11,12 @@ import { } from "../index.js"; import { persistOAuthCredential } from "./credentials.js"; import { createLoginStore, type LoginState } from "./login-store.js"; -import { callbackTargetFor, type OAuthLoginProviders } from "./registry.js"; +import { + callbackTargetFor, + signedInHtml, + signInFailedHtml, + type OAuthLoginProviders, +} from "../provider.js"; /** An abandoned login holds a fixed loopback port, so it is not held long. */ const DEFAULT_LOGIN_TTL_MS = 5 * 60 * 1000; @@ -23,12 +28,6 @@ const StartLogin = type({ credentialName: "string", }); -const DONE_HTML = - "Signed in

Signed in — you can close this tab and return to your workbench."; - -const failedHtml = (reason: string): string => - `Sign-in failed

Sign-in failed: ${reason.replace(/[<&]/g, "")}`; - export type MountOAuthLoginOpts = { readonly db: DB["db"]; readonly cipher: CredentialCipher; @@ -90,8 +89,8 @@ export function mountOAuthLogin( port: target.port, host: target.host, path: target.path, - doneHtml: DONE_HTML, - failedHtml, + doneHtml: signedInHtml, + failedHtml: signInFailedHtml, }), buildAuthorizeUrl: (pkce, state) => buildAuthorizeUrl(provider.oauthConfig, pkce, state), diff --git a/src/hub/refresh.test.ts b/src/hub/refresh.test.ts index 31fd545..8e5981c 100644 --- a/src/hub/refresh.test.ts +++ b/src/hub/refresh.test.ts @@ -6,7 +6,7 @@ import { refreshCredential, type OAuthRefreshStore, } from "./refresh.js"; -import type { OAuthLoginProviders } from "./registry.js"; +import type { OAuthLoginProviders } from "../provider.js"; const MINUTE = 60_000; diff --git a/src/hub/refresh.ts b/src/hub/refresh.ts index c1641bc..9aca590 100644 --- a/src/hub/refresh.ts +++ b/src/hub/refresh.ts @@ -9,7 +9,7 @@ import { OAUTH_PROVIDER_METADATA_KEY, writeOAuthTokens, } from "./credentials.js"; -import type { OAuthLoginProviders } from "./registry.js"; +import type { OAuthLoginProviders } from "../provider.js"; const DEFAULT_INTERVAL_MS = 60_000; const DEFAULT_MARGIN_MS = 10 * 60 * 1000; diff --git a/src/index.ts b/src/index.ts index d390b6e..d858f44 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,6 +33,8 @@ export { type StartOAuthLoginOptions, } from "./login.js"; +export { callbackTargetFor, type OAuthLoginProvider } from "./provider.js"; + export { createTokenSession, isTokenExpired, diff --git a/src/hub/registry.test.ts b/src/provider.test.ts similarity index 70% rename from src/hub/registry.test.ts rename to src/provider.test.ts index 04b934d..dc7a028 100644 --- a/src/hub/registry.test.ts +++ b/src/provider.test.ts @@ -1,33 +1,28 @@ import { describe, expect, it } from "bun:test"; -import { callbackTargetFor } from "./registry.js"; +import { callbackTargetFor, type OAuthClientConfig } from "./index.js"; -const base = { +const configFor = (redirectUri: string): OAuthClientConfig => ({ clientId: "client", authorizeUrl: "https://auth.example.com/authorize", tokenUrl: "https://auth.example.com/token", + redirectUri, scopes: ["openid"], tokenTimeoutMs: 1_000, -}; +}); describe("callbackTargetFor", () => { it("binds the host the provider registered, not a normalized loopback", () => { // A provider that registered `localhost` is not interchangeable with // `127.0.0.1`: the authorization server matches redirect_uri literally. expect( - callbackTargetFor({ - ...base, - redirectUri: "http://localhost:1455/auth/callback", - }), + callbackTargetFor(configFor("http://localhost:1455/auth/callback")), ).toEqual({ host: "localhost", port: 1455, path: "/auth/callback" }); }); it("refuses a redirect_uri with no explicit port", () => { expect(() => - callbackTargetFor({ - ...base, - redirectUri: "http://127.0.0.1/callback", - }), + callbackTargetFor(configFor("http://127.0.0.1/callback")), ).toThrow(/explicit loopback port/); }); }); diff --git a/src/hub/registry.ts b/src/provider.ts similarity index 71% rename from src/hub/registry.ts rename to src/provider.ts index 198e18b..8f15a1c 100644 --- a/src/hub/registry.ts +++ b/src/provider.ts @@ -1,9 +1,10 @@ -import type { BaseTokens, OAuthClientConfig } from "../index.js"; +import type { OAuthClientConfig } from "./client.js"; +import type { BaseTokens } from "./tokens.js"; /** - * One provider a host offers for hub-hosted login. The host owns the - * provider packages, so this library never depends on any of them: it only - * sees an OAuth config and a code exchange. + * One OAuth provider a host offers for login. The host owns the provider + * packages, so this library never depends on any of them: it only sees an + * OAuth config and a code exchange. */ export type OAuthLoginProvider = { /** Supplies the authorize URL and the fixed loopback redirect_uri. */ @@ -53,3 +54,10 @@ export function callbackTargetFor(config: OAuthClientConfig): { } return { host: url.hostname, port, path: url.pathname }; } + +export const signedInHtml = + "Signed in

Signed in. You can close this tab."; + +// `reason` echoes the redirect's `error` parameter, which anyone can set. +export const signInFailedHtml = (reason: string): string => + `Sign-in failed

Sign-in failed: ${reason.replace(/[<&]/g, "")}`; From 9d60098415b47670d25ed1a29a1eb5fa02d0f565 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 25 Sep 2026 14:26:28 -0700 Subject: [PATCH 2/2] feat(login): add loginWithProvider It runs the loopback PKCE login against a provider's fixed redirect_uri, saves the profile, and returns it. Hosts that need their own callback pages, or to show the staged profile before committing it, keep using startOAuthLogin. --- AGENTS.md | 2 ++ src/index.ts | 7 ++++- src/provider.test.ts | 73 ++++++++++++++++++++++++++++++++++++++++++-- src/provider.ts | 52 +++++++++++++++++++++++++++++-- 4 files changed, 129 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8162663..f76fea3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,8 @@ id, or product name. - `src/session.ts` — `createTokenSession`, the refresh-ahead-of-expiry logic. - `src/browser.ts` — best-effort browser launch. - `src/login.ts` — `startOAuthLogin`, wiring the above into one flow. +- `src/provider.ts` — `OAuthLoginProvider` and `loginWithProvider`, which + builds the login deps from a provider and saves the result. - `src/index.ts` — the only module consumers import from. - `src/*.test.ts` — tests next to the module they cover. diff --git a/src/index.ts b/src/index.ts index d858f44..a1466ea 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,7 +33,12 @@ export { type StartOAuthLoginOptions, } from "./login.js"; -export { callbackTargetFor, type OAuthLoginProvider } from "./provider.js"; +export { + callbackTargetFor, + loginWithProvider, + type LoginWithProviderOptions, + type OAuthLoginProvider, +} from "./provider.js"; export { createTokenSession, diff --git a/src/provider.test.ts b/src/provider.test.ts index dc7a028..ef146d9 100644 --- a/src/provider.test.ts +++ b/src/provider.test.ts @@ -1,6 +1,75 @@ -import { describe, expect, it } from "bun:test"; +import { createServer } from "node:net"; -import { callbackTargetFor, type OAuthClientConfig } from "./index.js"; +import { describe, expect, it, test } from "bun:test"; + +import { + callbackTargetFor, + loginWithProvider, + type BaseTokens, + type OAuthClientConfig, + type OAuthLoginProvider, +} from "./index.js"; + +async function freePort(): Promise { + const server = createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + await new Promise((resolve) => server.close(resolve)); + if (address === null || typeof address === "string") { + throw new Error("no TCP address"); + } + return address.port; +} + +describe("loginWithProvider", () => { + test("binds the provider's redirect_uri, exchanges the code, and saves before returning", async () => { + const port = await freePort(); + // Load-bearing: the helper derives every login dependency from the + // provider. A wrong callback target or a skipped save only shows up as a + // login that hangs or a profile that is gone on the next run. + const tokens: BaseTokens = { + access: "access-1", + refresh: "refresh-1", + expiresAt: 10_000, + }; + const exchanged: string[] = []; + const provider: OAuthLoginProvider = { + oauthConfig: { + clientId: "client", + authorizeUrl: "https://auth.example/authorize", + tokenUrl: "https://auth.example/token", + redirectUri: `http://127.0.0.1:${String(port)}/cb`, + scopes: ["openid"], + tokenTimeoutMs: 1_000, + }, + exchange: async (code) => { + exchanged.push(code); + return tokens; + }, + }; + const saved: string[] = []; + let callback: Promise | undefined; + + const done = loginWithProvider(provider, { + profile: "work", + signal: AbortSignal.timeout(5_000), + save: async (profile) => { + saved.push(profile.name); + }, + openInBrowser: (url) => { + const state = new URL(url).searchParams.get("state") ?? ""; + callback = fetch( + `http://127.0.0.1:${String(port)}/cb?code=the-code&state=${state}`, + ); + }, + }); + const profile = await done; + expect((await callback)?.status).toBe(200); + expect(profile.tokens).toEqual(tokens); + expect(exchanged).toEqual(["the-code"]); + expect(saved).toEqual(["work"]); + }); +}); const configFor = (redirectUri: string): OAuthClientConfig => ({ clientId: "client", diff --git a/src/provider.ts b/src/provider.ts index 8f15a1c..7dea9be 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -1,5 +1,7 @@ -import type { OAuthClientConfig } from "./client.js"; -import type { BaseTokens } from "./tokens.js"; +import { startCallbackServer } from "./callback-server.js"; +import { buildAuthorizeUrl, type OAuthClientConfig } from "./client.js"; +import { startOAuthLogin, type OAuthLoginDeps } from "./login.js"; +import type { AuthProfile, BaseTokens } from "./tokens.js"; /** * One OAuth provider a host offers for login. The host owns the provider @@ -61,3 +63,49 @@ export const signedInHtml = // `reason` echoes the redirect's `error` parameter, which anyone can set. export const signInFailedHtml = (reason: string): string => `Sign-in failed

Sign-in failed: ${reason.replace(/[<&]/g, "")}`; + +export type LoginWithProviderOptions = { + save: OAuthLoginDeps["saveProfile"]; + profile: string; + signal: AbortSignal; + openInBrowser: (url: string) => void; +}; + +/** + * Run the loopback PKCE login against `provider`'s fixed redirect_uri, save + * the exchanged profile, and return it. Hosts that need their own callback + * pages, or to show the staged profile before committing it, use + * `startOAuthLogin`. `provider.metadata` is not applied: the profile holds + * tokens only, and the host derives any metadata from them when it stores + * the credential. + */ +export async function loginWithProvider( + provider: OAuthLoginProvider, + opts: LoginWithProviderOptions, +): Promise> { + const target = callbackTargetFor(provider.oauthConfig); + const login = await startOAuthLogin( + { + profile: opts.profile, + signal: opts.signal, + }, + { + startCallbackServer: (state) => + startCallbackServer(state, { + host: target.host, + port: target.port, + path: target.path, + doneHtml: signedInHtml, + failedHtml: signInFailedHtml, + }), + buildAuthorizeUrl: (pkce, state) => + buildAuthorizeUrl(provider.oauthConfig, pkce, state), + exchangeCode: provider.exchange, + saveProfile: opts.save, + openInBrowser: opts.openInBrowser, + }, + ); + const staged = await login.completed; + await staged.commit(); + return staged.profile; +}