diff --git a/README.md b/README.md index 0ede4d8..5db4635 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/e2e/oauth-login-target.test.ts b/e2e/oauth-login-target.test.ts index a10e27f..e6da52c 100644 --- a/e2e/oauth-login-target.test.ts +++ b/e2e/oauth-login-target.test.ts @@ -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"; @@ -62,7 +62,7 @@ beforeAll(async () => { afterAll(() => t.close()); -function app(principalId: string) { +function app(principalId: string, owner?: "tenant") { const host = new Hono(); host.use(async (c, next) => { const at = new Date(0); @@ -94,6 +94,7 @@ function app(principalId: string) { requireGrant: async (_c, next) => { await next(); }, + ...(owner !== undefined ? { owner } : {}), }); return host; } @@ -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(); + 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"); + }); + }); }); diff --git a/src/hub/credentials.ts b/src/hub/credentials.ts index 9171ac2..28165ed 100644 --- a/src/hub/credentials.ts +++ b/src/hub/credentials.ts @@ -108,13 +108,23 @@ export type PersistOAuthCredentialOpts = { readonly scopes: readonly string[]; readonly tokens: BaseTokens; readonly metadata: Record; + 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. */ @@ -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"], @@ -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", @@ -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`. */ @@ -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", @@ -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, diff --git a/src/hub/index.ts b/src/hub/index.ts index e2e517e..01347cc 100644 --- a/src/hub/index.ts +++ b/src/hub/index.ts @@ -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, diff --git a/src/hub/mount.ts b/src/hub/mount.ts index 4f0789e..4c24410 100644 --- a/src/hub/mount.ts +++ b/src/hub/mount.ts @@ -30,6 +30,7 @@ import { OAUTH_TOKEN_URL_METADATA_KEY, OAuthCredentialTargetRejectedError, persistOAuthCredential, + type OAuthCredentialOwner, } from "./credentials.js"; import { createLoginStore, @@ -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; }; @@ -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, @@ -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); @@ -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); @@ -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), );