Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ export function installOAuthLogin(
}
```

A login's credential belongs to the principal who signed in. Interchange lets a catalog model provider or a credential binding use only an organizational credential (`principalId` null), so a login meant to back one mounts with `owner: "tenant"`: the credential is stored with no principal and no creator grant, as the stock `POST /credentials` stores an organizational one, and it never replaces an existing credential of the same name: start each login under a new name, then repoint the consumer, through the stock routes and their grants. Such a mount writes organizational credentials, so gate it with an administrative grant.

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.
Expand Down
129 changes: 127 additions & 2 deletions e2e/oauth-login-target.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
import type { TenantEnv } from "@intx/hub-api";
import { credential } from "@intx/db/schema";
import { credential, grant } from "@intx/db/schema";
import { eq } from "drizzle-orm";
import { Hono } from "hono";

Expand Down Expand Up @@ -62,7 +62,7 @@ beforeAll(async () => {

afterAll(() => t.close());

function app(principalId: string) {
function app(principalId: string, owner?: "tenant") {
const host = new Hono<TenantEnv>();
host.use(async (c, next) => {
const at = new Date(0);
Expand Down Expand Up @@ -94,6 +94,7 @@ function app(principalId: string) {
requireGrant: async (_c, next) => {
await next();
},
...(owner !== undefined ? { owner } : {}),
});
return host;
}
Expand Down Expand Up @@ -167,4 +168,128 @@ describe("the OAuth login target", () => {
const first = await login("first");
expect(await login("second")).toBe(first);
});

// Interchange resolves only an organizational credential (principalId
// null) for a catalog model provider, so a tenant-owned login must land
// with no principal, and must not be replaceable by a personal one.
describe("owned by the tenant", () => {
const persistAs = (
name: string,
principalId: string,
access: string,
owner?: "tenant",
) =>
persistOAuthCredential({
db: t.db,
cipher: plainCipher,
tenantId: "ta",
principalId,
providerId: "prov_inherited",
provider: "acme",
name,
scopes: ["openid"],
tokens: { access, refresh: "refresh" },
metadata: {},
...(owner !== undefined ? { owner } : {}),
});

it("stores no principal and no creator grant", async () => {
const id = await persistAs("shared", "p_a", "first", "tenant");
const row = await t.db.query.credential.findFirst({
where: eq(credential.id, id),
});
expect(row?.principalId).toBeNull();
const grants = await t.db.query.grant.findMany({
where: eq(grant.resource, `credential:${id}`),
});
expect(grants).toEqual([]);
});

it("never replaces an existing name, so a login cannot take over an org credential", async () => {
const id = await persistAs("org", "p_a", "first", "tenant");
await expect(persistAs("org", "p_b", "second", "tenant")).rejects.toThrow(
"already exists",
);
await expect(persistAs("org", "p_a", "third")).rejects.toThrow(
"already exists",
);
const row = await t.db.query.credential.findFirst({
where: eq(credential.id, id),
});
expect(row?.secret).toEndWith("|first");
});

it("stores a mount's completed login with no principal", async () => {
const host = new Hono<TenantEnv>();
host.use(async (c, next) => {
const at = new Date(0);
c.set("tenant", {
id: "ta",
name: "ta",
slug: "ta",
domain: "ta.example",
parentId: "tparent",
config: null,
createdAt: at,
updatedAt: at,
});
c.set("principal", {
id: "p_a",
tenantId: "ta",
kind: "user",
refId: "p_a",
status: "active",
createdAt: at,
updatedAt: at,
});
await next();
});
mountOAuthLogin(host, {
db: t.db,
cipher: plainCipher,
providers: {
granting: {
oauthConfig: {
...providers.acme.oauthConfig,
redirectUri: "http://127.0.0.1:1457/callback",
},
exchange: () => Promise.resolve({ access: "minted", refresh: "r" }),
},
},
requireGrant: async (_c, next) => {
await next();
},
owner: "tenant",
});
const started = await host.request("/oauth-logins", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
provider: "granting",
providerId: "prov_inherited",
credentialName: "mounted",
}),
});
expect(started.status).toBe(201);
const { loginId, authorizeUrl } = (await started.json()) as {
loginId: string;
authorizeUrl: string;
};
const state = new URL(authorizeUrl).searchParams.get("state");
await fetch(`http://127.0.0.1:1457/callback?code=c&state=${state}`);
let polled: { status?: string; credentialId?: string } = {};
for (let i = 0; i < 50 && polled.status !== "completed"; i++) {
polled = (await (
await host.request(`/oauth-logins/${loginId}`)
).json()) as typeof polled;
if (polled.status !== "completed") await Bun.sleep(20);
}
expect(polled.status).toBe("completed");
const row = await t.db.query.credential.findFirst({
where: eq(credential.id, polled.credentialId ?? ""),
});
expect(row?.principalId).toBeNull();
expect(row?.secret).toEndWith("|minted");
});
});
});
44 changes: 33 additions & 11 deletions src/hub/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,13 +108,23 @@ export type PersistOAuthCredentialOpts = {
readonly scopes: readonly string[];
readonly tokens: BaseTokens;
readonly metadata: Record<string, string>;
readonly owner?: OAuthCredentialOwner;
};

/**
* Who owns a login's credential. `principal` (the default) is the person who
* signed in. `tenant` is an organizational credential (`principalId` null),
* the only kind Interchange lets a catalog model provider or a credential
* binding use.
*/
export type OAuthCredentialOwner = "principal" | "tenant";

export type OAuthCredentialTarget = {
readonly tenantId: string;
readonly principalId: string;
readonly providerId: string;
readonly name: string;
readonly owner?: OAuthCredentialOwner;
};

/** Why a login may not write to its target, as the stock route would answer. */
Expand Down Expand Up @@ -145,18 +155,31 @@ async function findByName(
});
}

/** Only the caller's own OAuth login may be replaced in place. */
/**
* Only the caller's own personal OAuth login may be replaced in place. An
* organizational credential is never replaced: changing one is the stock
* `PATCH /credentials/:id` and its manage grant, which a login mount's
* single grant does not check.
*/
function isOwnOAuthLogin(
row: typeof credential.$inferSelect,
principalId: string,
target: OAuthCredentialTarget,
): boolean {
return row.type === "oauth_token" && row.principalId === principalId;
return (
target.owner !== "tenant" &&
row.type === "oauth_token" &&
row.principalId === target.principalId
);
}

function ownerPrincipalId(target: OAuthCredentialTarget): string | null {
return target.owner === "tenant" ? null : target.principalId;
}

/**
* The stock `POST /credentials` rules, applied to a login's target: the
* provider must sit in the caller's tenant ancestor chain, and the name may
* only collide with the caller's own `oauth_token` credential.
* only collide with the caller's own personal `oauth_token` credential.
*/
export async function checkOAuthCredentialTarget(
db: DB["db"],
Expand All @@ -173,10 +196,7 @@ export async function checkOAuthCredentialTarget(
return { status: 404, error: "Provider not found" };
}
const existing = await findByName(db, target);
if (
existing !== undefined &&
!isOwnOAuthLogin(existing, target.principalId)
) {
if (existing !== undefined && !isOwnOAuthLogin(existing, target)) {
return {
status: 409,
error: "Credential name already exists in this tenant",
Expand All @@ -189,7 +209,7 @@ export async function checkOAuthCredentialTarget(
* Store freshly-minted OAuth tokens as a stock Interchange `oauth_token`
* credential — the same row shape, AAD-bound encryption and creator grant
* the platform's own `POST /credentials` writes, so nothing downstream can
* tell a login-minted credential from a hand-entered one. Re-logging in
* tell a login-minted credential from a hand-entered one. A personal re-login
* under the same name replaces the caller's own secrets in place; any other
* rejected target throws `OAuthCredentialTargetRejectedError`.
*/
Expand Down Expand Up @@ -231,7 +251,7 @@ export async function persistOAuthCredential(
id: credentialId,
tenantId: opts.tenantId,
providerId: opts.providerId,
principalId: opts.principalId,
principalId: ownerPrincipalId(opts),
oauthClientId: null,
name: opts.name,
type: "oauth_token",
Expand All @@ -245,7 +265,9 @@ export async function persistOAuthCredential(
updatedAt: now,
});
// Mirrors the stock route: a personal credential grants its owner
// durable `use` authority so no separate manual grant is needed.
// durable `use` authority so no separate manual grant is needed. An
// organizational credential is used by tenant ownership and gets none.
if (opts.owner === "tenant") return;
await tx.insert(grantTable).values({
id: generateId("grant"),
tenantId: opts.tenantId,
Expand Down
1 change: 1 addition & 0 deletions src/hub/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export {
OAUTH_PROVIDER_METADATA_KEY,
OAUTH_RESOURCE_METADATA_KEY,
OAUTH_TOKEN_URL_METADATA_KEY,
type OAuthCredentialOwner,
} from "./credentials.js";
export {
createOAuthRefreshStore,
Expand Down
17 changes: 13 additions & 4 deletions src/hub/mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
OAUTH_TOKEN_URL_METADATA_KEY,
OAuthCredentialTargetRejectedError,
persistOAuthCredential,
type OAuthCredentialOwner,
} from "./credentials.js";
import {
createLoginStore,
Expand Down Expand Up @@ -169,6 +170,12 @@ export type MountOAuthLoginOpts = {
*/
readonly clientMetadataUrl?: string;
readonly loginTtlMs?: number;
/**
* Who owns the credentials these logins write; defaults to `principal`.
* `tenant` writes organizational credentials, so gate the mount with an
* administrative grant. A tenant login never replaces an existing name.
*/
readonly owner?: OAuthCredentialOwner;
/** Reported when a login fails after the request that started it returned. */
readonly onError?: (error: unknown, context: { provider: string }) => void;
};
Expand Down Expand Up @@ -201,7 +208,7 @@ export function mountOAuthLogin(
const ttlMs = opts.loginTtlMs ?? DEFAULT_LOGIN_TTL_MS;
if (opts.callbackUrl !== undefined) assertSecureCallbackUrl(opts.callbackUrl);

const owner = (c: {
const caller = (c: {
get(key: "tenant" | "principal"): { id: string };
}): { tenantId: string; principalId: string } => ({
tenantId: c.get("tenant").id,
Expand Down Expand Up @@ -331,12 +338,13 @@ export function mountOAuthLogin(
if (body instanceof type.errors) {
return c.json({ error: body.summary }, 400);
}
const { tenantId, principalId } = owner(c);
const { tenantId, principalId } = caller(c);
const rejected = await checkOAuthCredentialTarget(opts.db, {
tenantId,
principalId,
providerId: body.providerId,
name: body.credentialName,
...(opts.owner !== undefined ? { owner: opts.owner } : {}),
});
if (rejected !== null) {
return c.json({ error: rejected.error }, rejected.status);
Expand Down Expand Up @@ -421,6 +429,7 @@ export function mountOAuthLogin(
scopes: flow.config.scopes,
tokens: staged.profile.tokens,
metadata: flow.metadata(staged.profile.tokens),
...(opts.owner !== undefined ? { owner: opts.owner } : {}),
});
logins.settle(loginId, { status: "completed", credentialId });
releaseLoginId(flow.provider, loginId);
Expand Down Expand Up @@ -452,14 +461,14 @@ export function mountOAuthLogin(
app.get("/oauth-logins/:loginId", opts.requireGrant, (c) => {
const state: LoginState | undefined = logins.read(
c.req.param("loginId"),
owner(c),
caller(c),
);
if (state === undefined) return c.json({ error: "not_found" }, 404);
return c.json(state);
});

app.delete("/oauth-logins/:loginId", opts.requireGrant, (c) =>
logins.cancel(c.req.param("loginId"), owner(c))
logins.cancel(c.req.param("loginId"), caller(c))
? c.body(null, 204)
: c.json({ error: "not_found" }, 404),
);
Expand Down
Loading