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/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 inSigned in — you can close this tab and return to your workbench.";
-
-const failedHtml = (reason: string): string =>
- `
Sign-in failedSign-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/hub/registry.test.ts b/src/hub/registry.test.ts
deleted file mode 100644
index 04b934d..0000000
--- a/src/hub/registry.test.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { describe, expect, it } from "bun:test";
-
-import { callbackTargetFor } from "./registry.js";
-
-const base = {
- clientId: "client",
- authorizeUrl: "https://auth.example.com/authorize",
- tokenUrl: "https://auth.example.com/token",
- 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",
- }),
- ).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",
- }),
- ).toThrow(/explicit loopback port/);
- });
-});
diff --git a/src/hub/registry.ts b/src/hub/registry.ts
deleted file mode 100644
index 198e18b..0000000
--- a/src/hub/registry.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import type { BaseTokens, OAuthClientConfig } from "../index.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.
- */
-export type OAuthLoginProvider = {
- /** Supplies the authorize URL and the fixed loopback redirect_uri. */
- readonly oauthConfig: OAuthClientConfig;
- /** Redeems the authorization code for tokens. */
- readonly exchange: (
- code: string,
- verifier: string,
- now: number,
- ) => Promise;
- /**
- * Redeems a stored refresh token for a fresh access token, ahead of
- * expiry. Absent when the provider issues no refreshable token: its
- * credentials are then left for the person to sign in again.
- */
- readonly refresh?: (
- refreshSecret: string,
- now: number,
- ) => Promise;
- /**
- * Credential metadata derived from the freshly-minted tokens — an account
- * id a provider needs on every inference request, say. Never the raw
- * id_token: the credential row is not a place to park a bearer assertion.
- */
- readonly metadata?: (tokens: BaseTokens) => Record;
-};
-
-export type OAuthLoginProviders = Readonly>;
-
-/**
- * Splits a provider's registered redirect_uri into the host, port and path
- * the loopback callback server must bind. The port is fixed by the
- * authorization server's client registration, so it is read from the
- * provider rather than chosen here.
- */
-export function callbackTargetFor(config: OAuthClientConfig): {
- host: string;
- port: number;
- path: string;
-} {
- const url = new URL(config.redirectUri);
- const port = Number(url.port);
- if (!Number.isInteger(port) || port <= 0) {
- throw new Error(
- `OAuth redirect_uri must name an explicit loopback port; received ${config.redirectUri}.`,
- );
- }
- return { host: url.hostname, port, path: url.pathname };
-}
diff --git a/src/index.ts b/src/index.ts
index d390b6e..a1466ea 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -33,6 +33,13 @@ export {
type StartOAuthLoginOptions,
} from "./login.js";
+export {
+ callbackTargetFor,
+ loginWithProvider,
+ type LoginWithProviderOptions,
+ type OAuthLoginProvider,
+} from "./provider.js";
+
export {
createTokenSession,
isTokenExpired,
diff --git a/src/provider.test.ts b/src/provider.test.ts
new file mode 100644
index 0000000..ef146d9
--- /dev/null
+++ b/src/provider.test.ts
@@ -0,0 +1,97 @@
+import { createServer } from "node:net";
+
+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",
+ 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(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(configFor("http://127.0.0.1/callback")),
+ ).toThrow(/explicit loopback port/);
+ });
+});
diff --git a/src/provider.ts b/src/provider.ts
new file mode 100644
index 0000000..7dea9be
--- /dev/null
+++ b/src/provider.ts
@@ -0,0 +1,111 @@
+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
+ * 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. */
+ readonly oauthConfig: OAuthClientConfig;
+ /** Redeems the authorization code for tokens. */
+ readonly exchange: (
+ code: string,
+ verifier: string,
+ now: number,
+ ) => Promise;
+ /**
+ * Redeems a stored refresh token for a fresh access token, ahead of
+ * expiry. Absent when the provider issues no refreshable token: its
+ * credentials are then left for the person to sign in again.
+ */
+ readonly refresh?: (
+ refreshSecret: string,
+ now: number,
+ ) => Promise;
+ /**
+ * Credential metadata derived from the freshly-minted tokens — an account
+ * id a provider needs on every inference request, say. Never the raw
+ * id_token: the credential row is not a place to park a bearer assertion.
+ */
+ readonly metadata?: (tokens: BaseTokens) => Record;
+};
+
+export type OAuthLoginProviders = Readonly>;
+
+/**
+ * Splits a provider's registered redirect_uri into the host, port and path
+ * the loopback callback server must bind. The port is fixed by the
+ * authorization server's client registration, so it is read from the
+ * provider rather than chosen here.
+ */
+export function callbackTargetFor(config: OAuthClientConfig): {
+ host: string;
+ port: number;
+ path: string;
+} {
+ const url = new URL(config.redirectUri);
+ const port = Number(url.port);
+ if (!Number.isInteger(port) || port <= 0) {
+ throw new Error(
+ `OAuth redirect_uri must name an explicit loopback port; received ${config.redirectUri}.`,
+ );
+ }
+ return { host: url.hostname, port, path: url.pathname };
+}
+
+export const signedInHtml =
+ "Signed inSigned 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 failedSign-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;
+}