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.
- Provider-agnostic. A provider is a config plus an
exchangefunction the host supplies. The package never names or depends on one. - One call to sign in.
loginWithProviderbinds the provider's registered redirect URI, opens the browser, exchanges the code, and saves the profile. - 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.
- Hub-ready. The
/hubentry 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'sCredentialCipher.
bun add @corbits/oauth-coreThis 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.
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.
- 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/hubentry mounts login routes and refreshesoauth_tokencredentials that anInferenceSourcenames bycredentialId. - Providers: a provider package or the host itself supplies an
OAuthLoginProvider. - MCP servers:
discoverMcpLoginEntrybuilds theOAuthClientConfigfor servers that publish their authorization server instead of a fixed client.
| 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. |
| 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.
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:
import {
createOAuthTokenRefresher,
mountOAuthLogin,
type OAuthLoginProviders,
type OAuthTokenRefresher,
} from "@corbits/oauth-core/hub";
import type { DB } from "@intx/db";
import type { TenantEnv } from "@intx/hub-api";
import type { CredentialCipher } from "@intx/types";
import { Hono, type MiddlewareHandler } from "hono";
export function installOAuthLogin(
app: Hono<TenantEnv>,
opts: {
db: DB["db"];
cipher: CredentialCipher;
requireGrant: MiddlewareHandler<TenantEnv>;
providers: OAuthLoginProviders;
onRefreshed: (context: { tenantId: string; credentialId: string }) => void;
},
): OAuthTokenRefresher {
const oauthLoginApi = new Hono<TenantEnv>();
mountOAuthLogin(oauthLoginApi, {
db: opts.db,
cipher: opts.cipher,
requireGrant: opts.requireGrant,
providers: opts.providers,
});
app.route("/api/tenants/:tenantId", oauthLoginApi);
const refresher = createOAuthTokenRefresher({
db: opts.db,
cipher: opts.cipher,
providers: opts.providers,
onRefreshed: opts.onRefreshed,
});
refresher.start();
return refresher;
}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.
The hub entry will move to a separate @corbits/oauth-hub package in a later release.
OAuthLoginProviderandcallbackTargetForimport from@corbits/oauth-core, not@corbits/oauth-core/hub.CallbackServer.portis required. A customstartCallbackServermust return the bound port.- Stored profiles and
oauth_tokencredentials are unchanged and keep working.
LGPL-2.1-only.