Skip to content

Commit 2c3e867

Browse files
Merge pull request #43 from corbitsdev/feat/tenant-owned-login
feat(hub): store a login as a tenant-owned credential
2 parents cd20117 + 04f5a2c commit 2c3e867

5 files changed

Lines changed: 176 additions & 17 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,8 @@ export function installOAuthLogin(
152152
}
153153
```
154154

155+
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.
156+
155157
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`.
156158

157159
The hub entry will move to a separate `@corbits/oauth-hub` package in a later release.

‎e2e/oauth-login-target.test.ts‎

Lines changed: 127 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
22
import type { TenantEnv } from "@intx/hub-api";
3-
import { credential } from "@intx/db/schema";
3+
import { credential, grant } from "@intx/db/schema";
44
import { eq } from "drizzle-orm";
55
import { Hono } from "hono";
66

@@ -62,7 +62,7 @@ beforeAll(async () => {
6262

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

65-
function app(principalId: string) {
65+
function app(principalId: string, owner?: "tenant") {
6666
const host = new Hono<TenantEnv>();
6767
host.use(async (c, next) => {
6868
const at = new Date(0);
@@ -94,6 +94,7 @@ function app(principalId: string) {
9494
requireGrant: async (_c, next) => {
9595
await next();
9696
},
97+
...(owner !== undefined ? { owner } : {}),
9798
});
9899
return host;
99100
}
@@ -167,4 +168,128 @@ describe("the OAuth login target", () => {
167168
const first = await login("first");
168169
expect(await login("second")).toBe(first);
169170
});
171+
172+
// Interchange resolves only an organizational credential (principalId
173+
// null) for a catalog model provider, so a tenant-owned login must land
174+
// with no principal, and must not be replaceable by a personal one.
175+
describe("owned by the tenant", () => {
176+
const persistAs = (
177+
name: string,
178+
principalId: string,
179+
access: string,
180+
owner?: "tenant",
181+
) =>
182+
persistOAuthCredential({
183+
db: t.db,
184+
cipher: plainCipher,
185+
tenantId: "ta",
186+
principalId,
187+
providerId: "prov_inherited",
188+
provider: "acme",
189+
name,
190+
scopes: ["openid"],
191+
tokens: { access, refresh: "refresh" },
192+
metadata: {},
193+
...(owner !== undefined ? { owner } : {}),
194+
});
195+
196+
it("stores no principal and no creator grant", async () => {
197+
const id = await persistAs("shared", "p_a", "first", "tenant");
198+
const row = await t.db.query.credential.findFirst({
199+
where: eq(credential.id, id),
200+
});
201+
expect(row?.principalId).toBeNull();
202+
const grants = await t.db.query.grant.findMany({
203+
where: eq(grant.resource, `credential:${id}`),
204+
});
205+
expect(grants).toEqual([]);
206+
});
207+
208+
it("never replaces an existing name, so a login cannot take over an org credential", async () => {
209+
const id = await persistAs("org", "p_a", "first", "tenant");
210+
await expect(persistAs("org", "p_b", "second", "tenant")).rejects.toThrow(
211+
"already exists",
212+
);
213+
await expect(persistAs("org", "p_a", "third")).rejects.toThrow(
214+
"already exists",
215+
);
216+
const row = await t.db.query.credential.findFirst({
217+
where: eq(credential.id, id),
218+
});
219+
expect(row?.secret).toEndWith("|first");
220+
});
221+
222+
it("stores a mount's completed login with no principal", async () => {
223+
const host = new Hono<TenantEnv>();
224+
host.use(async (c, next) => {
225+
const at = new Date(0);
226+
c.set("tenant", {
227+
id: "ta",
228+
name: "ta",
229+
slug: "ta",
230+
domain: "ta.example",
231+
parentId: "tparent",
232+
config: null,
233+
createdAt: at,
234+
updatedAt: at,
235+
});
236+
c.set("principal", {
237+
id: "p_a",
238+
tenantId: "ta",
239+
kind: "user",
240+
refId: "p_a",
241+
status: "active",
242+
createdAt: at,
243+
updatedAt: at,
244+
});
245+
await next();
246+
});
247+
mountOAuthLogin(host, {
248+
db: t.db,
249+
cipher: plainCipher,
250+
providers: {
251+
granting: {
252+
oauthConfig: {
253+
...providers.acme.oauthConfig,
254+
redirectUri: "http://127.0.0.1:1457/callback",
255+
},
256+
exchange: () => Promise.resolve({ access: "minted", refresh: "r" }),
257+
},
258+
},
259+
requireGrant: async (_c, next) => {
260+
await next();
261+
},
262+
owner: "tenant",
263+
});
264+
const started = await host.request("/oauth-logins", {
265+
method: "POST",
266+
headers: { "content-type": "application/json" },
267+
body: JSON.stringify({
268+
provider: "granting",
269+
providerId: "prov_inherited",
270+
credentialName: "mounted",
271+
}),
272+
});
273+
expect(started.status).toBe(201);
274+
const { loginId, authorizeUrl } = (await started.json()) as {
275+
loginId: string;
276+
authorizeUrl: string;
277+
};
278+
const state = new URL(authorizeUrl).searchParams.get("state");
279+
await fetch(`http://127.0.0.1:1457/callback?code=c&state=${state}`);
280+
let polled: { status?: string; credentialId?: string } = {};
281+
for (let i = 0; i < 50 && polled.status !== "completed"; i++) {
282+
polled = (await (
283+
await host.request(`/oauth-logins/${loginId}`)
284+
).json()) as typeof polled;
285+
if (polled.status !== "completed") await Bun.sleep(20);
286+
}
287+
expect(polled.status).toBe("completed");
288+
const row = await t.db.query.credential.findFirst({
289+
where: eq(credential.id, polled.credentialId ?? ""),
290+
});
291+
expect(row?.principalId).toBeNull();
292+
expect(row?.secret).toEndWith("|minted");
293+
});
294+
});
170295
});

‎src/hub/credentials.ts‎

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -108,13 +108,23 @@ export type PersistOAuthCredentialOpts = {
108108
readonly scopes: readonly string[];
109109
readonly tokens: BaseTokens;
110110
readonly metadata: Record<string, string>;
111+
readonly owner?: OAuthCredentialOwner;
111112
};
112113

114+
/**
115+
* Who owns a login's credential. `principal` (the default) is the person who
116+
* signed in. `tenant` is an organizational credential (`principalId` null),
117+
* the only kind Interchange lets a catalog model provider or a credential
118+
* binding use.
119+
*/
120+
export type OAuthCredentialOwner = "principal" | "tenant";
121+
113122
export type OAuthCredentialTarget = {
114123
readonly tenantId: string;
115124
readonly principalId: string;
116125
readonly providerId: string;
117126
readonly name: string;
127+
readonly owner?: OAuthCredentialOwner;
118128
};
119129

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

148-
/** Only the caller's own OAuth login may be replaced in place. */
158+
/**
159+
* Only the caller's own personal OAuth login may be replaced in place. An
160+
* organizational credential is never replaced: changing one is the stock
161+
* `PATCH /credentials/:id` and its manage grant, which a login mount's
162+
* single grant does not check.
163+
*/
149164
function isOwnOAuthLogin(
150165
row: typeof credential.$inferSelect,
151-
principalId: string,
166+
target: OAuthCredentialTarget,
152167
): boolean {
153-
return row.type === "oauth_token" && row.principalId === principalId;
168+
return (
169+
target.owner !== "tenant" &&
170+
row.type === "oauth_token" &&
171+
row.principalId === target.principalId
172+
);
173+
}
174+
175+
function ownerPrincipalId(target: OAuthCredentialTarget): string | null {
176+
return target.owner === "tenant" ? null : target.principalId;
154177
}
155178

156179
/**
157180
* The stock `POST /credentials` rules, applied to a login's target: the
158181
* provider must sit in the caller's tenant ancestor chain, and the name may
159-
* only collide with the caller's own `oauth_token` credential.
182+
* only collide with the caller's own personal `oauth_token` credential.
160183
*/
161184
export async function checkOAuthCredentialTarget(
162185
db: DB["db"],
@@ -173,10 +196,7 @@ export async function checkOAuthCredentialTarget(
173196
return { status: 404, error: "Provider not found" };
174197
}
175198
const existing = await findByName(db, target);
176-
if (
177-
existing !== undefined &&
178-
!isOwnOAuthLogin(existing, target.principalId)
179-
) {
199+
if (existing !== undefined && !isOwnOAuthLogin(existing, target)) {
180200
return {
181201
status: 409,
182202
error: "Credential name already exists in this tenant",
@@ -189,7 +209,7 @@ export async function checkOAuthCredentialTarget(
189209
* Store freshly-minted OAuth tokens as a stock Interchange `oauth_token`
190210
* credential — the same row shape, AAD-bound encryption and creator grant
191211
* the platform's own `POST /credentials` writes, so nothing downstream can
192-
* tell a login-minted credential from a hand-entered one. Re-logging in
212+
* tell a login-minted credential from a hand-entered one. A personal re-login
193213
* under the same name replaces the caller's own secrets in place; any other
194214
* rejected target throws `OAuthCredentialTargetRejectedError`.
195215
*/
@@ -231,7 +251,7 @@ export async function persistOAuthCredential(
231251
id: credentialId,
232252
tenantId: opts.tenantId,
233253
providerId: opts.providerId,
234-
principalId: opts.principalId,
254+
principalId: ownerPrincipalId(opts),
235255
oauthClientId: null,
236256
name: opts.name,
237257
type: "oauth_token",
@@ -245,7 +265,9 @@ export async function persistOAuthCredential(
245265
updatedAt: now,
246266
});
247267
// Mirrors the stock route: a personal credential grants its owner
248-
// durable `use` authority so no separate manual grant is needed.
268+
// durable `use` authority so no separate manual grant is needed. An
269+
// organizational credential is used by tenant ownership and gets none.
270+
if (opts.owner === "tenant") return;
249271
await tx.insert(grantTable).values({
250272
id: generateId("grant"),
251273
tenantId: opts.tenantId,

‎src/hub/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export {
1414
OAUTH_PROVIDER_METADATA_KEY,
1515
OAUTH_RESOURCE_METADATA_KEY,
1616
OAUTH_TOKEN_URL_METADATA_KEY,
17+
type OAuthCredentialOwner,
1718
} from "./credentials.js";
1819
export {
1920
createOAuthRefreshStore,

‎src/hub/mount.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
OAUTH_TOKEN_URL_METADATA_KEY,
3131
OAuthCredentialTargetRejectedError,
3232
persistOAuthCredential,
33+
type OAuthCredentialOwner,
3334
} from "./credentials.js";
3435
import {
3536
createLoginStore,
@@ -169,6 +170,12 @@ export type MountOAuthLoginOpts = {
169170
*/
170171
readonly clientMetadataUrl?: string;
171172
readonly loginTtlMs?: number;
173+
/**
174+
* Who owns the credentials these logins write; defaults to `principal`.
175+
* `tenant` writes organizational credentials, so gate the mount with an
176+
* administrative grant. A tenant login never replaces an existing name.
177+
*/
178+
readonly owner?: OAuthCredentialOwner;
172179
/** Reported when a login fails after the request that started it returned. */
173180
readonly onError?: (error: unknown, context: { provider: string }) => void;
174181
};
@@ -201,7 +208,7 @@ export function mountOAuthLogin(
201208
const ttlMs = opts.loginTtlMs ?? DEFAULT_LOGIN_TTL_MS;
202209
if (opts.callbackUrl !== undefined) assertSecureCallbackUrl(opts.callbackUrl);
203210

204-
const owner = (c: {
211+
const caller = (c: {
205212
get(key: "tenant" | "principal"): { id: string };
206213
}): { tenantId: string; principalId: string } => ({
207214
tenantId: c.get("tenant").id,
@@ -331,12 +338,13 @@ export function mountOAuthLogin(
331338
if (body instanceof type.errors) {
332339
return c.json({ error: body.summary }, 400);
333340
}
334-
const { tenantId, principalId } = owner(c);
341+
const { tenantId, principalId } = caller(c);
335342
const rejected = await checkOAuthCredentialTarget(opts.db, {
336343
tenantId,
337344
principalId,
338345
providerId: body.providerId,
339346
name: body.credentialName,
347+
...(opts.owner !== undefined ? { owner: opts.owner } : {}),
340348
});
341349
if (rejected !== null) {
342350
return c.json({ error: rejected.error }, rejected.status);
@@ -421,6 +429,7 @@ export function mountOAuthLogin(
421429
scopes: flow.config.scopes,
422430
tokens: staged.profile.tokens,
423431
metadata: flow.metadata(staged.profile.tokens),
432+
...(opts.owner !== undefined ? { owner: opts.owner } : {}),
424433
});
425434
logins.settle(loginId, { status: "completed", credentialId });
426435
releaseLoginId(flow.provider, loginId);
@@ -452,14 +461,14 @@ export function mountOAuthLogin(
452461
app.get("/oauth-logins/:loginId", opts.requireGrant, (c) => {
453462
const state: LoginState | undefined = logins.read(
454463
c.req.param("loginId"),
455-
owner(c),
464+
caller(c),
456465
);
457466
if (state === undefined) return c.json({ error: "not_found" }, 404);
458467
return c.json(state);
459468
});
460469

461470
app.delete("/oauth-logins/:loginId", opts.requireGrant, (c) =>
462-
logins.cancel(c.req.param("loginId"), owner(c))
471+
logins.cancel(c.req.param("loginId"), caller(c))
463472
? c.body(null, 204)
464473
: c.json({ error: "not_found" }, 404),
465474
);

0 commit comments

Comments
 (0)