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 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/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 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, "")}`; + +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; +}