PKCE + loopback OAuth for an Interchange host: mint an access token the
harness injects as InferenceSource.apiKey. A loopback callback server,
token exchange/refresh, and an expiring-token session that refreshes ahead
of expiry and coalesces concurrent refreshes. It is not a credential vault
and it is not a client for any particular issuer — endpoints and client id
come from the caller (typically @corbits/xai-provider or
@corbits/codex-provider). One flow shape: public client, PKCE S256,
fixed-port loopback, no client secret.
bun add @corbits/oauth-core
# or
npm install @corbits/oauth-coreThe package ships TypeScript source and needs no build step. It requires Bun >= 1.2, which consumes the TypeScript source directly.
import {
baseTokensFromResponse,
buildAuthorizeUrl,
createTokenSession,
exchangeCode,
refreshTokenRequest,
startCallbackServer,
startOAuthLogin,
type BaseTokens,
type OAuthClientConfig,
} from "@corbits/oauth-core";
const config: OAuthClientConfig = {
clientId: "my-client-id",
authorizeUrl: "https://provider.example.com/oauth/authorize",
tokenUrl: "https://provider.example.com/oauth/token",
redirectUri: "http://127.0.0.1:8765/callback",
scopes: ["profile"],
tokenTimeoutMs: 10_000,
};
// `persist`, `load`, and `update` below are the host's storage layer — write
// an Interchange `oauth_token` credential or use the OS vault. This package
// never stores.
const handle = await startOAuthLogin(
{ profile: "default", signal: new AbortController().signal },
{
startCallbackServer: (state) =>
startCallbackServer(state, {
port: 8765,
// Optional; defaults to 127.0.0.1.
host: "127.0.0.1",
path: "/callback",
doneHtml:
"<html><body>Signed in — you can close this tab.</body></html>",
failedHtml: (reason) =>
`<html><body>Sign-in failed: ${reason}</body></html>`,
}),
buildAuthorizeUrl: (pkce, state) => buildAuthorizeUrl(config, pkce, state),
exchangeCode: async (code, verifier, now) =>
baseTokensFromResponse(
await exchangeCode(config, code, verifier),
now,
undefined,
),
// Host: Interchange `oauth_token` or the OS vault. This package does not store.
saveProfile: persist,
},
);
const staged = await handle.completed;
await staged.commit();
const session = createTokenSession<BaseTokens, string>({
skewMs: 30_000,
loadProfile: load,
updateTokens: update,
refreshTokens: async (refreshToken, now) =>
baseTokensFromResponse(
await refreshTokenRequest(config, refreshToken),
now,
refreshToken,
),
toAccess: (tokens) => tokens.access,
});
const accessToken = await session.getValidToken("default");
// Host: put `accessToken` on InferenceSource.apiKey. The harness injects it at send.See src/index.ts for the full export surface.
A server-only subpath for an Interchange hub that wants a browser-driven login: the person clicks a button in a web client, the hub runs the whole loopback PKCE flow in its own process, and the browser only ever sees an authorize URL, a login id, and -- on success -- the id of the credential the tokens were stored under.
import { mountOAuthLogin } from "@corbits/oauth-core/hub";
const api = new Hono<TenantEnv>();
mountOAuthLogin(api, {
db,
cipher: credentialCipher,
requireGrant: requireGrant("credential:*", "create"),
providers: {
// The host owns the provider packages; this library never imports one.
someProvider: {
oauthConfig,
exchange: (code, verifier, now) => exchangeSomeCode(code, verifier, now),
},
},
});
app.route("/api/tenants/:tenantId", api);Routes, all under the tenant prefix the host mounts them on:
| Route | What it does |
|---|---|
GET /oauth-logins/providers |
The provider names this host registered. |
POST /oauth-logins |
Starts a login; returns { loginId, authorizeUrl }. |
GET /oauth-logins/:loginId |
pending / completed (with credentialId) / failed / cancelled. |
DELETE /oauth-logins/:loginId |
Cancels an abandoned login and frees its fixed callback port. |
The tokens are written as a stock oauth_token credential with the same
row shape, AAD-bound encryption and creator grant the platform's own
POST /credentials writes. The PKCE verifier never leaves the process and
no raw id_token is ever stored -- a provider that needs an account id
supplies a metadata projection instead. Logins expire (five minutes by
default) so an abandoned one releases its fixed loopback port.
createOAuthTokenRefresher walks oauth_token credentials ahead of expiry
on a timer, since stock Interchange has no serving-time refresh hook.
start() runs one pass immediately, so tokens that lapsed while the hub
was down are fresh before anything re-registers, then arms the interval;
stop() clears the interval and lets an in-flight pass finish. Its
per-credential decision (claim, refresh, write) is also exported as
refreshCredential, the shape a future Interchange serving-time hook
would call directly for one credential.
- Nothing here names a provider, product, or default client id — config and callback HTML are caller-supplied. Persistence is the host's callback.
expiresAtonBaseTokensis optional: RFC 6749 §5.1 makesexpires_inRECOMMENDED, not required, and this package never guesses a lifetime the server didn't send.startOAuthLoginstages the exchanged profile behind acommit()the caller controls, so persistence can be gated on the host's own setup succeeding first.- Callback hostnames are resolved once before binding. Every returned address
must be loopback (
127.0.0.0/8or::1), and the validated address is bound directly; wildcard and routable hosts are rejected. This is a cleartext safeguard: the redirect carrying the authorization code travels as plain HTTP, so a routable or wildcard bind would expose it on the network where passive capture defeats the state check. - The token session coalesces concurrent refreshes for the same profile
into one in-flight request, since a provider that rotates refresh tokens
would otherwise invalidate a racing second attempt. Interchange's harness
injects
apiKeyat send; it does not refresh provider tokens. fetch,now, and refresh skew are all injectable, so login and refresh paths are testable without patching globals.
- No credential persistence — the host writes an Interchange
oauth_tokenor the OS vault. - No device-code flow — loopback redirect only.
- No confidential client / client secret support — public clients (PKCE) only.
- No token revocation endpoint call.
- Fixed-port loopback only, no dynamic port selection: authorization
servers only accept the registered
redirect_urifor the client, so a randomly chosen port would be rejected.
LGPL-2.1-only. See LICENSE.