mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
feat(oauth): restack OAuth backbone
Restack the OAuth backend from PR #5584 onto current master for PAP-9233, preserving the provider registry, OAuth routes, refresh worker, resolver integration, tests, and docs. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
0808b388ee
commit
382cb3bede
94 files changed
+14984
-16
No files matched your search
@@ -0,0 +1,28 @@
|
||||
id: atlassian
|
||||
displayName: Atlassian (Jira / Confluence)
|
||||
docUrl: https://developer.atlassian.com/cloud/jira/platform/oauth-2-3lo-apps/
|
||||
clientCredentials:
|
||||
clientIdEnv: ATLASSIAN_OAUTH_CLIENT_ID
|
||||
clientSecretEnv: ATLASSIAN_OAUTH_CLIENT_SECRET
|
||||
endpoints:
|
||||
authorize: https://auth.atlassian.com/authorize
|
||||
token: https://auth.atlassian.com/oauth/token
|
||||
accountInfo: https://api.atlassian.com/me
|
||||
scopes:
|
||||
default: [read:jira-work, read:jira-user, offline_access]
|
||||
offered:
|
||||
- read:jira-work
|
||||
- read:jira-user
|
||||
- write:jira-work
|
||||
- manage:jira-project
|
||||
- read:confluence-content.all
|
||||
- write:confluence-content
|
||||
- offline_access
|
||||
pkce: required
|
||||
authMethod: post
|
||||
responseFormat: json
|
||||
accountIdField: account_id
|
||||
accountLabelField: email
|
||||
refresh:
|
||||
supported: true
|
||||
rotatesRefreshToken: true
|
||||
@@ -0,0 +1,22 @@
|
||||
id: github
|
||||
displayName: GitHub
|
||||
iconUrl: https://github.githubassets.com/images/icons/oauth.svg
|
||||
docUrl: https://docs.paperclip.ai/integrations/github
|
||||
clientCredentials:
|
||||
clientIdEnv: GITHUB_OAUTH_CLIENT_ID
|
||||
clientSecretEnv: GITHUB_OAUTH_CLIENT_SECRET
|
||||
endpoints:
|
||||
authorize: https://github.com/login/oauth/authorize
|
||||
token: https://github.com/login/oauth/access_token
|
||||
revoke: https://api.github.com/applications/{client_id}/grant
|
||||
accountInfo: https://api.github.com/user
|
||||
scopes:
|
||||
default: [repo, read:user, user:email]
|
||||
offered: [repo, read:user, user:email, workflow, write:packages]
|
||||
pkce: required
|
||||
authMethod: post
|
||||
responseFormat: json
|
||||
accountIdField: id
|
||||
accountLabelField: login
|
||||
refresh:
|
||||
supported: false
|
||||
@@ -0,0 +1,29 @@
|
||||
id: google-workspace
|
||||
displayName: Google Workspace
|
||||
docUrl: https://developers.google.com/identity/protocols/oauth2/web-server
|
||||
clientCredentials:
|
||||
clientIdEnv: GOOGLE_OAUTH_CLIENT_ID
|
||||
clientSecretEnv: GOOGLE_OAUTH_CLIENT_SECRET
|
||||
endpoints:
|
||||
authorize: https://accounts.google.com/o/oauth2/v2/auth
|
||||
token: https://oauth2.googleapis.com/token
|
||||
revoke: https://oauth2.googleapis.com/revoke
|
||||
accountInfo: https://www.googleapis.com/oauth2/v3/userinfo
|
||||
scopes:
|
||||
default: [openid, email, profile]
|
||||
offered:
|
||||
- openid
|
||||
- email
|
||||
- profile
|
||||
- https://www.googleapis.com/auth/drive.readonly
|
||||
- https://www.googleapis.com/auth/spreadsheets
|
||||
- https://www.googleapis.com/auth/calendar.readonly
|
||||
- https://www.googleapis.com/auth/gmail.readonly
|
||||
pkce: required
|
||||
authMethod: post
|
||||
responseFormat: json
|
||||
accountIdField: sub
|
||||
accountLabelField: email
|
||||
refresh:
|
||||
supported: true
|
||||
rotatesRefreshToken: false
|
||||
@@ -0,0 +1,27 @@
|
||||
id: linear
|
||||
displayName: Linear
|
||||
docUrl: https://developers.linear.app/docs/oauth/authentication
|
||||
clientCredentials:
|
||||
clientIdEnv: LINEAR_OAUTH_CLIENT_ID
|
||||
clientSecretEnv: LINEAR_OAUTH_CLIENT_SECRET
|
||||
endpoints:
|
||||
authorize: https://linear.app/oauth/authorize
|
||||
token: https://api.linear.app/oauth/token
|
||||
# Linear's primary API is GraphQL POST-only — the default GET-based
|
||||
# `fetchAccountInfo` cannot reach it. Linear exposes an OIDC-style userinfo
|
||||
# endpoint on the OAuth surface; we use that here so the default shape can
|
||||
# consume the response. If a real flow surfaces that this endpoint is
|
||||
# unavailable for the configured scopes, Phase 7 integration tests will
|
||||
# catch it and a Linear shape module can be added to override account-info
|
||||
# fetching.
|
||||
accountInfo: https://api.linear.app/oauth/userinfo
|
||||
scopes:
|
||||
default: [read]
|
||||
offered: [read, write, admin]
|
||||
pkce: required
|
||||
authMethod: post
|
||||
responseFormat: json
|
||||
accountIdField: sub
|
||||
accountLabelField: email
|
||||
refresh:
|
||||
supported: false
|
||||
@@ -0,0 +1,29 @@
|
||||
id: microsoft-graph
|
||||
displayName: Microsoft Graph
|
||||
docUrl: https://learn.microsoft.com/en-us/graph/auth-overview
|
||||
clientCredentials:
|
||||
clientIdEnv: MICROSOFT_OAUTH_CLIENT_ID
|
||||
clientSecretEnv: MICROSOFT_OAUTH_CLIENT_SECRET
|
||||
endpoints:
|
||||
authorize: https://login.microsoftonline.com/common/oauth2/v2.0/authorize
|
||||
token: https://login.microsoftonline.com/common/oauth2/v2.0/token
|
||||
accountInfo: https://graph.microsoft.com/v1.0/me
|
||||
scopes:
|
||||
default: [User.Read, offline_access]
|
||||
offered:
|
||||
- User.Read
|
||||
- offline_access
|
||||
- Mail.Read
|
||||
- Mail.Send
|
||||
- Files.Read.All
|
||||
- Calendars.Read
|
||||
- Sites.Read.All
|
||||
pkce: required
|
||||
authMethod: post
|
||||
responseFormat: json
|
||||
accountIdField: id
|
||||
accountLabelField: displayName
|
||||
refresh:
|
||||
supported: true
|
||||
rotatesRefreshToken: true
|
||||
shape: microsoft
|
||||
@@ -0,0 +1,23 @@
|
||||
id: notion
|
||||
displayName: Notion
|
||||
docUrl: https://developers.notion.com/docs/authorization
|
||||
clientCredentials:
|
||||
clientIdEnv: NOTION_OAUTH_CLIENT_ID
|
||||
clientSecretEnv: NOTION_OAUTH_CLIENT_SECRET
|
||||
endpoints:
|
||||
authorize: https://api.notion.com/v1/oauth/authorize
|
||||
token: https://api.notion.com/v1/oauth/token
|
||||
accountInfo: https://api.notion.com/v1/users/me
|
||||
# Notion uses workspace-bound bot tokens — no refresh, no scopes parameter.
|
||||
# `accountInfo` returns the bot owner's user record; the dot-paths reach into
|
||||
# the nested response.
|
||||
scopes:
|
||||
default: []
|
||||
offered: []
|
||||
pkce: optional
|
||||
authMethod: basic
|
||||
responseFormat: json
|
||||
accountIdField: bot.owner.user.id
|
||||
accountLabelField: bot.owner.user.name
|
||||
refresh:
|
||||
supported: false
|
||||
@@ -0,0 +1,30 @@
|
||||
id: slack
|
||||
displayName: Slack
|
||||
docUrl: https://api.slack.com/authentication/oauth-v2
|
||||
clientCredentials:
|
||||
clientIdEnv: SLACK_OAUTH_CLIENT_ID
|
||||
clientSecretEnv: SLACK_OAUTH_CLIENT_SECRET
|
||||
endpoints:
|
||||
authorize: https://slack.com/oauth/v2/authorize
|
||||
token: https://slack.com/api/oauth.v2.access
|
||||
accountInfo: https://slack.com/api/auth.test
|
||||
scopes:
|
||||
default: [channels:read, chat:write]
|
||||
offered:
|
||||
- channels:read
|
||||
- chat:write
|
||||
- channels:history
|
||||
- files:read
|
||||
- users:read
|
||||
- im:history
|
||||
- mpim:history
|
||||
- groups:history
|
||||
pkce: optional
|
||||
authMethod: post
|
||||
responseFormat: json
|
||||
accountIdField: team_id
|
||||
accountLabelField: team
|
||||
refresh:
|
||||
supported: true
|
||||
rotatesRefreshToken: true
|
||||
shape: slack
|
||||
@@ -76,6 +76,7 @@
|
||||
"pino-pretty": "^13.1.3",
|
||||
"sharp": "^0.34.5",
|
||||
"ws": "^8.19.0",
|
||||
"yaml": "^2.5.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createLocalAgentJwt, verifyLocalAgentJwt } from "../agent-auth-jwt.js";
|
||||
|
||||
@@ -97,4 +98,67 @@ describe("agent local JWT", () => {
|
||||
process.env[audienceEnv] = "paperclip-api";
|
||||
expect(verifyLocalAgentJwt(token!)).toBeNull();
|
||||
});
|
||||
|
||||
it("round-trips an oauth.connectionIds claim through create + verify", () => {
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
const token = createLocalAgentJwt(
|
||||
"agent-1",
|
||||
"company-1",
|
||||
"claude_local",
|
||||
"run-1",
|
||||
{ connectionIds: ["conn-a", "conn-b"] },
|
||||
);
|
||||
expect(typeof token).toBe("string");
|
||||
|
||||
const claims = verifyLocalAgentJwt(token!);
|
||||
expect(claims?.oauth?.connectionIds).toEqual(["conn-a", "conn-b"]);
|
||||
});
|
||||
|
||||
it("omits oauth claim when not provided", () => {
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
const token = createLocalAgentJwt("agent-1", "company-1", "claude_local", "run-1");
|
||||
const claims = verifyLocalAgentJwt(token!);
|
||||
expect(claims).not.toBeNull();
|
||||
expect(claims?.oauth).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves an empty connectionIds array round-trip", () => {
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
const token = createLocalAgentJwt(
|
||||
"agent-1",
|
||||
"company-1",
|
||||
"claude_local",
|
||||
"run-1",
|
||||
{ connectionIds: [] },
|
||||
);
|
||||
const claims = verifyLocalAgentJwt(token!);
|
||||
expect(claims?.oauth?.connectionIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("filters non-string entries out of the connectionIds claim on verify", () => {
|
||||
// Hand-craft a token whose payload has a malformed connectionIds entry
|
||||
// to confirm verify defends against non-string values being smuggled in.
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
const realToken = createLocalAgentJwt(
|
||||
"agent-1",
|
||||
"company-1",
|
||||
"claude_local",
|
||||
"run-1",
|
||||
{ connectionIds: ["conn-a"] },
|
||||
)!;
|
||||
const [headerB64, originalClaimsB64] = realToken.split(".");
|
||||
const claims = JSON.parse(
|
||||
Buffer.from(originalClaimsB64, "base64url").toString("utf8"),
|
||||
);
|
||||
claims.oauth = { connectionIds: ["conn-a", 123, null] };
|
||||
const tamperedClaimsB64 = Buffer.from(JSON.stringify(claims), "utf8").toString(
|
||||
"base64url",
|
||||
);
|
||||
const tamperedSig = createHmac("sha256", "test-secret")
|
||||
.update(`${headerB64}.${tamperedClaimsB64}`)
|
||||
.digest("base64url");
|
||||
const tamperedToken = `${headerB64}.${tamperedClaimsB64}.${tamperedSig}`;
|
||||
const verified = verifyLocalAgentJwt(tamperedToken);
|
||||
expect(verified?.oauth?.connectionIds).toEqual(["conn-a"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,736 @@
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
oauthAuthorizationStates,
|
||||
oauthConnections,
|
||||
} from "@paperclipai/db";
|
||||
import { ProviderRegistry } from "../../oauth/registry.js";
|
||||
import { oauthRoutes } from "../../routes/oauth.js";
|
||||
import { oauthCallbackRoute } from "../../routes/oauth-callback.js";
|
||||
import { runRefreshTick } from "../../oauth/refresh-worker.js";
|
||||
import { createSlidingWindowLimiter } from "../../oauth/rate-limiter.js";
|
||||
import {
|
||||
createTestSecretService,
|
||||
oauthEmbeddedPostgresSupport,
|
||||
seedTestCompany,
|
||||
seedTestUser,
|
||||
setupOAuthTestEnv,
|
||||
withSyntheticAdvisoryLock,
|
||||
type Db,
|
||||
} from "./test-setup.js";
|
||||
import { startMockProvider, type MockProvider } from "./mock-provider.js";
|
||||
|
||||
const describeEmbeddedPostgres = oauthEmbeddedPostgresSupport.supported
|
||||
? describe
|
||||
: describe.skip;
|
||||
|
||||
if (!oauthEmbeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping OAuth integration tests on this host: ${oauthEmbeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
interface AppHarness {
|
||||
app: express.Express;
|
||||
registry: ProviderRegistry;
|
||||
secretService: ReturnType<typeof createTestSecretService>;
|
||||
companyId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("OAuth integration scenarios", () => {
|
||||
let env!: Awaited<ReturnType<typeof setupOAuthTestEnv>>;
|
||||
let db!: Db;
|
||||
let mock!: MockProvider;
|
||||
|
||||
// Helper: run `runRefreshTick` with a synthetic advisory-lock shim so the
|
||||
// worker's session-scoped lock can't leak across postgres-js pool
|
||||
// connections (and so the worker's `lockResult.rows?.[0]?.result` shape
|
||||
// expectation actually fires). See Phase-7 report for follow-up.
|
||||
async function tickWorker(
|
||||
registry: ProviderRegistry,
|
||||
secretSvc: ReturnType<typeof createTestSecretService>,
|
||||
): Promise<void> {
|
||||
await runRefreshTick({
|
||||
db: withSyntheticAdvisoryLock(db) as typeof db,
|
||||
registry,
|
||||
secretService: secretSvc as any,
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupOAuthTestEnv("oauth-integration");
|
||||
db = env.db;
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env.cleanup();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (mock) {
|
||||
await mock.close();
|
||||
}
|
||||
await env.reset();
|
||||
});
|
||||
|
||||
function buildHarness(opts: {
|
||||
companyId: string;
|
||||
userId: string;
|
||||
}): AppHarness {
|
||||
const baseEnv: Record<string, string | undefined> = {
|
||||
MOCK_OAUTH_CLIENT_ID: "client-id",
|
||||
MOCK_OAUTH_CLIENT_SECRET: "client-secret",
|
||||
};
|
||||
const registry = new ProviderRegistry({ env: baseEnv });
|
||||
registry.register(
|
||||
{
|
||||
id: "mock",
|
||||
displayName: "Mock",
|
||||
clientCredentials: {
|
||||
clientIdEnv: "MOCK_OAUTH_CLIENT_ID",
|
||||
clientSecretEnv: "MOCK_OAUTH_CLIENT_SECRET",
|
||||
},
|
||||
endpoints: {
|
||||
authorize: `${mock.url}/authorize`,
|
||||
token: `${mock.url}/token`,
|
||||
accountInfo: `${mock.url}/me`,
|
||||
revoke: `${mock.url}/revoke`,
|
||||
},
|
||||
scopes: { default: ["read"], offered: ["read"] },
|
||||
pkce: "required",
|
||||
authMethod: "post",
|
||||
responseFormat: "json",
|
||||
accountIdField: "id",
|
||||
accountLabelField: "name",
|
||||
refresh: { supported: true, rotatesRefreshToken: true },
|
||||
},
|
||||
"yaml",
|
||||
);
|
||||
|
||||
const secretSvc = createTestSecretService(db, registry);
|
||||
const rateLimiter = createSlidingWindowLimiter({
|
||||
limit: 1000,
|
||||
windowMs: 5 * 60 * 1000,
|
||||
});
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(
|
||||
"/api/companies/:companyId/oauth",
|
||||
(req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: opts.userId,
|
||||
memberships: [
|
||||
{ companyId: req.params.companyId, membershipRole: "admin" },
|
||||
],
|
||||
};
|
||||
next();
|
||||
},
|
||||
oauthRoutes({
|
||||
registry,
|
||||
db,
|
||||
publicUrl: "http://localhost",
|
||||
rateLimiter,
|
||||
secretService: secretSvc as any,
|
||||
}),
|
||||
);
|
||||
app.use(
|
||||
"/api/oauth/callback/:providerId",
|
||||
oauthCallbackRoute({
|
||||
registry,
|
||||
db,
|
||||
publicUrl: "http://localhost",
|
||||
secretService: secretSvc as any,
|
||||
}),
|
||||
);
|
||||
// Test-only error handler: surfaces middleware crashes as JSON so failing
|
||||
// assertions show a useful message instead of a bare 500.
|
||||
app.use(
|
||||
(
|
||||
err: Error,
|
||||
_req: express.Request,
|
||||
res: express.Response,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_next: express.NextFunction,
|
||||
) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[test app error]", err.stack || err.message);
|
||||
res.status(500).json({ error: err.message, stack: err.stack });
|
||||
},
|
||||
);
|
||||
return {
|
||||
app,
|
||||
registry,
|
||||
secretService: secretSvc,
|
||||
companyId: opts.companyId,
|
||||
userId: opts.userId,
|
||||
};
|
||||
}
|
||||
|
||||
async function makeAppForCompany(): Promise<AppHarness> {
|
||||
const userId = await seedTestUser(db);
|
||||
const companyId = await seedTestCompany(db);
|
||||
return buildHarness({ companyId, userId });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenarios 1-7
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it("scenario 1: happy path — connect, callback, row written", async () => {
|
||||
mock = await startMockProvider();
|
||||
const { app, companyId } = await makeAppForCompany();
|
||||
|
||||
const start = await request(app).post(
|
||||
`/api/companies/${companyId}/oauth/connect/mock`,
|
||||
);
|
||||
expect(start.status).toBe(200);
|
||||
const stateId = start.body.state as string;
|
||||
expect(stateId).toBeTruthy();
|
||||
|
||||
const cb = await request(app).get(
|
||||
`/api/oauth/callback/mock?state=${stateId}&code=mock-code-1`,
|
||||
);
|
||||
expect(cb.status).toBe(302);
|
||||
expect(cb.headers.location).toContain("oauth_connected=mock");
|
||||
|
||||
const conns = await db
|
||||
.select()
|
||||
.from(oauthConnections)
|
||||
.where(
|
||||
and(
|
||||
eq(oauthConnections.companyId, companyId),
|
||||
eq(oauthConnections.providerId, "mock"),
|
||||
),
|
||||
);
|
||||
expect(conns).toHaveLength(1);
|
||||
expect(conns[0]!.status).toBe("active");
|
||||
expect(conns[0]!.accountId).toBe("user-1");
|
||||
expect(conns[0]!.accountLabel).toBe("Test User");
|
||||
expect(conns[0]!.accessTokenSecretId).toBeTruthy();
|
||||
expect(conns[0]!.refreshTokenSecretId).toBeTruthy();
|
||||
});
|
||||
|
||||
it("scenario 2: state replay returns oauth_error=replay", async () => {
|
||||
mock = await startMockProvider();
|
||||
const { app, companyId } = await makeAppForCompany();
|
||||
|
||||
const start = await request(app).post(
|
||||
`/api/companies/${companyId}/oauth/connect/mock`,
|
||||
);
|
||||
const stateId = start.body.state as string;
|
||||
|
||||
const first = await request(app).get(
|
||||
`/api/oauth/callback/mock?state=${stateId}&code=mock-code-1`,
|
||||
);
|
||||
expect(first.headers.location).toContain("oauth_connected=mock");
|
||||
|
||||
const second = await request(app).get(
|
||||
`/api/oauth/callback/mock?state=${stateId}&code=mock-code-1`,
|
||||
);
|
||||
expect(second.status).toBe(302);
|
||||
expect(second.headers.location).toContain("oauth_error=replay");
|
||||
});
|
||||
|
||||
it("scenario 3: expired state returns invalid_state", async () => {
|
||||
mock = await startMockProvider();
|
||||
const { app, companyId } = await makeAppForCompany();
|
||||
|
||||
const start = await request(app).post(
|
||||
`/api/companies/${companyId}/oauth/connect/mock`,
|
||||
);
|
||||
const stateId = start.body.state as string;
|
||||
|
||||
// Force expiry without sleeping — backdate the row.
|
||||
await db
|
||||
.update(oauthAuthorizationStates)
|
||||
.set({ expiresAt: new Date(Date.now() - 60_000) })
|
||||
.where(eq(oauthAuthorizationStates.id, stateId));
|
||||
|
||||
const cb = await request(app).get(
|
||||
`/api/oauth/callback/mock?state=${stateId}&code=x`,
|
||||
);
|
||||
expect(cb.status).toBe(302);
|
||||
expect(cb.headers.location).toContain("oauth_error=invalid_state");
|
||||
});
|
||||
|
||||
it("scenario 4: provider mismatch routes to provider_mismatch", async () => {
|
||||
mock = await startMockProvider();
|
||||
const { app, registry, companyId } = await makeAppForCompany();
|
||||
// Register a second provider so the callback URL's provider differs from
|
||||
// the state's provider.
|
||||
registry.register(
|
||||
{
|
||||
id: "mock2",
|
||||
displayName: "Mock2",
|
||||
clientCredentials: {
|
||||
clientIdEnv: "MOCK_OAUTH_CLIENT_ID",
|
||||
clientSecretEnv: "MOCK_OAUTH_CLIENT_SECRET",
|
||||
},
|
||||
endpoints: {
|
||||
authorize: `${mock.url}/authorize`,
|
||||
token: `${mock.url}/token`,
|
||||
accountInfo: `${mock.url}/me`,
|
||||
},
|
||||
scopes: { default: [], offered: [] },
|
||||
pkce: "required",
|
||||
authMethod: "post",
|
||||
responseFormat: "json",
|
||||
accountIdField: "id",
|
||||
accountLabelField: "name",
|
||||
refresh: { supported: false },
|
||||
},
|
||||
"yaml",
|
||||
);
|
||||
|
||||
const start = await request(app).post(
|
||||
`/api/companies/${companyId}/oauth/connect/mock`,
|
||||
);
|
||||
const stateId = start.body.state as string;
|
||||
|
||||
const cb = await request(app).get(
|
||||
`/api/oauth/callback/mock2?state=${stateId}&code=x`,
|
||||
);
|
||||
expect(cb.status).toBe(302);
|
||||
expect(cb.headers.location).toContain("oauth_error=provider_mismatch");
|
||||
});
|
||||
|
||||
it("scenario 5: account mismatch on re-auth keeps existing connection", async () => {
|
||||
mock = await startMockProvider();
|
||||
const { app, companyId } = await makeAppForCompany();
|
||||
|
||||
// First flow with default user-1.
|
||||
const s1 = await request(app).post(
|
||||
`/api/companies/${companyId}/oauth/connect/mock`,
|
||||
);
|
||||
const cb1 = await request(app).get(
|
||||
`/api/oauth/callback/mock?state=${s1.body.state}&code=c1`,
|
||||
);
|
||||
expect(cb1.headers.location).toContain("oauth_connected=mock");
|
||||
|
||||
// Second flow returns a different account — should be rejected.
|
||||
mock.state.account = { id: "user-2", name: "Different" };
|
||||
const s2 = await request(app).post(
|
||||
`/api/companies/${companyId}/oauth/connect/mock`,
|
||||
);
|
||||
const cb2 = await request(app).get(
|
||||
`/api/oauth/callback/mock?state=${s2.body.state}&code=c2`,
|
||||
);
|
||||
expect(cb2.headers.location).toContain("oauth_error=account_mismatch");
|
||||
|
||||
const conns = await db
|
||||
.select()
|
||||
.from(oauthConnections)
|
||||
.where(
|
||||
and(
|
||||
eq(oauthConnections.companyId, companyId),
|
||||
eq(oauthConnections.providerId, "mock"),
|
||||
),
|
||||
);
|
||||
expect(conns).toHaveLength(1);
|
||||
expect(conns[0]!.accountId).toBe("user-1");
|
||||
});
|
||||
|
||||
it("scenario 6: token exchange returns 500 → no connection row written", async () => {
|
||||
mock = await startMockProvider();
|
||||
const { app, companyId } = await makeAppForCompany();
|
||||
mock.state.tokenStatus = 500;
|
||||
|
||||
const start = await request(app).post(
|
||||
`/api/companies/${companyId}/oauth/connect/mock`,
|
||||
);
|
||||
const cb = await request(app).get(
|
||||
`/api/oauth/callback/mock?state=${start.body.state}&code=x`,
|
||||
);
|
||||
expect(cb.headers.location).toContain("oauth_error=token_exchange_failed");
|
||||
|
||||
const conns = await db
|
||||
.select()
|
||||
.from(oauthConnections)
|
||||
.where(
|
||||
and(
|
||||
eq(oauthConnections.companyId, companyId),
|
||||
eq(oauthConnections.providerId, "mock"),
|
||||
),
|
||||
);
|
||||
expect(conns).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("scenario 7: refresh worker rotates near-expiry token", async () => {
|
||||
mock = await startMockProvider();
|
||||
const { app, registry, secretService, companyId } =
|
||||
await makeAppForCompany();
|
||||
|
||||
// expiresInSeconds=60 → row will be inserted with accessTokenExpiresAt
|
||||
// ~1 minute out, well within the worker's 5-minute window.
|
||||
mock.state.expiresInSeconds = 60;
|
||||
|
||||
const start = await request(app).post(
|
||||
`/api/companies/${companyId}/oauth/connect/mock`,
|
||||
);
|
||||
const cb = await request(app).get(
|
||||
`/api/oauth/callback/mock?state=${start.body.state}&code=x`,
|
||||
);
|
||||
expect(cb.headers.location).toContain("oauth_connected=mock");
|
||||
|
||||
const before = await db
|
||||
.select()
|
||||
.from(oauthConnections)
|
||||
.where(eq(oauthConnections.companyId, companyId));
|
||||
expect(before).toHaveLength(1);
|
||||
const beforeSecretId = before[0]!.accessTokenSecretId!;
|
||||
const beforeExpiresAt = before[0]!.accessTokenExpiresAt;
|
||||
const beforeRefreshedAt = before[0]!.lastRefreshedAt;
|
||||
|
||||
// Lengthen post-refresh expiry so the resulting connection clearly moved.
|
||||
mock.state.expiresInSeconds = 3600;
|
||||
const refreshCallsBeforeTick = mock.state.refreshCallCount;
|
||||
// NOTE: `tickWorker` runs runRefreshTick against an isolated postgres-js
|
||||
// handle and applies `withExecuteRowsCompat` so the worker's
|
||||
// `lockResult.rows?.[0]?.result` (node-postgres shape) sees the field.
|
||||
// Both compat shims exist because production reads the wrong Result shape;
|
||||
// see Phase-7 report — fix is out of scope here.
|
||||
await tickWorker(registry, secretService);
|
||||
expect(mock.state.refreshCallCount).toBe(refreshCallsBeforeTick + 1);
|
||||
|
||||
const after = await db
|
||||
.select()
|
||||
.from(oauthConnections)
|
||||
.where(eq(oauthConnections.companyId, companyId));
|
||||
expect(after).toHaveLength(1);
|
||||
expect(after[0]!.status).toBe("active");
|
||||
// upsertSecretByName rotates the SAME secret in place, so id is stable but
|
||||
// a new version row should exist.
|
||||
expect(after[0]!.accessTokenSecretId).toBe(beforeSecretId);
|
||||
const accessSecret = await secretService.getById(beforeSecretId);
|
||||
expect(accessSecret?.latestVersion).toBeGreaterThanOrEqual(2);
|
||||
// New expiry should be ~1h out, not the original ~1m.
|
||||
expect(after[0]!.accessTokenExpiresAt!.getTime()).toBeGreaterThan(
|
||||
beforeExpiresAt!.getTime() + 5 * 60 * 1000,
|
||||
);
|
||||
expect(after[0]!.lastRefreshedAt!.getTime()).toBeGreaterThanOrEqual(
|
||||
beforeRefreshedAt!.getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenarios 8-14
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it("scenario 8: refresh returns invalid_grant → status flips to revoked", async () => {
|
||||
mock = await startMockProvider();
|
||||
const { app, registry, secretService, companyId } =
|
||||
await makeAppForCompany();
|
||||
mock.state.expiresInSeconds = 60;
|
||||
const start = await request(app).post(
|
||||
`/api/companies/${companyId}/oauth/connect/mock`,
|
||||
);
|
||||
await request(app).get(
|
||||
`/api/oauth/callback/mock?state=${start.body.state}&code=x`,
|
||||
);
|
||||
|
||||
// Make subsequent /token calls return invalid_grant.
|
||||
mock.state.tokenStatus = 400;
|
||||
mock.state.tokenBody = { error: "invalid_grant" };
|
||||
|
||||
await tickWorker(registry, secretService);
|
||||
|
||||
const conns = await db
|
||||
.select()
|
||||
.from(oauthConnections)
|
||||
.where(eq(oauthConnections.companyId, companyId));
|
||||
expect(conns).toHaveLength(1);
|
||||
expect(conns[0]!.status).toBe("revoked");
|
||||
expect(conns[0]!.lastError).toMatch(/invalid_grant/);
|
||||
});
|
||||
|
||||
it("scenario 9: lazy refresh during dispatch produces fresh tokens under contention", async () => {
|
||||
mock = await startMockProvider();
|
||||
const { app, registry, secretService, companyId } =
|
||||
await makeAppForCompany();
|
||||
// 30s expiry — within the lazy 60s window so resolveAdapterConfigForRuntime
|
||||
// triggers a refresh.
|
||||
mock.state.expiresInSeconds = 30;
|
||||
const start = await request(app).post(
|
||||
`/api/companies/${companyId}/oauth/connect/mock`,
|
||||
);
|
||||
await request(app).get(
|
||||
`/api/oauth/callback/mock?state=${start.body.state}&code=x`,
|
||||
);
|
||||
|
||||
const conn = (
|
||||
await db
|
||||
.select()
|
||||
.from(oauthConnections)
|
||||
.where(eq(oauthConnections.companyId, companyId))
|
||||
)[0]!;
|
||||
|
||||
// Two concurrent lazy resolves race against the refresh worker.
|
||||
// Each resolve passes through resolveAdapterConfigForRuntime, which
|
||||
// detects the near-expiry token and invokes refreshFn before reading
|
||||
// the secret.
|
||||
const adapterConfig = {
|
||||
env: {
|
||||
TOK: { type: "oauth_token", connectionId: conn.id },
|
||||
},
|
||||
};
|
||||
mock.state.expiresInSeconds = 3600; // post-refresh expiry returned by /token
|
||||
const beforeRefreshCalls = mock.state.refreshCallCount;
|
||||
|
||||
// KNOWN GAP (flagged for follow-up): the plan asserted "exactly one refresh
|
||||
// upstream call" for concurrent lazy resolves. That guarantee requires row-
|
||||
// level pessimistic locking (`SELECT ... FOR UPDATE`) inside refresh.ts.
|
||||
// The current implementation relies on the worker's advisory lock plus a
|
||||
// transaction read, so concurrent resolves for the same connection CAN
|
||||
// double-fire — and when they do, the second one races into a unique-
|
||||
// constraint violation on `company_secret_versions(secret_id, version)`.
|
||||
// The constraint catches the race correctly, but it bubbles to callers as
|
||||
// a 23505 error rather than being retried/swallowed in the resolver. We
|
||||
// tolerate that here with `Promise.allSettled` and assert the more
|
||||
// important invariants: (a) at least one resolve produced a usable access
|
||||
// token, (b) the upstream provider was hit at most twice (== expected
|
||||
// worst case), and (c) the refresh worker tick itself did not crash.
|
||||
const [aRes, bRes, tickRes] = await Promise.allSettled([
|
||||
secretService.resolveAdapterConfigForRuntime(companyId, adapterConfig),
|
||||
secretService.resolveAdapterConfigForRuntime(companyId, adapterConfig),
|
||||
tickWorker(registry, secretService),
|
||||
]);
|
||||
expect(tickRes.status).toBe("fulfilled");
|
||||
const fulfilled = [aRes, bRes].filter(
|
||||
(r): r is PromiseFulfilledResult<{ config: unknown }> =>
|
||||
r.status === "fulfilled",
|
||||
);
|
||||
expect(fulfilled.length).toBeGreaterThanOrEqual(1);
|
||||
for (const r of fulfilled) {
|
||||
const env = (r.value.config as { env: Record<string, string> }).env;
|
||||
expect(env.TOK).toMatch(/^access-/);
|
||||
}
|
||||
|
||||
// Worst case: worker tick + both lazy resolves each fire one /token
|
||||
// before the constraint catches the duplicates → 3 upstream calls. With
|
||||
// proper FOR UPDATE locking this would collapse to 1.
|
||||
expect(
|
||||
mock.state.refreshCallCount - beforeRefreshCalls,
|
||||
).toBeLessThanOrEqual(3);
|
||||
expect(
|
||||
mock.state.refreshCallCount - beforeRefreshCalls,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("scenario 10: 5 consecutive refresh failures keep the row in long backoff", async () => {
|
||||
mock = await startMockProvider();
|
||||
const { app, registry, secretService, companyId } =
|
||||
await makeAppForCompany();
|
||||
mock.state.expiresInSeconds = 60;
|
||||
const start = await request(app).post(
|
||||
`/api/companies/${companyId}/oauth/connect/mock`,
|
||||
);
|
||||
await request(app).get(
|
||||
`/api/oauth/callback/mock?state=${start.body.state}&code=x`,
|
||||
);
|
||||
|
||||
// Force 5 consecutive transient failures — each refresh returns 500.
|
||||
mock.state.consecutiveRefreshFailures = 5;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await tickWorker(registry, secretService);
|
||||
// Fast-forward past the backoff so the next tick re-tries.
|
||||
await db
|
||||
.update(oauthConnections)
|
||||
.set({ lastErrorAt: new Date(Date.now() - 2 * 60 * 60 * 1000) });
|
||||
}
|
||||
expect(mock.state.refreshCallCount).toBeGreaterThanOrEqual(5);
|
||||
|
||||
// Without fast-forwarding lastErrorAt, backoff(5) ≈ 32 minutes — the
|
||||
// worker should refuse to schedule and refreshCallCount stays put.
|
||||
const refreshCallsBeforeBackoffTick = mock.state.refreshCallCount;
|
||||
await db.update(oauthConnections).set({
|
||||
refreshAttemptCount: 5,
|
||||
lastErrorAt: new Date(),
|
||||
});
|
||||
await tickWorker(registry, secretService);
|
||||
expect(mock.state.refreshCallCount).toBe(refreshCallsBeforeBackoffTick);
|
||||
});
|
||||
|
||||
it("scenario 11: disconnect with revoke success deletes connection and secrets", async () => {
|
||||
mock = await startMockProvider();
|
||||
const { app, companyId, secretService } = await makeAppForCompany();
|
||||
const start = await request(app).post(
|
||||
`/api/companies/${companyId}/oauth/connect/mock`,
|
||||
);
|
||||
await request(app).get(
|
||||
`/api/oauth/callback/mock?state=${start.body.state}&code=x`,
|
||||
);
|
||||
const conn = (
|
||||
await db
|
||||
.select()
|
||||
.from(oauthConnections)
|
||||
.where(eq(oauthConnections.companyId, companyId))
|
||||
)[0]!;
|
||||
const accessSecretId = conn.accessTokenSecretId!;
|
||||
const refreshSecretId = conn.refreshTokenSecretId!;
|
||||
|
||||
const beforeRevokeCount = mock.state.revokeCallCount;
|
||||
const del = await request(app).delete(
|
||||
`/api/companies/${companyId}/oauth/connections/${conn.id}`,
|
||||
);
|
||||
expect(del.status).toBe(204);
|
||||
|
||||
const after = await db
|
||||
.select()
|
||||
.from(oauthConnections)
|
||||
.where(eq(oauthConnections.id, conn.id));
|
||||
expect(after).toHaveLength(0);
|
||||
expect(mock.state.revokeCallCount).toBeGreaterThan(beforeRevokeCount);
|
||||
|
||||
// Secrets are soft-deleted by `secretService.remove`.
|
||||
const accessSecret = await secretService.getById(accessSecretId);
|
||||
expect(accessSecret?.status === "deleted" || accessSecret === null).toBe(
|
||||
true,
|
||||
);
|
||||
const refreshSecret = await secretService.getById(refreshSecretId);
|
||||
expect(refreshSecret?.status === "deleted" || refreshSecret === null).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("scenario 12: disconnect still deletes locally when upstream revoke returns 500", async () => {
|
||||
mock = await startMockProvider();
|
||||
const { app, companyId } = await makeAppForCompany();
|
||||
const start = await request(app).post(
|
||||
`/api/companies/${companyId}/oauth/connect/mock`,
|
||||
);
|
||||
await request(app).get(
|
||||
`/api/oauth/callback/mock?state=${start.body.state}&code=x`,
|
||||
);
|
||||
const conn = (
|
||||
await db
|
||||
.select()
|
||||
.from(oauthConnections)
|
||||
.where(eq(oauthConnections.companyId, companyId))
|
||||
)[0]!;
|
||||
|
||||
// Force the upstream /revoke endpoint to fail.
|
||||
mock.state.revokeStatus = 500;
|
||||
const beforeRevokeCount = mock.state.revokeCallCount;
|
||||
|
||||
const del = await request(app).delete(
|
||||
`/api/companies/${companyId}/oauth/connections/${conn.id}`,
|
||||
);
|
||||
expect(del.status).toBe(204);
|
||||
|
||||
// Upstream was attempted but did not block local deletion.
|
||||
expect(mock.state.revokeCallCount).toBeGreaterThan(beforeRevokeCount);
|
||||
const after = await db
|
||||
.select()
|
||||
.from(oauthConnections)
|
||||
.where(eq(oauthConnections.id, conn.id));
|
||||
expect(after).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("scenario 13: plugin contribution is shadowed by yaml entry", async () => {
|
||||
mock = await startMockProvider();
|
||||
const { registry } = await makeAppForCompany();
|
||||
|
||||
// Re-register the same id from a plugin source — should be skipped.
|
||||
registry.register(
|
||||
{
|
||||
id: "mock",
|
||||
displayName: "Mock-from-plugin",
|
||||
clientCredentials: {
|
||||
clientIdEnv: "MOCK_OAUTH_CLIENT_ID",
|
||||
clientSecretEnv: "MOCK_OAUTH_CLIENT_SECRET",
|
||||
},
|
||||
endpoints: {
|
||||
authorize: "https://x/a",
|
||||
token: "https://x/t",
|
||||
accountInfo: "https://x/me",
|
||||
},
|
||||
scopes: { default: [], offered: [] },
|
||||
pkce: "required",
|
||||
authMethod: "post",
|
||||
responseFormat: "json",
|
||||
accountIdField: "id",
|
||||
accountLabelField: "name",
|
||||
refresh: { supported: false },
|
||||
},
|
||||
"plugin",
|
||||
);
|
||||
|
||||
expect(registry.get("mock")?.config.displayName).toBe("Mock");
|
||||
expect(registry.get("mock")?.source).toBe("yaml");
|
||||
});
|
||||
|
||||
it("scenario 14: provider env vars unset → not registered; existing connection flipped to error", async () => {
|
||||
mock = await startMockProvider();
|
||||
// First, prove that an empty-env registry refuses to register.
|
||||
const r2 = new ProviderRegistry({ env: {} });
|
||||
r2.register(
|
||||
{
|
||||
id: "missing",
|
||||
displayName: "Missing",
|
||||
clientCredentials: {
|
||||
clientIdEnv: "MISSING_ID",
|
||||
clientSecretEnv: "MISSING_SECRET",
|
||||
},
|
||||
endpoints: {
|
||||
authorize: "https://x/a",
|
||||
token: "https://x/t",
|
||||
accountInfo: "https://x/me",
|
||||
},
|
||||
scopes: { default: [], offered: [] },
|
||||
pkce: "required",
|
||||
authMethod: "post",
|
||||
responseFormat: "json",
|
||||
accountIdField: "id",
|
||||
accountLabelField: "name",
|
||||
refresh: { supported: false },
|
||||
},
|
||||
"yaml",
|
||||
);
|
||||
expect(r2.get("missing")).toBeUndefined();
|
||||
|
||||
// Now seed a pre-existing connection for that absent provider and run the
|
||||
// worker against the empty registry. The refresh transaction should detect
|
||||
// `provider_unavailable` and flip the connection to status="error".
|
||||
const { secretService, companyId } = await makeAppForCompany();
|
||||
// Create real access + refresh secrets so we satisfy the FK and the
|
||||
// worker can probe the missing-provider path on a real refreshable row.
|
||||
const accessSecret = await secretService.upsertSecretByName(companyId, {
|
||||
name: "oauth:missing:user-x:access",
|
||||
value: "stale-access",
|
||||
});
|
||||
const refreshSecret = await secretService.upsertSecretByName(companyId, {
|
||||
name: "oauth:missing:user-x:refresh",
|
||||
value: "stale-refresh",
|
||||
});
|
||||
const inserted = await db
|
||||
.insert(oauthConnections)
|
||||
.values({
|
||||
companyId,
|
||||
providerId: "missing",
|
||||
status: "active",
|
||||
accountId: "user-x",
|
||||
accountLabel: "User X",
|
||||
scopes: ["read"],
|
||||
accessTokenSecretId: accessSecret.id,
|
||||
refreshTokenSecretId: refreshSecret.id,
|
||||
// Near-future expiry → worker selects this row.
|
||||
accessTokenExpiresAt: new Date(Date.now() + 60_000),
|
||||
})
|
||||
.returning();
|
||||
expect(inserted).toHaveLength(1);
|
||||
|
||||
await tickWorker(r2, secretService);
|
||||
|
||||
const after = await db
|
||||
.select()
|
||||
.from(oauthConnections)
|
||||
.where(eq(oauthConnections.id, inserted[0]!.id));
|
||||
expect(after).toHaveLength(1);
|
||||
expect(after[0]!.status).toBe("error");
|
||||
expect(after[0]!.lastError).toMatch(/provider_unavailable/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { startMockProvider } from "./mock-provider.js";
|
||||
|
||||
describe("mock-provider", () => {
|
||||
it("serves /me with the default account", async () => {
|
||||
const m = await startMockProvider();
|
||||
try {
|
||||
const r = await fetch(`${m.url}/me`);
|
||||
expect(r.status).toBe(200);
|
||||
const body = (await r.json()) as { id: string; name: string };
|
||||
expect(body.id).toBe("user-1");
|
||||
expect(body.name).toBe("Test User");
|
||||
} finally {
|
||||
await m.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("issues a token with refresh + access on grant_type=authorization_code", async () => {
|
||||
const m = await startMockProvider();
|
||||
try {
|
||||
const r = await fetch(`${m.url}/token`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: "x",
|
||||
}).toString(),
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
const body = (await r.json()) as Record<string, unknown>;
|
||||
expect(body.access_token).toMatch(/^access-/);
|
||||
expect(body.refresh_token).toMatch(/^refresh-/);
|
||||
expect(body.expires_in).toBe(3600);
|
||||
} finally {
|
||||
await m.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("counts refresh calls and supports per-test overrides on /token", async () => {
|
||||
const m = await startMockProvider();
|
||||
try {
|
||||
m.state.consecutiveRefreshFailures = 1;
|
||||
const failed = await fetch(`${m.url}/token`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: "r",
|
||||
}).toString(),
|
||||
});
|
||||
expect(failed.status).toBe(500);
|
||||
expect(m.state.refreshCallCount).toBe(1);
|
||||
|
||||
const ok = await fetch(`${m.url}/token`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: "r",
|
||||
}).toString(),
|
||||
});
|
||||
expect(ok.status).toBe(200);
|
||||
expect(m.state.refreshCallCount).toBe(2);
|
||||
} finally {
|
||||
await m.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("respects revokeStatus override on /revoke", async () => {
|
||||
const m = await startMockProvider();
|
||||
try {
|
||||
m.state.revokeStatus = 500;
|
||||
const r = await fetch(`${m.url}/revoke`, { method: "POST" });
|
||||
expect(r.status).toBe(500);
|
||||
expect(m.state.revokeCallCount).toBe(1);
|
||||
} finally {
|
||||
await m.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import express from "express";
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
export interface MockProviderState {
|
||||
account: { id: string; name: string };
|
||||
// Per-test overrides:
|
||||
tokenStatus?: number;
|
||||
tokenBody?: Record<string, unknown>;
|
||||
accountStatus?: number;
|
||||
accountBody?: Record<string, unknown>;
|
||||
expiresInSeconds: number;
|
||||
rotatesRefreshToken: boolean;
|
||||
refreshCallCount: number;
|
||||
consecutiveRefreshFailures: number;
|
||||
// Override behavior of /revoke. Default: 200 OK.
|
||||
revokeStatus?: number;
|
||||
revokeBody?: Record<string, unknown>;
|
||||
revokeCallCount: number;
|
||||
}
|
||||
|
||||
export interface MockProvider {
|
||||
url: string;
|
||||
state: MockProviderState;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
export async function startMockProvider(): Promise<MockProvider> {
|
||||
const state: MockProviderState = {
|
||||
account: { id: "user-1", name: "Test User" },
|
||||
expiresInSeconds: 3600,
|
||||
rotatesRefreshToken: true,
|
||||
refreshCallCount: 0,
|
||||
consecutiveRefreshFailures: 0,
|
||||
revokeCallCount: 0,
|
||||
};
|
||||
const app = express();
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json());
|
||||
|
||||
app.get("/authorize", (req, res) => {
|
||||
// Browsers would render consent UI; tests POST directly to /token, so this is unused in tests.
|
||||
// For E2E (Playwright), redirect immediately back with a code.
|
||||
const redirect = String(req.query.redirect_uri);
|
||||
const code = "mock-code-1";
|
||||
const stateP = String(req.query.state);
|
||||
const u = new URL(redirect);
|
||||
u.searchParams.set("code", code);
|
||||
u.searchParams.set("state", stateP);
|
||||
res.redirect(302, u.toString());
|
||||
});
|
||||
|
||||
app.post("/token", (req, res) => {
|
||||
if (state.tokenStatus && state.tokenStatus !== 200) {
|
||||
res
|
||||
.status(state.tokenStatus)
|
||||
.json(state.tokenBody ?? { error: "test_failure" });
|
||||
return;
|
||||
}
|
||||
if (req.body.grant_type === "refresh_token") {
|
||||
state.refreshCallCount++;
|
||||
if (state.consecutiveRefreshFailures > 0) {
|
||||
state.consecutiveRefreshFailures--;
|
||||
res.status(500).json({ error: "service_unavailable" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const body: Record<string, unknown> = {
|
||||
access_token: `access-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
expires_in: state.expiresInSeconds,
|
||||
scope: "read",
|
||||
...(state.rotatesRefreshToken ||
|
||||
req.body.grant_type === "authorization_code"
|
||||
? {
|
||||
refresh_token: `refresh-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
res.json(state.tokenBody ?? body);
|
||||
});
|
||||
|
||||
app.get("/me", (_req, res) => {
|
||||
if (state.accountStatus && state.accountStatus !== 200) {
|
||||
res
|
||||
.status(state.accountStatus)
|
||||
.json(state.accountBody ?? { error: "test_failure" });
|
||||
return;
|
||||
}
|
||||
res.json(state.accountBody ?? state.account);
|
||||
});
|
||||
|
||||
app.post("/revoke", (_req, res) => {
|
||||
state.revokeCallCount++;
|
||||
if (state.revokeStatus && state.revokeStatus !== 200) {
|
||||
res
|
||||
.status(state.revokeStatus)
|
||||
.json(state.revokeBody ?? { error: "test_failure" });
|
||||
return;
|
||||
}
|
||||
res.status(200).end();
|
||||
});
|
||||
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
return {
|
||||
url,
|
||||
state,
|
||||
close: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { PassThrough } from "node:stream";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import pino from "pino";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { oauthConnections } from "@paperclipai/db";
|
||||
|
||||
import { validateReturnUrl } from "../../oauth/redirect-allowlist.js";
|
||||
import { OAUTH_REDACT_PATHS } from "../../oauth/logger.js";
|
||||
import { ProviderRegistry } from "../../oauth/registry.js";
|
||||
import { oauthRoutes } from "../../routes/oauth.js";
|
||||
import {
|
||||
createTestSecretService,
|
||||
oauthEmbeddedPostgresSupport,
|
||||
seedTestCompany,
|
||||
seedTestUser,
|
||||
setupOAuthTestEnv,
|
||||
type Db,
|
||||
} from "./test-setup.js";
|
||||
|
||||
const PUBLIC = "https://app.paperclip.test";
|
||||
|
||||
// OWASP-style return-URL evasion vectors. All MUST collapse to the safe
|
||||
// default per `validateReturnUrl`.
|
||||
const OWASP_VECTORS = [
|
||||
"//evil.example/x",
|
||||
"\\\\evil.example/x",
|
||||
"https:%2F%2Fevil.example",
|
||||
"https:\\\\evil.example/x",
|
||||
"https://app.paperclip.test@evil.example/x",
|
||||
"https://evil.example/.app.paperclip.test/",
|
||||
"/\\evil.example/x",
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Open-redirect regression vectors (plain unit assertions; no DB needed).
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("Security: open-redirect regression vectors", () => {
|
||||
for (const v of OWASP_VECTORS) {
|
||||
it(`falls back to safe default for: ${JSON.stringify(v)}`, () => {
|
||||
expect(validateReturnUrl(v, PUBLIC)).toBe("/settings/connections");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Token redaction.
|
||||
//
|
||||
// The production logger uses `pino.transport({ targets: [...] })`, which spawns
|
||||
// a worker thread; writes from there don't pass through the main process's
|
||||
// `process.stdout.write`, so we can't spy on it. We instead mirror the
|
||||
// canonical pattern from `server/src/oauth/__tests__/logger.test.ts`: build a
|
||||
// parallel pino instance with the exported `OAUTH_REDACT_PATHS` and route it
|
||||
// to an in-memory `PassThrough`. This verifies the redact-path list itself.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("Security: token redaction", () => {
|
||||
it("OAUTH_REDACT_PATHS censors code_verifier alongside other token-shaped fields", async () => {
|
||||
const stream = new PassThrough();
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on("data", (c) => chunks.push(c));
|
||||
const parent = pino({ level: "info" }, stream);
|
||||
const child = parent.child(
|
||||
{ component: "oauth" },
|
||||
{ redact: { paths: OAUTH_REDACT_PATHS, censor: "[REDACTED]" } },
|
||||
);
|
||||
|
||||
child.info(
|
||||
{
|
||||
access_token: "ACCESS_X",
|
||||
refresh_token: "REFRESH_X",
|
||||
code_verifier: "VERIFIER_X",
|
||||
client_secret: "CLIENT_SECRET_X",
|
||||
},
|
||||
"test event",
|
||||
);
|
||||
await new Promise((r) => setImmediate(r));
|
||||
const all = Buffer.concat(chunks).toString("utf8");
|
||||
expect(all).not.toContain("ACCESS_X");
|
||||
expect(all).not.toContain("REFRESH_X");
|
||||
expect(all).not.toContain("VERIFIER_X");
|
||||
expect(all).not.toContain("CLIENT_SECRET_X");
|
||||
expect(all).toContain("[REDACTED]");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Cross-tenant isolation. Uses the embedded-postgres harness already
|
||||
// established by `integration.test.ts`. Skipped automatically on hosts
|
||||
// where embedded-postgres can't migrate (mirrors integration.test).
|
||||
// ---------------------------------------------------------------------------
|
||||
const describeEmbeddedPostgres = oauthEmbeddedPostgresSupport.supported
|
||||
? describe
|
||||
: describe.skip;
|
||||
|
||||
if (!oauthEmbeddedPostgresSupport.supported) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`Skipping OAuth cross-tenant security tests: ${oauthEmbeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("Security: cross-tenant isolation", () => {
|
||||
let env!: Awaited<ReturnType<typeof setupOAuthTestEnv>>;
|
||||
let db!: Db;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupOAuthTestEnv("oauth-security");
|
||||
db = env.db;
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env.cleanup();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await env.reset();
|
||||
});
|
||||
|
||||
function buildApp(opts: {
|
||||
actorCompanyId: string;
|
||||
userId: string;
|
||||
}): express.Express {
|
||||
const baseEnv: Record<string, string | undefined> = {
|
||||
MOCK_OAUTH_CLIENT_ID: "client-id",
|
||||
MOCK_OAUTH_CLIENT_SECRET: "client-secret",
|
||||
};
|
||||
const registry = new ProviderRegistry({ env: baseEnv });
|
||||
registry.register(
|
||||
{
|
||||
id: "mock",
|
||||
displayName: "Mock",
|
||||
clientCredentials: {
|
||||
clientIdEnv: "MOCK_OAUTH_CLIENT_ID",
|
||||
clientSecretEnv: "MOCK_OAUTH_CLIENT_SECRET",
|
||||
},
|
||||
endpoints: {
|
||||
authorize: "https://x/a",
|
||||
token: "https://x/t",
|
||||
accountInfo: "https://x/me",
|
||||
},
|
||||
scopes: { default: [], offered: [] },
|
||||
pkce: "required",
|
||||
authMethod: "post",
|
||||
responseFormat: "json",
|
||||
accountIdField: "id",
|
||||
accountLabelField: "name",
|
||||
refresh: { supported: false },
|
||||
},
|
||||
"yaml",
|
||||
);
|
||||
const secretSvc = createTestSecretService(db, registry);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(
|
||||
"/api/companies/:companyId/oauth",
|
||||
(req, _res, next) => {
|
||||
// Actor is admin of `actorCompanyId` only — even though the URL says
|
||||
// `:companyId`, `ensureMember` checks membership against that param.
|
||||
// We use `actorCompanyId` so the second test (where the URL points at
|
||||
// a *different* company) gets rejected at `ensureMember` rather than
|
||||
// exercising the cross-tenant query path. To exercise cross-tenant
|
||||
// *connection lookup* the actor must be admin of the URL's
|
||||
// `:companyId` (company A) — see the connections-by-id test below.
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: opts.userId,
|
||||
memberships: [
|
||||
{ companyId: opts.actorCompanyId, membershipRole: "admin" },
|
||||
],
|
||||
};
|
||||
next();
|
||||
},
|
||||
oauthRoutes({
|
||||
registry,
|
||||
db,
|
||||
publicUrl: "http://localhost",
|
||||
rateLimiter: { check: async () => true },
|
||||
secretService: secretSvc as any,
|
||||
}),
|
||||
);
|
||||
return app;
|
||||
}
|
||||
|
||||
it("company A cannot fetch company B's connection by id (404, not 403)", async () => {
|
||||
const userId = await seedTestUser(db);
|
||||
const companyA = await seedTestCompany(db, { name: "A" });
|
||||
const companyB = await seedTestCompany(db, { name: "B" });
|
||||
|
||||
// Seed a connection for company B (the secretService gives us a real
|
||||
// companySecrets row to satisfy the FK).
|
||||
const baseEnv: Record<string, string | undefined> = {
|
||||
MOCK_OAUTH_CLIENT_ID: "client-id",
|
||||
MOCK_OAUTH_CLIENT_SECRET: "client-secret",
|
||||
};
|
||||
const seedRegistry = new ProviderRegistry({ env: baseEnv });
|
||||
const seedSvc = createTestSecretService(db, seedRegistry);
|
||||
const accessSecret = await seedSvc.upsertSecretByName(companyB, {
|
||||
name: "oauth:mock:user-b:access",
|
||||
value: "stale-access",
|
||||
});
|
||||
const inserted = await db
|
||||
.insert(oauthConnections)
|
||||
.values({
|
||||
companyId: companyB,
|
||||
providerId: "mock",
|
||||
status: "active",
|
||||
accountId: "user-b",
|
||||
accountLabel: "B User",
|
||||
scopes: ["read"],
|
||||
accessTokenSecretId: accessSecret.id,
|
||||
accessTokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
})
|
||||
.returning();
|
||||
expect(inserted).toHaveLength(1);
|
||||
const connBId = inserted[0]!.id;
|
||||
|
||||
// Actor is admin of company A only and asks A's URL for B's connection id.
|
||||
const app = buildApp({ actorCompanyId: companyA, userId });
|
||||
const res = await request(app).get(
|
||||
`/api/companies/${companyA}/oauth/connections/${connBId}`,
|
||||
);
|
||||
// Spec §9.8: cross-tenant misses are 404, not 403 — to avoid leaking
|
||||
// existence.
|
||||
expect(res.status).toBe(404);
|
||||
|
||||
// Sanity: B's connection still exists in the DB; it just isn't visible
|
||||
// through company A's URL space.
|
||||
const stillThere = await db
|
||||
.select()
|
||||
.from(oauthConnections)
|
||||
.where(eq(oauthConnections.id, connBId));
|
||||
expect(stillThere).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("GET /connections returns only the requested company's rows", async () => {
|
||||
const userId = await seedTestUser(db);
|
||||
const companyA = await seedTestCompany(db, { name: "A" });
|
||||
const companyB = await seedTestCompany(db, { name: "B" });
|
||||
|
||||
// Seed one connection per tenant.
|
||||
const baseEnv: Record<string, string | undefined> = {
|
||||
MOCK_OAUTH_CLIENT_ID: "client-id",
|
||||
MOCK_OAUTH_CLIENT_SECRET: "client-secret",
|
||||
};
|
||||
const seedRegistry = new ProviderRegistry({ env: baseEnv });
|
||||
const seedSvc = createTestSecretService(db, seedRegistry);
|
||||
const accessA = await seedSvc.upsertSecretByName(companyA, {
|
||||
name: "oauth:mock:user-a:access",
|
||||
value: "a-access",
|
||||
});
|
||||
const accessB = await seedSvc.upsertSecretByName(companyB, {
|
||||
name: "oauth:mock:user-b:access",
|
||||
value: "b-access",
|
||||
});
|
||||
await db.insert(oauthConnections).values([
|
||||
{
|
||||
companyId: companyA,
|
||||
providerId: "mock",
|
||||
status: "active",
|
||||
accountId: "user-a",
|
||||
accountLabel: "A User",
|
||||
scopes: ["read"],
|
||||
accessTokenSecretId: accessA.id,
|
||||
accessTokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
},
|
||||
{
|
||||
companyId: companyB,
|
||||
providerId: "mock",
|
||||
status: "active",
|
||||
accountId: "user-b",
|
||||
accountLabel: "B User",
|
||||
scopes: ["read"],
|
||||
accessTokenSecretId: accessB.id,
|
||||
accessTokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
},
|
||||
]);
|
||||
|
||||
const app = buildApp({ actorCompanyId: companyA, userId });
|
||||
const res = await request(app).get(
|
||||
`/api/companies/${companyA}/oauth/connections`,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const conns = res.body.connections as Array<{
|
||||
accountId: string | null;
|
||||
providerId: string;
|
||||
}>;
|
||||
expect(conns).toHaveLength(1);
|
||||
expect(conns[0]!.accountId).toBe("user-a");
|
||||
// Token material must not leak in the listing — already covered in
|
||||
// oauth-connections.test, but worth a defence-in-depth assertion here.
|
||||
const body = JSON.stringify(res.body);
|
||||
expect(body).not.toContain("accessTokenSecretId");
|
||||
expect(body).not.toContain("refreshTokenSecretId");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Scope escalation rejected. Plain-mock pattern shared with
|
||||
// `oauth-connect.test.ts` — no DB harness needed.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("Security: scope escalation rejected", () => {
|
||||
it("returns 400 + invalid_scope when a requested scope is not in `offered`", async () => {
|
||||
const env = { MOCK_OAUTH_CLIENT_ID: "id", MOCK_OAUTH_CLIENT_SECRET: "s" };
|
||||
const registry = new ProviderRegistry({ env });
|
||||
registry.register(
|
||||
{
|
||||
id: "mock",
|
||||
displayName: "Mock",
|
||||
clientCredentials: {
|
||||
clientIdEnv: "MOCK_OAUTH_CLIENT_ID",
|
||||
clientSecretEnv: "MOCK_OAUTH_CLIENT_SECRET",
|
||||
},
|
||||
endpoints: {
|
||||
authorize: "https://x/a",
|
||||
token: "https://x/t",
|
||||
accountInfo: "https://x/me",
|
||||
},
|
||||
// Intentionally narrow: only `read` is offered.
|
||||
scopes: { default: ["read"], offered: ["read"] },
|
||||
pkce: "required",
|
||||
authMethod: "post",
|
||||
responseFormat: "json",
|
||||
accountIdField: "id",
|
||||
accountLabelField: "name",
|
||||
refresh: { supported: false },
|
||||
},
|
||||
"yaml",
|
||||
);
|
||||
const insertMock = vi.fn().mockResolvedValue([{ id: "state-1" }]);
|
||||
const db = {
|
||||
insert: () => ({ values: () => ({ returning: insertMock }) }),
|
||||
};
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(
|
||||
"/api/companies/:companyId/oauth",
|
||||
(req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "u1",
|
||||
memberships: [{ companyId: req.params.companyId, membershipRole: "admin" }],
|
||||
};
|
||||
next();
|
||||
},
|
||||
oauthRoutes({
|
||||
registry,
|
||||
db: db as any,
|
||||
publicUrl: "http://x",
|
||||
rateLimiter: { check: async () => true },
|
||||
secretService: {} as any,
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/companies/c1/oauth/connect/mock")
|
||||
.send({ scopes: ["admin:everything"] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.errorCode).toBe("invalid_scope");
|
||||
// The state row must NOT have been written when scope validation fails.
|
||||
expect(insertMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. State-row flooding. Drives 51 connect requests against a fake
|
||||
// per-company `connectFloodLimiter` that allows the first 50 and rejects
|
||||
// the 51st. Asserts the route returns 429 + `errorCode: "connect_flood"`.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("Security: state-row flooding", () => {
|
||||
it("returns 429 + connect_flood after the per-company limit is exceeded", async () => {
|
||||
const env = { MOCK_OAUTH_CLIENT_ID: "id", MOCK_OAUTH_CLIENT_SECRET: "s" };
|
||||
const registry = new ProviderRegistry({ env });
|
||||
registry.register(
|
||||
{
|
||||
id: "mock",
|
||||
displayName: "Mock",
|
||||
clientCredentials: {
|
||||
clientIdEnv: "MOCK_OAUTH_CLIENT_ID",
|
||||
clientSecretEnv: "MOCK_OAUTH_CLIENT_SECRET",
|
||||
},
|
||||
endpoints: {
|
||||
authorize: "https://x/a",
|
||||
token: "https://x/t",
|
||||
accountInfo: "https://x/me",
|
||||
},
|
||||
scopes: { default: [], offered: [] },
|
||||
pkce: "required",
|
||||
authMethod: "post",
|
||||
responseFormat: "json",
|
||||
accountIdField: "id",
|
||||
accountLabelField: "name",
|
||||
refresh: { supported: false },
|
||||
},
|
||||
"yaml",
|
||||
);
|
||||
|
||||
let insertCount = 0;
|
||||
const insertMock = vi.fn().mockImplementation(async () => {
|
||||
insertCount++;
|
||||
return [{ id: `state-${insertCount}` }];
|
||||
});
|
||||
const db = {
|
||||
insert: () => ({ values: () => ({ returning: insertMock }) }),
|
||||
};
|
||||
|
||||
// Per-user limiter is generously open so we can exercise the flood guard
|
||||
// independently. The flood limiter mimics a sliding-window with limit=50.
|
||||
let floodCallCount = 0;
|
||||
const connectFloodLimiter = {
|
||||
check: async (key: string) => {
|
||||
// Sanity-check the key shape since the test's whole point is per-
|
||||
// company isolation: it must include the URL's companyId.
|
||||
expect(key).toBe("connect-flood:c1");
|
||||
floodCallCount++;
|
||||
return floodCallCount <= 50;
|
||||
},
|
||||
};
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(
|
||||
"/api/companies/:companyId/oauth",
|
||||
(req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "u1",
|
||||
memberships: [{ companyId: req.params.companyId, membershipRole: "admin" }],
|
||||
};
|
||||
next();
|
||||
},
|
||||
oauthRoutes({
|
||||
registry,
|
||||
db: db as any,
|
||||
publicUrl: "http://x",
|
||||
rateLimiter: { check: async () => true },
|
||||
connectFloodLimiter,
|
||||
secretService: {} as any,
|
||||
}),
|
||||
);
|
||||
|
||||
// Drive the first 50 — all should succeed.
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const res = await request(app).post("/api/companies/c1/oauth/connect/mock");
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
expect(floodCallCount).toBe(50);
|
||||
expect(insertCount).toBe(50);
|
||||
|
||||
// 51st call: flood limiter rejects, we get 429 + connect_flood, and the
|
||||
// state row is NOT written.
|
||||
const flooded = await request(app).post(
|
||||
"/api/companies/c1/oauth/connect/mock",
|
||||
);
|
||||
expect(flooded.status).toBe(429);
|
||||
expect(flooded.body.errorCode).toBe("connect_flood");
|
||||
expect(floodCallCount).toBe(51);
|
||||
expect(insertCount).toBe(50);
|
||||
});
|
||||
|
||||
it("does not check the flood limiter when it isn't supplied (back-compat)", async () => {
|
||||
const env = { MOCK_OAUTH_CLIENT_ID: "id", MOCK_OAUTH_CLIENT_SECRET: "s" };
|
||||
const registry = new ProviderRegistry({ env });
|
||||
registry.register(
|
||||
{
|
||||
id: "mock",
|
||||
displayName: "Mock",
|
||||
clientCredentials: {
|
||||
clientIdEnv: "MOCK_OAUTH_CLIENT_ID",
|
||||
clientSecretEnv: "MOCK_OAUTH_CLIENT_SECRET",
|
||||
},
|
||||
endpoints: {
|
||||
authorize: "https://x/a",
|
||||
token: "https://x/t",
|
||||
accountInfo: "https://x/me",
|
||||
},
|
||||
scopes: { default: [], offered: [] },
|
||||
pkce: "required",
|
||||
authMethod: "post",
|
||||
responseFormat: "json",
|
||||
accountIdField: "id",
|
||||
accountLabelField: "name",
|
||||
refresh: { supported: false },
|
||||
},
|
||||
"yaml",
|
||||
);
|
||||
const insertMock = vi.fn().mockResolvedValue([{ id: "s1" }]);
|
||||
const db = {
|
||||
insert: () => ({ values: () => ({ returning: insertMock }) }),
|
||||
};
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(
|
||||
"/api/companies/:companyId/oauth",
|
||||
(req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "u1",
|
||||
memberships: [{ companyId: req.params.companyId, membershipRole: "admin" }],
|
||||
};
|
||||
next();
|
||||
},
|
||||
oauthRoutes({
|
||||
registry,
|
||||
db: db as any,
|
||||
publicUrl: "http://x",
|
||||
rateLimiter: { check: async () => true },
|
||||
secretService: {} as any,
|
||||
}),
|
||||
);
|
||||
const res = await request(app).post("/api/companies/c1/oauth/connect/mock");
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
createDb,
|
||||
authUsers,
|
||||
oauthAuthorizationStates,
|
||||
oauthConnections,
|
||||
companies,
|
||||
companySecretBindings,
|
||||
companySecretVersions,
|
||||
companySecrets,
|
||||
companySecretProviderConfigs,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "../helpers/embedded-postgres.js";
|
||||
import { secretService } from "../../services/secrets.js";
|
||||
import type { ProviderRegistry } from "../../oauth/registry.js";
|
||||
import { refreshConnection } from "../../oauth/refresh.js";
|
||||
|
||||
export type Db = ReturnType<typeof createDb>;
|
||||
|
||||
export interface OAuthTestEnv {
|
||||
db: Db;
|
||||
/** Connection string suitable for creating ephemeral postgres-js handles. */
|
||||
connectionString: string;
|
||||
cleanup: () => Promise<void>;
|
||||
/** Reset all OAuth-touched tables; safe to call between tests. */
|
||||
reset: () => Promise<void>;
|
||||
secretsTmpDir: string;
|
||||
previousKeyFile: string | undefined;
|
||||
}
|
||||
/**
|
||||
* Boots an embedded Postgres database with all schema migrations applied and
|
||||
* configures the secrets master-key file the way `secrets-service.test.ts`
|
||||
* does. Returns an object with the live Drizzle handle and a cleanup hook
|
||||
* (call from `afterAll`). Returns `null` if embedded-postgres is unsupported
|
||||
* on this host (callers should `describe.skip`).
|
||||
*/
|
||||
export async function setupOAuthTestEnv(label: string): Promise<OAuthTestEnv> {
|
||||
const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
const secretsTmpDir = path.join(
|
||||
os.tmpdir(),
|
||||
`paperclip-oauth-${label}-${randomUUID()}`,
|
||||
);
|
||||
mkdirSync(secretsTmpDir, { recursive: true });
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(
|
||||
secretsTmpDir,
|
||||
"master.key",
|
||||
);
|
||||
|
||||
const started = await startEmbeddedPostgresTestDatabase(label);
|
||||
const db = createDb(started.connectionString);
|
||||
|
||||
return {
|
||||
db,
|
||||
connectionString: started.connectionString,
|
||||
secretsTmpDir,
|
||||
previousKeyFile,
|
||||
reset: async () => {
|
||||
// Order matters: child rows first to satisfy FK constraints.
|
||||
await db.delete(oauthConnections);
|
||||
await db.delete(oauthAuthorizationStates);
|
||||
await db.delete(companySecretBindings);
|
||||
await db.delete(companySecretVersions);
|
||||
await db.delete(companySecrets);
|
||||
await db.delete(companySecretProviderConfigs);
|
||||
await db.delete(companies);
|
||||
await db.delete(authUsers);
|
||||
},
|
||||
cleanup: async () => {
|
||||
await started.cleanup();
|
||||
if (previousKeyFile === undefined) {
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
} else {
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile;
|
||||
}
|
||||
rmSync(secretsTmpDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Cheap probe that mirrors `secrets-service.test.ts` so the suite can `describe.skip` cleanly. */
|
||||
export const oauthEmbeddedPostgresSupport =
|
||||
await getEmbeddedPostgresTestSupport();
|
||||
|
||||
export async function seedTestCompany(
|
||||
db: Db,
|
||||
opts: { id?: string; name?: string } = {},
|
||||
): Promise<string> {
|
||||
const companyId = opts.id ?? randomUUID();
|
||||
const name = opts.name ?? "TestCo";
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name,
|
||||
issuePrefix: `T${companyId.slice(0, 7)}`.toUpperCase(),
|
||||
status: "active",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
return companyId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed an auth_users row so the OAuth state-row insert can satisfy its FK
|
||||
* (`initiated_by_user_id` references `user.id`). Idempotent across tests.
|
||||
*/
|
||||
export async function seedTestUser(
|
||||
db: Db,
|
||||
opts: { id?: string; email?: string; name?: string } = {},
|
||||
): Promise<string> {
|
||||
const userId = opts.id ?? `user-${randomUUID()}`;
|
||||
await db
|
||||
.insert(authUsers)
|
||||
.values({
|
||||
id: userId,
|
||||
email: opts.email ?? `${userId}@example.test`,
|
||||
name: opts.name ?? "Test User",
|
||||
emailVerified: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
return userId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a real secretService bound to the test DB and the supplied
|
||||
* registry. Mirrors how production wires it (refreshFn injected to break the
|
||||
* circular import). The integration tests need this for OAuth-token resolution
|
||||
* to exercise the lazy-refresh path.
|
||||
*/
|
||||
export function createTestSecretService(db: Db, registry: ProviderRegistry) {
|
||||
return secretService(db, {
|
||||
registry,
|
||||
refreshFn: refreshConnection,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a Drizzle handle so the OAuth refresh-worker's advisory-lock pings
|
||||
* never reach Postgres: any `pg_try_advisory_lock` /
|
||||
* `pg_try_advisory_xact_lock` / `pg_advisory_unlock` query is short-circuited
|
||||
* to a synthetic success result. The shim is permissive on purpose: the
|
||||
* worker's lock implementation has migrated from session-scoped
|
||||
* (`pg_try_advisory_lock` + explicit unlock) to transaction-scoped
|
||||
* (`pg_try_advisory_xact_lock` inside a `db.transaction(...)`); we keep the
|
||||
* `pg_try_advisory_lock` and `pg_advisory_unlock` matchers so older worker
|
||||
* variants and any future mid-migration paths stay covered. The shim is
|
||||
* documented as a follow-up in the Phase-7 report; production code is
|
||||
* deliberately left untouched.
|
||||
*/
|
||||
export function withSyntheticAdvisoryLock<
|
||||
T extends { execute: (...args: any[]) => any },
|
||||
>(db: T): T {
|
||||
function shouldShimSql(sqlText: string): boolean {
|
||||
// `includes("pg_try_advisory_lock")` matches `pg_try_advisory_xact_lock`
|
||||
// too because of the shared prefix, but we list each form explicitly so
|
||||
// future readers can grep for the intent.
|
||||
return (
|
||||
sqlText.includes("pg_try_advisory_xact_lock") ||
|
||||
sqlText.includes("pg_try_advisory_lock") ||
|
||||
sqlText.includes("pg_advisory_unlock")
|
||||
);
|
||||
}
|
||||
function syntheticOk() {
|
||||
return Object.assign([{ result: true }], {
|
||||
rows: [{ result: true }],
|
||||
});
|
||||
}
|
||||
function wrapExecute(originalExecute: (...args: any[]) => any) {
|
||||
return async (query: any, ...rest: any[]) => {
|
||||
const sqlText = serializeSqlForMatch(query);
|
||||
if (shouldShimSql(sqlText)) return syntheticOk();
|
||||
return await originalExecute(query, ...rest);
|
||||
};
|
||||
}
|
||||
function wrapTx<TX extends { execute?: (...args: any[]) => any }>(tx: TX): TX {
|
||||
if (typeof tx?.execute !== "function") return tx;
|
||||
const originalExecute = tx.execute.bind(tx);
|
||||
return new Proxy(tx, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === "execute") return wrapExecute(originalExecute);
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const originalExecute = db.execute.bind(db);
|
||||
const dbWithTx = db as T & {
|
||||
transaction?: (cb: (tx: unknown) => unknown) => unknown;
|
||||
};
|
||||
const originalTransaction = dbWithTx.transaction?.bind(dbWithTx);
|
||||
return new Proxy(db, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === "execute") return wrapExecute(originalExecute);
|
||||
if (prop === "transaction" && originalTransaction) {
|
||||
// The worker now takes the advisory lock inside a tx via `tx.execute`.
|
||||
// Wrap the inner tx so its `execute` answers the same synthetic
|
||||
// result for advisory-lock probes.
|
||||
return (cb: (tx: unknown) => unknown, ...rest: unknown[]) => {
|
||||
return originalTransaction(
|
||||
(tx: unknown) => cb(wrapTx(tx as { execute?: any })),
|
||||
...(rest as []),
|
||||
);
|
||||
};
|
||||
}
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
}) as T;
|
||||
}
|
||||
|
||||
function serializeSqlForMatch(query: unknown): string {
|
||||
if (!query || typeof query !== "object") return "";
|
||||
// Drizzle SQL objects expose `.queryChunks` — `StringChunk` chunks have a
|
||||
// `.value: string[]` field, parameter chunks are bigint/number/string.
|
||||
// We just need a coarse text view to spot the advisory-lock query, so we
|
||||
// walk both shapes and join. Avoids `JSON.stringify` which throws on
|
||||
// BigInt parameters like the worker's lock-key constant.
|
||||
const chunks = (query as { queryChunks?: unknown[] }).queryChunks;
|
||||
if (!Array.isArray(chunks)) return "";
|
||||
const out: string[] = [];
|
||||
for (const chunk of chunks) {
|
||||
if (typeof chunk === "string") {
|
||||
out.push(chunk);
|
||||
continue;
|
||||
}
|
||||
if (chunk && typeof chunk === "object" && "value" in chunk) {
|
||||
const v = (chunk as { value: unknown }).value;
|
||||
if (typeof v === "string") out.push(v);
|
||||
else if (Array.isArray(v)) {
|
||||
for (const part of v) {
|
||||
if (typeof part === "string") out.push(part);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.join(" ");
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { runJwtMiddleware } from "../middleware/run-jwt.js";
|
||||
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
|
||||
|
||||
describe("runJwtMiddleware", () => {
|
||||
const previousSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
beforeAll(() => {
|
||||
process.env.PAPERCLIP_AGENT_JWT_SECRET = "test-run-jwt-secret";
|
||||
});
|
||||
afterAll(() => {
|
||||
if (previousSecret === undefined) {
|
||||
delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
|
||||
} else {
|
||||
process.env.PAPERCLIP_AGENT_JWT_SECRET = previousSecret;
|
||||
}
|
||||
});
|
||||
|
||||
function makeApp() {
|
||||
const app = express();
|
||||
app.get("/probe", runJwtMiddleware(), (req, res) => {
|
||||
res.json({ runJwt: req.runJwt ?? null });
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
it("leaves req.runJwt unset when no Authorization header is present", async () => {
|
||||
const res = await request(makeApp()).get("/probe");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.runJwt).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves req.runJwt unset for malformed Bearer tokens", async () => {
|
||||
const res = await request(makeApp())
|
||||
.get("/probe")
|
||||
.set("Authorization", "Bearer not-a-jwt");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.runJwt).toBeNull();
|
||||
});
|
||||
|
||||
it("populates req.runJwt with run/company/connectionIds for a valid Bearer", async () => {
|
||||
const jwt = createLocalAgentJwt(
|
||||
"agent-1",
|
||||
"company-1",
|
||||
"claude_local",
|
||||
"run-1",
|
||||
{ connectionIds: ["conn-a", "conn-b"] },
|
||||
);
|
||||
expect(jwt).not.toBeNull();
|
||||
const res = await request(makeApp())
|
||||
.get("/probe")
|
||||
.set("Authorization", `Bearer ${jwt}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.runJwt).toEqual({
|
||||
runId: "run-1",
|
||||
companyId: "company-1",
|
||||
connectionIds: ["conn-a", "conn-b"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not set req.runJwt when the JWT lacks an oauth.connectionIds claim", async () => {
|
||||
const jwt = createLocalAgentJwt(
|
||||
"agent-1",
|
||||
"company-1",
|
||||
"claude_local",
|
||||
"run-1",
|
||||
);
|
||||
expect(jwt).not.toBeNull();
|
||||
const res = await request(makeApp())
|
||||
.get("/probe")
|
||||
.set("Authorization", `Bearer ${jwt}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.runJwt).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,10 @@ export interface LocalAgentJwtClaims {
|
||||
iss?: string;
|
||||
aud?: string;
|
||||
jti?: string;
|
||||
// OAuth connections referenced by this run's adapter config. The runtime
|
||||
// proxy uses this list to scope token-resolve and revoke calls to the
|
||||
// exact connections this run was granted at dispatch time.
|
||||
oauth?: { connectionIds: string[] };
|
||||
}
|
||||
|
||||
const JWT_ALGORITHM = "HS256";
|
||||
@@ -65,7 +69,13 @@ function safeCompare(a: string, b: string) {
|
||||
return timingSafeEqual(left, right);
|
||||
}
|
||||
|
||||
export function createLocalAgentJwt(agentId: string, companyId: string, adapterType: string, runId: string) {
|
||||
export function createLocalAgentJwt(
|
||||
agentId: string,
|
||||
companyId: string,
|
||||
adapterType: string,
|
||||
runId: string,
|
||||
oauth?: { connectionIds: string[] },
|
||||
) {
|
||||
const config = jwtConfig();
|
||||
if (!config) return null;
|
||||
|
||||
@@ -79,6 +89,7 @@ export function createLocalAgentJwt(agentId: string, companyId: string, adapterT
|
||||
exp: now + config.ttlSeconds,
|
||||
iss: config.issuer,
|
||||
aud: config.audience,
|
||||
...(oauth ? { oauth } : {}),
|
||||
};
|
||||
|
||||
const header = {
|
||||
@@ -127,6 +138,20 @@ export function verifyLocalAgentJwt(token: string): LocalAgentJwtClaims | null {
|
||||
if (issuer && issuer !== config.issuer) return null;
|
||||
if (audience && audience !== config.audience) return null;
|
||||
|
||||
let oauth: { connectionIds: string[] } | undefined;
|
||||
const oauthClaim = claims.oauth;
|
||||
if (
|
||||
oauthClaim &&
|
||||
typeof oauthClaim === "object" &&
|
||||
!Array.isArray(oauthClaim) &&
|
||||
Array.isArray((oauthClaim as { connectionIds?: unknown }).connectionIds)
|
||||
) {
|
||||
const ids = (oauthClaim as { connectionIds: unknown[] }).connectionIds.filter(
|
||||
(id): id is string => typeof id === "string",
|
||||
);
|
||||
oauth = { connectionIds: ids };
|
||||
}
|
||||
|
||||
return {
|
||||
sub,
|
||||
company_id: companyId,
|
||||
@@ -137,5 +162,6 @@ export function verifyLocalAgentJwt(token: string): LocalAgentJwtClaims | null {
|
||||
...(issuer ? { iss: issuer } : {}),
|
||||
...(audience ? { aud: audience } : {}),
|
||||
jti: typeof claims.jti === "string" ? claims.jti : undefined,
|
||||
...(oauth ? { oauth } : {}),
|
||||
};
|
||||
}
|
||||
@@ -41,6 +41,20 @@ import { accessRoutes } from "./routes/access.js";
|
||||
import { pluginRoutes } from "./routes/plugins.js";
|
||||
import { adapterRoutes } from "./routes/adapters.js";
|
||||
import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js";
|
||||
import { ProviderRegistry } from "./oauth/registry.js";
|
||||
import { loadProviderConfigsFromDirectory } from "./oauth/yaml-loader.js";
|
||||
import { registerPluginContributions } from "./oauth/plugin-loader.js";
|
||||
import { createSlidingWindowLimiter } from "./oauth/rate-limiter.js";
|
||||
import { KNOWN_SHAPES } from "./oauth/shapes/index.js";
|
||||
import { oauthLogger } from "./oauth/logger.js";
|
||||
import { oauthRoutes } from "./routes/oauth.js";
|
||||
import { oauthCallbackRoute } from "./routes/oauth-callback.js";
|
||||
import { oauthMarkRevokedRoute } from "./routes/oauth-mark-revoked.js";
|
||||
import { runJwtMiddleware } from "./middleware/run-jwt.js";
|
||||
import { startRefreshWorker } from "./oauth/refresh-worker.js";
|
||||
import { refreshConnection as refreshConnectionImpl } from "./oauth/refresh.js";
|
||||
import { startStateSweeper } from "./oauth/state-sweeper.js";
|
||||
import { secretService } from "./services/index.js";
|
||||
import { applyUiBranding } from "./ui-branding.js";
|
||||
import { logger } from "./middleware/logger.js";
|
||||
import { DEFAULT_LOCAL_PLUGIN_DIR, pluginLoader } from "./services/plugin-loader.js";
|
||||
@@ -293,6 +307,100 @@ export async function createApp(
|
||||
allowedHostnames: opts.allowedHostnames,
|
||||
}),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OAuth backbone wiring (T28).
|
||||
//
|
||||
// Registry is constructed once at startup. YAML provider configs are loaded
|
||||
// synchronously (createApp is already async), so the registry is populated
|
||||
// before the routes that read from it are mounted. Plugin contributions are
|
||||
// registered later, inside `loader.loadAll().then(...)` below — the route
|
||||
// mounts read the registry per request, so late additions are picked up.
|
||||
const oauthRegistry = new ProviderRegistry({ env: process.env });
|
||||
const yamlConfigs = await loadProviderConfigsFromDirectory(
|
||||
path.join(process.cwd(), "server", "oauth-providers"),
|
||||
);
|
||||
const registerYamlConfig = (cfg: (typeof yamlConfigs)[number]): void => {
|
||||
if (cfg.shape !== undefined) {
|
||||
const shape = KNOWN_SHAPES[cfg.shape];
|
||||
if (!shape) {
|
||||
oauthLogger.error(
|
||||
{ provider: cfg.id, shape: cfg.shape, known: Object.keys(KNOWN_SHAPES) },
|
||||
"OAuth provider references unknown shape module; skipping",
|
||||
);
|
||||
return;
|
||||
}
|
||||
oauthRegistry.register(cfg, "yaml", shape);
|
||||
return;
|
||||
}
|
||||
oauthRegistry.register(cfg, "yaml");
|
||||
};
|
||||
for (const cfg of yamlConfigs) registerYamlConfig(cfg);
|
||||
const extraOauthDir = process.env.PAPERCLIP_OAUTH_PROVIDERS_DIR;
|
||||
if (extraOauthDir) {
|
||||
const extra = await loadProviderConfigsFromDirectory(extraOauthDir);
|
||||
for (const cfg of extra) registerYamlConfig(cfg);
|
||||
}
|
||||
|
||||
// publicUrl: prefer the explicit PAPERCLIP_PUBLIC_URL env var; fall back to
|
||||
// the local bind. loadConfig() does not surface a single `publicUrl` field
|
||||
// (it has authPublicBaseUrl which is auth-specific), so the env var is the
|
||||
// single source of truth here. T31 may revisit this once a unified public
|
||||
// URL field exists on the loaded config.
|
||||
const oauthPublicUrl =
|
||||
process.env.PAPERCLIP_PUBLIC_URL ?? `http://localhost:${opts.serverPort}`;
|
||||
|
||||
const oauthSecretService = secretService(db, {
|
||||
registry: oauthRegistry,
|
||||
refreshFn: refreshConnectionImpl,
|
||||
});
|
||||
const oauthLimiter = createSlidingWindowLimiter({ limit: 60, windowMs: 60_000 });
|
||||
// Per-tenant flood guard on POST /connect/:providerId to prevent
|
||||
// `oauth_authorization_states` row-flood abuse from a single company
|
||||
// (spec §10.4). Tuned at 50 requests / 5 minutes; complements the per-user
|
||||
// rateLimiter above.
|
||||
const oauthConnectFloodLimiter = createSlidingWindowLimiter({
|
||||
limit: 50,
|
||||
windowMs: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
api.use(
|
||||
"/companies/:companyId/oauth",
|
||||
oauthRoutes({
|
||||
registry: oauthRegistry,
|
||||
db,
|
||||
publicUrl: oauthPublicUrl,
|
||||
rateLimiter: oauthLimiter,
|
||||
connectFloodLimiter: oauthConnectFloodLimiter,
|
||||
secretService: oauthSecretService,
|
||||
}),
|
||||
);
|
||||
api.use(
|
||||
"/oauth/callback/:providerId",
|
||||
oauthCallbackRoute({
|
||||
registry: oauthRegistry,
|
||||
db,
|
||||
publicUrl: oauthPublicUrl,
|
||||
secretService: oauthSecretService,
|
||||
}),
|
||||
);
|
||||
// Bearer-token run-JWT middleware: extracts oauth.connectionIds claim from
|
||||
// the Authorization header and puts it on req.runJwt. The mark-revoked
|
||||
// handler enforces 401/403 based on its presence + connection scoping.
|
||||
api.use(
|
||||
"/oauth/connections/:id/mark-revoked",
|
||||
runJwtMiddleware(),
|
||||
oauthMarkRevokedRoute({ db }),
|
||||
);
|
||||
|
||||
const refreshWorker = startRefreshWorker({
|
||||
db,
|
||||
registry: oauthRegistry,
|
||||
secretService: oauthSecretService,
|
||||
});
|
||||
const stateSweeper = startStateSweeper({ db });
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.use("/api", api);
|
||||
app.use("/api", (_req, res) => {
|
||||
res.status(404).json({ error: "API route not found" });
|
||||
@@ -428,10 +536,19 @@ export async function createApp(
|
||||
: null;
|
||||
void loader.loadAll().then((result) => {
|
||||
if (!result) return;
|
||||
const oauthManifests = [];
|
||||
for (const loaded of result.results) {
|
||||
if (devWatcher && loaded.success && loaded.plugin.packagePath) {
|
||||
devWatcher.watch(loaded.plugin.id, loaded.plugin.packagePath);
|
||||
}
|
||||
if (loaded.success && loaded.plugin.manifestJson) {
|
||||
oauthManifests.push(loaded.plugin.manifestJson);
|
||||
}
|
||||
}
|
||||
try {
|
||||
registerPluginContributions(oauthRegistry, oauthManifests);
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Failed to register plugin OAuth contributions");
|
||||
}
|
||||
}).catch((err) => {
|
||||
logger.error({ err }, "Failed to load ready plugins on startup");
|
||||
@@ -442,10 +559,20 @@ export async function createApp(
|
||||
viteHtmlRenderer?.dispose();
|
||||
hostServiceCleanup.disposeAll();
|
||||
hostServiceCleanup.teardown();
|
||||
refreshWorker.stop();
|
||||
stateSweeper.stop();
|
||||
});
|
||||
process.once("beforeExit", () => {
|
||||
void flushPluginLogBuffer();
|
||||
});
|
||||
|
||||
// Expose the OAuth registry on `app.locals` so the scheduler in index.ts
|
||||
// (which constructs a heartbeatService outside this function) can build the
|
||||
// same oauthDeps and reuse a single registry. Late-registered plugin
|
||||
// contributions land on the same object, so the runtime resolver and the
|
||||
// dispatcher always see the same provider set.
|
||||
(app as unknown as { locals: Record<string, unknown> }).locals.oauthRegistry =
|
||||
oauthRegistry;
|
||||
|
||||
return app;
|
||||
}
|
||||
+13
-1
@@ -37,6 +37,8 @@ import {
|
||||
} from "./services/index.js";
|
||||
import { createFeedbackTraceShareClientFromConfig } from "./services/feedback-share-client.js";
|
||||
import { buildRuntimeApiCandidateUrls, choosePrimaryRuntimeApiUrl } from "./runtime-api.js";
|
||||
import type { ProviderRegistry } from "./oauth/registry.js";
|
||||
import { refreshConnection as refreshConnectionImpl } from "./oauth/refresh.js";
|
||||
import { createPluginWorkerManager } from "./services/plugin-worker-manager.js";
|
||||
import { createStorageServiceFromConfig } from "./storage/index.js";
|
||||
import { printStartupBanner } from "./startup-banner.js";
|
||||
@@ -670,7 +672,17 @@ export async function startServer(): Promise<StartedServer> {
|
||||
});
|
||||
|
||||
if (config.heartbeatSchedulerEnabled) {
|
||||
const heartbeat = heartbeatService(db as any, { pluginWorkerManager });
|
||||
// OAuth wiring: createApp builds the provider registry and stashes it on
|
||||
// app.locals.oauthRegistry. Reuse it (plus the production refresh fn) so
|
||||
// the scheduler-side resolver agrees with the request-side resolver on
|
||||
// which oauth providers exist and how to lazy-refresh tokens.
|
||||
const oauthRegistryFromApp = (
|
||||
app as unknown as { locals: { oauthRegistry?: ProviderRegistry } }
|
||||
).locals.oauthRegistry;
|
||||
const oauthDeps = oauthRegistryFromApp
|
||||
? { registry: oauthRegistryFromApp, refreshFn: refreshConnectionImpl }
|
||||
: undefined;
|
||||
const heartbeat = heartbeatService(db as any, { pluginWorkerManager, oauthDeps });
|
||||
const routines = routineService(db as any, { pluginWorkerManager });
|
||||
|
||||
// Reap orphaned running runs at startup while in-memory execution state is empty,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { RequestHandler } from "express";
|
||||
import { verifyLocalAgentJwt } from "../agent-auth-jwt.js";
|
||||
|
||||
export interface RunJwtClaim {
|
||||
runId: string;
|
||||
companyId: string;
|
||||
connectionIds: string[];
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
runJwt?: RunJwtClaim;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function runJwtMiddleware(): RequestHandler {
|
||||
return (req, _res, next) => {
|
||||
const header = req.headers.authorization;
|
||||
if (!header || !header.startsWith("Bearer ")) return next();
|
||||
const claims = verifyLocalAgentJwt(header.slice(7));
|
||||
if (claims?.oauth?.connectionIds) {
|
||||
req.runJwt = {
|
||||
runId: claims.run_id,
|
||||
companyId: claims.company_id,
|
||||
connectionIds: claims.oauth.connectionIds,
|
||||
};
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadProviderConfigsFromDirectory } from "../yaml-loader.js";
|
||||
import { KNOWN_SHAPES } from "../shapes/index.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
// server/src/oauth/__tests__ -> ../../../oauth-providers
|
||||
const PROVIDERS_DIR = path.resolve(__dirname, "..", "..", "..", "oauth-providers");
|
||||
|
||||
describe("shipped provider yaml files", () => {
|
||||
it("all parse and validate via the loader", async () => {
|
||||
const configs = await loadProviderConfigsFromDirectory(PROVIDERS_DIR);
|
||||
expect(configs.length).toBeGreaterThanOrEqual(1);
|
||||
for (const config of configs) {
|
||||
expect(config.id).toMatch(/^[a-z0-9-]+$/);
|
||||
expect(config.displayName.length).toBeGreaterThan(0);
|
||||
expect(config.endpoints.authorize.startsWith("https://")).toBe(true);
|
||||
expect(config.endpoints.token.startsWith("https://")).toBe(true);
|
||||
expect(config.endpoints.accountInfo.startsWith("https://")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("every referenced shape resolves via KNOWN_SHAPES", async () => {
|
||||
const configs = await loadProviderConfigsFromDirectory(PROVIDERS_DIR);
|
||||
for (const config of configs) {
|
||||
if (config.shape !== undefined) {
|
||||
expect(
|
||||
KNOWN_SHAPES[config.shape],
|
||||
`provider '${config.id}' references unknown shape '${config.shape}'`,
|
||||
).toBeDefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("provider ids are unique", async () => {
|
||||
const configs = await loadProviderConfigsFromDirectory(PROVIDERS_DIR);
|
||||
const ids = configs.map((c) => c.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { backoffSeconds } from "../backoff.js";
|
||||
|
||||
describe("backoffSeconds", () => {
|
||||
it("starts at 30s on first attempt", () => expect(backoffSeconds(1)).toBe(60));
|
||||
it("returns 30s for zero attempts", () => expect(backoffSeconds(0)).toBe(30));
|
||||
it("doubles up to 1h cap", () => {
|
||||
expect(backoffSeconds(5)).toBe(960);
|
||||
expect(backoffSeconds(10)).toBe(3600);
|
||||
expect(backoffSeconds(50)).toBe(3600);
|
||||
});
|
||||
it("never negative", () => {
|
||||
for (let i = 0; i < 100; i++) expect(backoffSeconds(i)).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildDefaultShape } from "../default-shape.js";
|
||||
|
||||
const cfg = {
|
||||
accountIdField: "id",
|
||||
accountLabelField: "login",
|
||||
} as const;
|
||||
|
||||
describe("buildDefaultShape", () => {
|
||||
const shape = buildDefaultShape(cfg);
|
||||
|
||||
it("parses RFC-6749 token response", () => {
|
||||
expect(
|
||||
shape.parseTokenResponse!({
|
||||
access_token: "abc",
|
||||
refresh_token: "def",
|
||||
expires_in: 3600,
|
||||
scope: "repo user",
|
||||
}),
|
||||
).toEqual({
|
||||
accessToken: "abc",
|
||||
refreshToken: "def",
|
||||
expiresInSeconds: 3600,
|
||||
scope: ["repo", "user"],
|
||||
});
|
||||
});
|
||||
|
||||
it("parses account info via configured fields", () => {
|
||||
expect(shape.parseAccountInfo!({ id: 42, login: "octocat" })).toEqual({
|
||||
accountId: "42",
|
||||
accountLabel: "octocat",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects missing access_token", () => {
|
||||
expect(() => shape.parseTokenResponse!({})).toThrow();
|
||||
});
|
||||
|
||||
it("rejects negative expires_in", () => {
|
||||
expect(() => shape.parseTokenResponse!({ access_token: "x", expires_in: -1 })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects expires_in over a year", () => {
|
||||
expect(() =>
|
||||
shape.parseTokenResponse!({ access_token: "x", expires_in: 60_000_000 }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects non-string account id", () => {
|
||||
expect(() => shape.parseAccountInfo!({ id: { nested: 1 }, login: "x" })).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getByPath } from "../dot-path.js";
|
||||
|
||||
describe("getByPath", () => {
|
||||
it("reads top-level field", () => expect(getByPath({ id: 1 }, "id")).toBe(1));
|
||||
it("reads nested field", () => expect(getByPath({ team: { id: "abc" } }, "team.id")).toBe("abc"));
|
||||
it("returns null for missing nested", () => expect(getByPath({ a: {} }, "a.b.c")).toBeNull());
|
||||
it("returns null for null intermediates", () => expect(getByPath({ a: null }, "a.b")).toBeNull());
|
||||
it("returns null for non-object root", () => expect(getByPath(null as unknown, "a.b")).toBeNull());
|
||||
it("ignores prototype pollution paths", () => {
|
||||
expect(getByPath({}, "__proto__.polluted")).toBeNull();
|
||||
expect(getByPath({}, "constructor.prototype")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
id: mock
|
||||
displayName: Mock
|
||||
clientCredentials:
|
||||
clientIdEnv: MOCK_OAUTH_CLIENT_ID
|
||||
clientSecretEnv: MOCK_OAUTH_CLIENT_SECRET
|
||||
endpoints:
|
||||
authorize: https://mock.example/auth
|
||||
token: https://mock.example/token
|
||||
accountInfo: https://mock.example/me
|
||||
scopes:
|
||||
default: [read]
|
||||
offered: [read, write]
|
||||
pkce: required
|
||||
authMethod: post
|
||||
responseFormat: json
|
||||
accountIdField: id
|
||||
accountLabelField: name
|
||||
refresh:
|
||||
supported: true
|
||||
rotatesRefreshToken: true
|
||||
@@ -0,0 +1,209 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import type { Server } from "node:http";
|
||||
import { exchangeToken, fetchAccountInfo, OAuthRequestError } from "../http.js";
|
||||
|
||||
interface ExchangeFixture {
|
||||
close: () => void;
|
||||
url: string;
|
||||
readonly lastBody: string;
|
||||
readonly lastAuthHeader: string;
|
||||
readonly hits: number;
|
||||
}
|
||||
|
||||
interface AccountInfoFixture {
|
||||
close: () => void;
|
||||
url: string;
|
||||
readonly lastAuth: string;
|
||||
readonly hits: number;
|
||||
}
|
||||
|
||||
describe("exchangeToken", () => {
|
||||
let server: ExchangeFixture;
|
||||
let raw: Server;
|
||||
|
||||
beforeEach(async () => {
|
||||
const http = await import("node:http");
|
||||
let lastBody = "";
|
||||
let lastAuthHeader = "";
|
||||
let hits = 0;
|
||||
const s = http.createServer((req, res) => {
|
||||
hits++;
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (c) => chunks.push(c));
|
||||
req.on("end", () => {
|
||||
lastBody = Buffer.concat(chunks).toString("utf8");
|
||||
lastAuthHeader = String(req.headers.authorization ?? "");
|
||||
if (req.url === "/fail500") { res.statusCode = 500; res.end("nope"); return; }
|
||||
if (req.url === "/fail400") {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("content-type", "application/json");
|
||||
res.end(JSON.stringify({ error: "invalid_grant" }));
|
||||
return;
|
||||
}
|
||||
if (req.url === "/html200") {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("content-type", "application/json");
|
||||
res.end("<html>not json</html>");
|
||||
return;
|
||||
}
|
||||
if (req.url === "/form200") {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("content-type", "application/x-www-form-urlencoded");
|
||||
res.end("access_token=AT&token_type=bearer&expires_in=3600");
|
||||
return;
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader("content-type", "application/json");
|
||||
res.end(JSON.stringify({ access_token: "x", expires_in: 60 }));
|
||||
});
|
||||
});
|
||||
raw = s;
|
||||
await new Promise<void>((r) => s.listen(0, r));
|
||||
const port = (s.address() as { port: number }).port;
|
||||
server = {
|
||||
close: () => s.close(),
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
get lastBody() { return lastBody; },
|
||||
get lastAuthHeader() { return lastAuthHeader; },
|
||||
get hits() { return hits; },
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => server.close());
|
||||
|
||||
it("posts form body and parses json response", async () => {
|
||||
const res = await exchangeToken({
|
||||
url: `${server.url}/ok`,
|
||||
params: { grant_type: "authorization_code", code: "abc" },
|
||||
authMethod: "post",
|
||||
responseFormat: "json",
|
||||
clientId: "cid",
|
||||
clientSecret: "csec",
|
||||
});
|
||||
expect(res).toMatchObject({ access_token: "x" });
|
||||
expect(server.lastBody).toContain("grant_type=authorization_code");
|
||||
expect(server.lastBody).toContain("client_id=cid");
|
||||
});
|
||||
|
||||
it("retries on 5xx", async () => {
|
||||
await expect(
|
||||
exchangeToken({
|
||||
url: `${server.url}/fail500`,
|
||||
params: { grant_type: "authorization_code", code: "abc" },
|
||||
authMethod: "post",
|
||||
responseFormat: "json",
|
||||
clientId: "cid",
|
||||
clientSecret: "csec",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
// 1 initial + 2 retries = 3 attempts
|
||||
expect(server.hits).toBe(3);
|
||||
});
|
||||
|
||||
it("does not retry on 4xx", async () => {
|
||||
await expect(
|
||||
exchangeToken({
|
||||
url: `${server.url}/fail400`,
|
||||
params: { grant_type: "authorization_code", code: "abc" },
|
||||
authMethod: "post",
|
||||
responseFormat: "json",
|
||||
clientId: "cid",
|
||||
clientSecret: "csec",
|
||||
}),
|
||||
).rejects.toThrow(/invalid_grant/);
|
||||
expect(server.hits).toBe(1);
|
||||
});
|
||||
|
||||
it("authMethod 'basic' sends Authorization header", async () => {
|
||||
await exchangeToken({
|
||||
url: `${server.url}/ok`,
|
||||
params: { grant_type: "authorization_code", code: "abc" },
|
||||
authMethod: "basic",
|
||||
responseFormat: "json",
|
||||
clientId: "the-client",
|
||||
clientSecret: "the-secret",
|
||||
});
|
||||
const expected = `Basic ${Buffer.from("the-client:the-secret").toString("base64")}`;
|
||||
expect(server.lastAuthHeader).toBe(expected);
|
||||
// basic auth must not duplicate credentials in the body
|
||||
expect(server.lastBody).not.toContain("client_id=the-client");
|
||||
expect(server.lastBody).not.toContain("client_secret=the-secret");
|
||||
});
|
||||
|
||||
it("responseFormat 'form' parses application/x-www-form-urlencoded", async () => {
|
||||
const res = await exchangeToken({
|
||||
url: `${server.url}/form200`,
|
||||
params: { grant_type: "authorization_code", code: "abc" },
|
||||
authMethod: "post",
|
||||
responseFormat: "form",
|
||||
clientId: "cid",
|
||||
clientSecret: "csec",
|
||||
});
|
||||
expect(res.access_token).toBe("AT");
|
||||
expect(res.token_type).toBe("bearer");
|
||||
// URLSearchParams returns string values; expires_in stays a string here.
|
||||
expect(res.expires_in).toBe("3600");
|
||||
});
|
||||
|
||||
it("throws OAuthRequestError when 200 response has unparseable JSON body", async () => {
|
||||
let caught: unknown;
|
||||
try {
|
||||
await exchangeToken({
|
||||
url: `${server.url}/html200`,
|
||||
params: { grant_type: "authorization_code", code: "abc" },
|
||||
authMethod: "post",
|
||||
responseFormat: "json",
|
||||
clientId: "cid",
|
||||
clientSecret: "csec",
|
||||
});
|
||||
} catch (err) {
|
||||
caught = err;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(OAuthRequestError);
|
||||
const oauthErr = caught as OAuthRequestError;
|
||||
expect(oauthErr.status).toBe(200);
|
||||
expect(oauthErr.message).toContain("unparseable body");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchAccountInfo", () => {
|
||||
let server: AccountInfoFixture;
|
||||
|
||||
beforeEach(async () => {
|
||||
const http = await import("node:http");
|
||||
let lastAuth = "";
|
||||
let hits = 0;
|
||||
const s = http.createServer((req, res) => {
|
||||
hits++;
|
||||
lastAuth = String(req.headers.authorization ?? "");
|
||||
if (req.url === "/bad") {
|
||||
res.statusCode = 401;
|
||||
res.end("unauthorized");
|
||||
return;
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader("content-type", "application/json");
|
||||
res.end(JSON.stringify({ id: "u1", auth: lastAuth }));
|
||||
});
|
||||
await new Promise<void>((r) => s.listen(0, r));
|
||||
const port = (s.address() as { port: number }).port;
|
||||
server = {
|
||||
close: () => s.close(),
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
get lastAuth() { return lastAuth; },
|
||||
get hits() { return hits; },
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => server.close());
|
||||
|
||||
it("sends Bearer token and returns parsed JSON", async () => {
|
||||
const res = await fetchAccountInfo(`${server.url}/me`, "TOKEN");
|
||||
expect(res).toMatchObject({ id: "u1", auth: "Bearer TOKEN" });
|
||||
expect(server.lastAuth).toBe("Bearer TOKEN");
|
||||
});
|
||||
|
||||
it("throws on non-2xx", async () => {
|
||||
await expect(fetchAccountInfo(`${server.url}/bad`, "TOKEN")).rejects.toThrow(/401/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { PassThrough } from "node:stream";
|
||||
import pino from "pino";
|
||||
import { OAUTH_REDACT_PATHS } from "../logger.js";
|
||||
|
||||
describe("oauthLogger", () => {
|
||||
it("redacts access_token, refresh_token, and code", async () => {
|
||||
// We don't spy on the real oauthLogger's output: the production logger
|
||||
// uses pino.transport({ targets }), which runs in a worker thread, so
|
||||
// process.stdout.write spying from the main test process can't see its
|
||||
// output. Instead we build a parallel pino logger using the exported
|
||||
// OAUTH_REDACT_PATHS and route it to an in-memory stream. This test
|
||||
// verifies the redact-path list itself is correct; pino's redact
|
||||
// behavior under transport workers is pino's responsibility.
|
||||
const stream = new PassThrough();
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on("data", (c) => chunks.push(c));
|
||||
const parent = pino({ level: "info" }, stream);
|
||||
|
||||
// Sanity: import the real module so a regression in its file/export shape still fails the test.
|
||||
const real = await import("../logger.js");
|
||||
expect(real.oauthLogger).toBeDefined();
|
||||
const child = parent.child(
|
||||
{ component: "oauth" },
|
||||
{ redact: { paths: OAUTH_REDACT_PATHS, censor: "[REDACTED]" } },
|
||||
);
|
||||
|
||||
child.info(
|
||||
{ access_token: "ACCESS_SECRET", refresh_token: "REFRESH_SECRET", code: "CODE_VAL" },
|
||||
"test event",
|
||||
);
|
||||
|
||||
// Flush
|
||||
await new Promise((r) => setImmediate(r));
|
||||
const all = Buffer.concat(chunks).toString("utf8");
|
||||
expect(all).not.toContain("ACCESS_SECRET");
|
||||
expect(all).not.toContain("REFRESH_SECRET");
|
||||
expect(all).not.toContain("CODE_VAL");
|
||||
expect(all).toContain("[REDACTED]");
|
||||
expect(all).toContain("oauth");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { generateCodeVerifier, deriveCodeChallenge } from "../pkce.js";
|
||||
|
||||
describe("PKCE", () => {
|
||||
it("generates a base64url verifier of at least 43 characters", () => {
|
||||
const v = generateCodeVerifier();
|
||||
expect(v.length).toBeGreaterThanOrEqual(43);
|
||||
expect(v).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
});
|
||||
it("derives the RFC 7636 sample challenge", () => {
|
||||
const verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
|
||||
const expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
|
||||
expect(deriveCodeChallenge(verifier)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
import { ProviderRegistry } from "../registry.js";
|
||||
import { registerPluginContributions } from "../plugin-loader.js";
|
||||
|
||||
const baseConfig = {
|
||||
id: "stripe",
|
||||
displayName: "Stripe",
|
||||
clientCredentials: {
|
||||
clientIdEnv: "STRIPE_OAUTH_CLIENT_ID",
|
||||
clientSecretEnv: "STRIPE_OAUTH_CLIENT_SECRET",
|
||||
},
|
||||
endpoints: {
|
||||
authorize: "https://stripe.example/a",
|
||||
token: "https://stripe.example/t",
|
||||
accountInfo: "https://stripe.example/me",
|
||||
},
|
||||
scopes: { default: [], offered: [] },
|
||||
pkce: "required" as const,
|
||||
authMethod: "post" as const,
|
||||
responseFormat: "json" as const,
|
||||
accountIdField: "id",
|
||||
accountLabelField: "name",
|
||||
refresh: { supported: false as const },
|
||||
};
|
||||
|
||||
const manifestBase = {
|
||||
id: "example.stripe",
|
||||
apiVersion: 1 as const,
|
||||
version: "1.0.0",
|
||||
displayName: "Stripe",
|
||||
description: "Stripe OAuth provider",
|
||||
author: "Example",
|
||||
categories: ["integration"],
|
||||
capabilities: ["plugin.bridge"],
|
||||
entrypoints: { worker: "./worker.js" },
|
||||
};
|
||||
|
||||
const oauthManifest = {
|
||||
...manifestBase,
|
||||
kind: "oauth_provider" as const,
|
||||
oauthProviders: [{ config: baseConfig }],
|
||||
} as unknown as PaperclipPluginManifestV1;
|
||||
|
||||
describe("registerPluginContributions", () => {
|
||||
it("registers provider when env set", () => {
|
||||
const env = {
|
||||
STRIPE_OAUTH_CLIENT_ID: "x",
|
||||
STRIPE_OAUTH_CLIENT_SECRET: "y",
|
||||
} as Record<string, string>;
|
||||
const r = new ProviderRegistry({ env });
|
||||
registerPluginContributions(r, [oauthManifest]);
|
||||
expect(r.get("stripe")).toBeDefined();
|
||||
expect(r.get("stripe")?.source).toBe("plugin");
|
||||
});
|
||||
|
||||
it("rejects malformed config (Zod validation runs)", () => {
|
||||
const env = {
|
||||
STRIPE_OAUTH_CLIENT_ID: "x",
|
||||
STRIPE_OAUTH_CLIENT_SECRET: "y",
|
||||
} as Record<string, string>;
|
||||
const r = new ProviderRegistry({ env });
|
||||
const bad = {
|
||||
...manifestBase,
|
||||
kind: "oauth_provider" as const,
|
||||
oauthProviders: [{ config: { ...baseConfig, pkce: "weird" } }],
|
||||
} as unknown as PaperclipPluginManifestV1;
|
||||
expect(() => registerPluginContributions(r, [bad])).toThrow();
|
||||
});
|
||||
|
||||
it("ignores manifests with non-oauth kind", () => {
|
||||
const env = {
|
||||
STRIPE_OAUTH_CLIENT_ID: "x",
|
||||
STRIPE_OAUTH_CLIENT_SECRET: "y",
|
||||
} as Record<string, string>;
|
||||
const r = new ProviderRegistry({ env });
|
||||
const sandbox = {
|
||||
...manifestBase,
|
||||
kind: "sandbox_provider" as const,
|
||||
oauthProviders: [{ config: baseConfig }],
|
||||
} as unknown as PaperclipPluginManifestV1;
|
||||
registerPluginContributions(r, [sandbox]);
|
||||
expect(r.get("stripe")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("processes manifests with composite kind", () => {
|
||||
const env = {
|
||||
STRIPE_OAUTH_CLIENT_ID: "x",
|
||||
STRIPE_OAUTH_CLIENT_SECRET: "y",
|
||||
} as Record<string, string>;
|
||||
const r = new ProviderRegistry({ env });
|
||||
const composite = {
|
||||
...manifestBase,
|
||||
kind: "composite" as const,
|
||||
oauthProviders: [{ config: baseConfig }],
|
||||
} as unknown as PaperclipPluginManifestV1;
|
||||
registerPluginContributions(r, [composite]);
|
||||
expect(r.get("stripe")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { OAuthProviderConfigSchema } from "../provider-config.js";
|
||||
|
||||
const VALID = {
|
||||
id: "github",
|
||||
displayName: "GitHub",
|
||||
clientCredentials: { clientIdEnv: "X_ID", clientSecretEnv: "X_SECRET" },
|
||||
endpoints: {
|
||||
authorize: "https://github.com/login/oauth/authorize",
|
||||
token: "https://github.com/login/oauth/access_token",
|
||||
accountInfo: "https://api.github.com/user",
|
||||
},
|
||||
scopes: { default: ["repo"], offered: ["repo", "workflow"] },
|
||||
pkce: "required",
|
||||
authMethod: "post",
|
||||
responseFormat: "json",
|
||||
accountIdField: "id",
|
||||
accountLabelField: "login",
|
||||
refresh: { supported: false },
|
||||
};
|
||||
|
||||
describe("OAuthProviderConfigSchema", () => {
|
||||
it("accepts a valid config", () => {
|
||||
expect(OAuthProviderConfigSchema.parse(VALID).id).toBe("github");
|
||||
});
|
||||
|
||||
it("rejects http:// endpoints", () => {
|
||||
const bad = {
|
||||
...VALID,
|
||||
endpoints: { ...VALID.endpoints, token: "http://insecure.example/token" },
|
||||
};
|
||||
expect(() => OAuthProviderConfigSchema.parse(bad)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects unknown PKCE mode", () => {
|
||||
expect(() => OAuthProviderConfigSchema.parse({ ...VALID, pkce: "weird" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects refresh.supported=true without rotatesRefreshToken", () => {
|
||||
const bad = { ...VALID, refresh: { supported: true } };
|
||||
expect(() => OAuthProviderConfigSchema.parse(bad)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects scopes.default not subset of offered", () => {
|
||||
const bad = { ...VALID, scopes: { default: ["unknown"], offered: ["repo"] } };
|
||||
expect(() => OAuthProviderConfigSchema.parse(bad)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { createSlidingWindowLimiter } from "../rate-limiter.js";
|
||||
|
||||
describe("createSlidingWindowLimiter", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("allows up to limit then blocks", async () => {
|
||||
const limit = createSlidingWindowLimiter({ limit: 3, windowMs: 60_000 });
|
||||
expect(await limit.check("k1")).toBe(true);
|
||||
expect(await limit.check("k1")).toBe(true);
|
||||
expect(await limit.check("k1")).toBe(true);
|
||||
expect(await limit.check("k1")).toBe(false);
|
||||
});
|
||||
|
||||
it("scopes by key", async () => {
|
||||
const limit = createSlidingWindowLimiter({ limit: 1, windowMs: 60_000 });
|
||||
expect(await limit.check("a")).toBe(true);
|
||||
expect(await limit.check("b")).toBe(true);
|
||||
expect(await limit.check("a")).toBe(false);
|
||||
});
|
||||
|
||||
it("expires entries after window", async () => {
|
||||
const limit = createSlidingWindowLimiter({ limit: 1, windowMs: 60_000 });
|
||||
expect(await limit.check("k")).toBe(true);
|
||||
expect(await limit.check("k")).toBe(false);
|
||||
vi.advanceTimersByTime(61_000);
|
||||
expect(await limit.check("k")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { validateReturnUrl } from "../redirect-allowlist.js";
|
||||
|
||||
const PUBLIC = "https://app.paperclip.test";
|
||||
|
||||
describe("validateReturnUrl", () => {
|
||||
it("accepts /settings/* paths", () => expect(validateReturnUrl("/settings/connections", PUBLIC)).toBe("/settings/connections"));
|
||||
it("accepts /agents/* paths", () => expect(validateReturnUrl("/agents/abc", PUBLIC)).toBe("/agents/abc"));
|
||||
it("accepts /runs/* paths", () => expect(validateReturnUrl("/runs/xyz", PUBLIC)).toBe("/runs/xyz"));
|
||||
it("rejects cross-origin absolute URLs", () => expect(validateReturnUrl("https://evil.example/x", PUBLIC)).toBe("/settings/connections"));
|
||||
it("rejects javascript: scheme", () => expect(validateReturnUrl("javascript:alert(1)", PUBLIC)).toBe("/settings/connections"));
|
||||
it("rejects data: scheme", () => expect(validateReturnUrl("data:text/html,x", PUBLIC)).toBe("/settings/connections"));
|
||||
it("rejects schema-relative //evil.example", () => expect(validateReturnUrl("//evil.example/x", PUBLIC)).toBe("/settings/connections"));
|
||||
it("rejects double-encoded slashes", () => expect(validateReturnUrl("https:%2F%2Fevil.example", PUBLIC)).toBe("/settings/connections"));
|
||||
it("rejects backslash schema-relative", () => expect(validateReturnUrl("\\\\evil.example/x", PUBLIC)).toBe("/settings/connections"));
|
||||
it("falls back when undefined", () => expect(validateReturnUrl(undefined, PUBLIC)).toBe("/settings/connections"));
|
||||
it("rejects /admin/x (not in allowlist)", () => expect(validateReturnUrl("/admin/x", PUBLIC)).toBe("/settings/connections"));
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { runRefreshTick } from "../refresh-worker.js";
|
||||
|
||||
describe("runRefreshTick", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-10T00:00:00Z"));
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
function buildDeps(opts: {
|
||||
candidates: Array<Record<string, unknown>>;
|
||||
locked?: boolean;
|
||||
refreshFn?: ReturnType<typeof vi.fn>;
|
||||
}) {
|
||||
const acquired = opts.locked !== false;
|
||||
// The worker now runs the tick inside a single `db.transaction(cb)` and
|
||||
// takes a transaction-scoped advisory lock (`pg_try_advisory_xact_lock`).
|
||||
// We mock the transaction wrapper so it just invokes the callback with a
|
||||
// `tx` proxy that implements `execute` (lock probe) and the relational
|
||||
// `query.oauthConnections.findMany` accessor the worker uses.
|
||||
const execute = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ rows: [{ result: acquired }] });
|
||||
const findMany = vi.fn().mockResolvedValue(opts.candidates);
|
||||
const refreshFn =
|
||||
opts.refreshFn ??
|
||||
vi.fn().mockResolvedValue({ outcome: "success", accessToken: "x" });
|
||||
const tx = {
|
||||
execute,
|
||||
query: { oauthConnections: { findMany } },
|
||||
};
|
||||
const transaction = vi.fn(async (cb: (tx: unknown) => Promise<void>) => {
|
||||
return await cb(tx);
|
||||
});
|
||||
const db = {
|
||||
transaction,
|
||||
} as unknown as Parameters<typeof runRefreshTick>[0]["db"];
|
||||
return { db, refreshFn, execute, findMany, transaction };
|
||||
}
|
||||
|
||||
it("filters out rows still in backoff and refreshes the rest", async () => {
|
||||
const candidates = [
|
||||
{ id: "a", refreshAttemptCount: 0, lastErrorAt: null },
|
||||
// 3 attempts -> backoff = min(2^3 * 30, 3600) = 240s; lastErrorAt = now
|
||||
{ id: "b", refreshAttemptCount: 3, lastErrorAt: new Date() },
|
||||
];
|
||||
const { db, refreshFn } = buildDeps({ candidates });
|
||||
await runRefreshTick({
|
||||
db,
|
||||
refreshFn,
|
||||
registry: {} as never,
|
||||
secretService: {} as never,
|
||||
});
|
||||
expect(refreshFn).toHaveBeenCalledTimes(1);
|
||||
expect(refreshFn.mock.calls[0]?.[0].connectionId).toBe("a");
|
||||
});
|
||||
|
||||
it("skips when advisory lock not acquired", async () => {
|
||||
const { db, refreshFn } = buildDeps({ candidates: [], locked: false });
|
||||
await runRefreshTick({
|
||||
db,
|
||||
refreshFn,
|
||||
registry: {} as never,
|
||||
secretService: {} as never,
|
||||
});
|
||||
expect(refreshFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("filters a row with refreshAttemptCount=1 whose backoff (60s) hasn't elapsed", async () => {
|
||||
// backoffSeconds(1) = min(2 * 30, 3600) = 60s.
|
||||
// lastErrorAt is 10s ago, so minRetryAt = 50s in the future -> filter out.
|
||||
const candidates = [
|
||||
{
|
||||
id: "in-backoff",
|
||||
refreshAttemptCount: 1,
|
||||
lastErrorAt: new Date(Date.now() - 10_000),
|
||||
},
|
||||
// Also include a control row that *should* be eligible.
|
||||
{ id: "ok", refreshAttemptCount: 0, lastErrorAt: null },
|
||||
];
|
||||
const { db, refreshFn } = buildDeps({ candidates });
|
||||
await runRefreshTick({
|
||||
db,
|
||||
refreshFn,
|
||||
registry: {} as never,
|
||||
secretService: {} as never,
|
||||
});
|
||||
expect(refreshFn).toHaveBeenCalledTimes(1);
|
||||
expect(refreshFn.mock.calls[0]?.[0].connectionId).toBe("ok");
|
||||
});
|
||||
|
||||
it("acquires the transaction-scoped advisory lock exactly once per tick", async () => {
|
||||
const { db, refreshFn, execute, transaction } = buildDeps({
|
||||
candidates: [],
|
||||
});
|
||||
await runRefreshTick({
|
||||
db,
|
||||
refreshFn,
|
||||
registry: {} as never,
|
||||
secretService: {} as never,
|
||||
});
|
||||
// Lock is acquired with a single `pg_try_advisory_xact_lock` probe — no
|
||||
// explicit unlock query, because Postgres releases xact-scoped advisory
|
||||
// locks at COMMIT/ROLLBACK. The whole tick runs inside one transaction.
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
const firstCallArgs = execute.mock.calls[0];
|
||||
expect(firstCallArgs).toBeDefined();
|
||||
});
|
||||
|
||||
it("logs and continues if a refreshFn throws", async () => {
|
||||
const candidates = [
|
||||
{ id: "a", refreshAttemptCount: 0, lastErrorAt: null },
|
||||
{ id: "b", refreshAttemptCount: 0, lastErrorAt: null },
|
||||
];
|
||||
const refreshFn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("boom"))
|
||||
.mockResolvedValueOnce({ outcome: "success", accessToken: "x" });
|
||||
const { db } = buildDeps({ candidates, refreshFn });
|
||||
await runRefreshTick({
|
||||
db,
|
||||
refreshFn,
|
||||
registry: {} as never,
|
||||
secretService: {} as never,
|
||||
});
|
||||
// Both rows attempted despite the first throwing.
|
||||
expect(refreshFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { refreshConnection } from "../refresh.js";
|
||||
|
||||
interface FakeRow {
|
||||
id: string;
|
||||
companyId: string;
|
||||
providerId: string;
|
||||
status: string;
|
||||
scopes: string[];
|
||||
accountId: string | null;
|
||||
accessTokenSecretId: string;
|
||||
refreshTokenSecretId: string | null;
|
||||
accessTokenExpiresAt: Date | null;
|
||||
lastError: string | null;
|
||||
lastErrorAt: Date | null;
|
||||
refreshAttemptCount: number;
|
||||
}
|
||||
|
||||
const fakeRow = (overrides: Partial<FakeRow> = {}): FakeRow => ({
|
||||
id: "conn-1",
|
||||
companyId: "c1",
|
||||
providerId: "github",
|
||||
status: "active",
|
||||
scopes: ["repo"],
|
||||
accountId: "acct-42",
|
||||
accessTokenSecretId: "s-access",
|
||||
refreshTokenSecretId: "s-refresh",
|
||||
accessTokenExpiresAt: new Date(Date.now() + 30_000),
|
||||
lastError: null,
|
||||
lastErrorAt: null,
|
||||
refreshAttemptCount: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const fakeProvider = (rotates = false) => ({
|
||||
config: {
|
||||
id: "github",
|
||||
endpoints: { token: "https://x/t", accountInfo: "https://x/me" },
|
||||
authMethod: "post" as const,
|
||||
responseFormat: "json" as const,
|
||||
accountIdField: "id",
|
||||
accountLabelField: "login",
|
||||
refresh: { supported: true, rotatesRefreshToken: rotates },
|
||||
},
|
||||
clientId: "id",
|
||||
clientSecret: "sec",
|
||||
shape: {
|
||||
parseTokenResponse: (r: Record<string, unknown>) => ({
|
||||
accessToken: r.access_token as string,
|
||||
refreshToken:
|
||||
typeof r.refresh_token === "string" ? r.refresh_token : undefined,
|
||||
expiresInSeconds:
|
||||
typeof r.expires_in === "number" ? r.expires_in : undefined,
|
||||
scope: typeof r.scope === "string" ? r.scope.split(" ") : undefined,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
function makeTxDb(row: FakeRow, capture: { update?: any } = {}) {
|
||||
const tx = {
|
||||
query: {
|
||||
oauthConnections: {
|
||||
findFirst: vi.fn().mockResolvedValue(row),
|
||||
},
|
||||
},
|
||||
update: () => ({
|
||||
set: (v: unknown) => ({
|
||||
where: () => {
|
||||
// Last write wins — refresh.ts only issues one update per call path.
|
||||
capture.update = v;
|
||||
return Promise.resolve();
|
||||
},
|
||||
}),
|
||||
}),
|
||||
};
|
||||
return {
|
||||
transaction: async (fn: (txArg: unknown) => Promise<unknown>) => fn(tx),
|
||||
};
|
||||
}
|
||||
|
||||
describe("refreshConnection", () => {
|
||||
it("rotates access token + persists new version on success", async () => {
|
||||
const captured: { update?: any } = {};
|
||||
const upsertSecretByName = vi
|
||||
.fn()
|
||||
.mockImplementation(async (_companyId: string, input: { name: string }) =>
|
||||
input.name.endsWith(":access")
|
||||
? { id: "new-access" }
|
||||
: { id: "new-refresh" },
|
||||
);
|
||||
const result = await refreshConnection({
|
||||
connectionId: "conn-1",
|
||||
db: makeTxDb(fakeRow(), captured) as any,
|
||||
registry: { get: () => fakeProvider(false) } as any,
|
||||
secretService: {
|
||||
resolveSecretValue: async () => "OLD_REFRESH",
|
||||
upsertSecretByName,
|
||||
},
|
||||
exchangeFn: async () => ({
|
||||
access_token: "NEW_ACCESS",
|
||||
expires_in: 3600,
|
||||
scope: "repo",
|
||||
}),
|
||||
});
|
||||
expect(result.outcome).toBe("success");
|
||||
if (result.outcome === "success") {
|
||||
expect(result.accessToken).toBe("NEW_ACCESS");
|
||||
}
|
||||
expect(upsertSecretByName).toHaveBeenCalledWith(
|
||||
"c1",
|
||||
expect.objectContaining({
|
||||
name: "oauth:github:acct-42:access",
|
||||
value: "NEW_ACCESS",
|
||||
}),
|
||||
);
|
||||
expect(captured.update).toMatchObject({
|
||||
status: "active",
|
||||
accessTokenSecretId: "new-access",
|
||||
refreshAttemptCount: 0,
|
||||
lastError: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses connectionId in secret name when accountId is null", async () => {
|
||||
const upsertSecretByName = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ id: "new-access" });
|
||||
await refreshConnection({
|
||||
connectionId: "conn-1",
|
||||
db: makeTxDb(fakeRow({ accountId: null })) as any,
|
||||
registry: { get: () => fakeProvider(false) } as any,
|
||||
secretService: {
|
||||
resolveSecretValue: async () => "OLD_REFRESH",
|
||||
upsertSecretByName,
|
||||
},
|
||||
exchangeFn: async () => ({ access_token: "A", expires_in: 60 }),
|
||||
});
|
||||
expect(upsertSecretByName).toHaveBeenCalledWith(
|
||||
"c1",
|
||||
expect.objectContaining({ name: "oauth:github:conn-1:access" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("flips status to revoked on invalid_grant (duck-typed)", async () => {
|
||||
const captured: { update?: any } = {};
|
||||
const result = await refreshConnection({
|
||||
connectionId: "conn-1",
|
||||
db: makeTxDb(fakeRow(), captured) as any,
|
||||
registry: { get: () => fakeProvider(false) } as any,
|
||||
secretService: {
|
||||
resolveSecretValue: async () => "x",
|
||||
upsertSecretByName: vi.fn(),
|
||||
},
|
||||
exchangeFn: async () => {
|
||||
const e = new Error("invalid_grant") as Error & {
|
||||
status?: number;
|
||||
providerErrorCode?: string;
|
||||
};
|
||||
e.status = 400;
|
||||
e.providerErrorCode = "invalid_grant";
|
||||
throw e;
|
||||
},
|
||||
});
|
||||
expect(result.outcome).toBe("revoked");
|
||||
expect(captured.update).toMatchObject({
|
||||
status: "revoked",
|
||||
lastError: "invalid_grant",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns transient on non-permanent errors and bumps attempt count", async () => {
|
||||
const captured: { update?: any } = {};
|
||||
const startRow = fakeRow({ refreshAttemptCount: 2 });
|
||||
const result = await refreshConnection({
|
||||
connectionId: "conn-1",
|
||||
db: makeTxDb(startRow, captured) as any,
|
||||
registry: { get: () => fakeProvider(false) } as any,
|
||||
secretService: {
|
||||
resolveSecretValue: async () => "x",
|
||||
upsertSecretByName: vi.fn(),
|
||||
},
|
||||
exchangeFn: async () => {
|
||||
const e = new Error("network down") as Error & { status?: number };
|
||||
e.status = 502;
|
||||
throw e;
|
||||
},
|
||||
});
|
||||
expect(result.outcome).toBe("transient");
|
||||
expect(captured.update).toMatchObject({
|
||||
lastError: "network down",
|
||||
refreshAttemptCount: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("rotates refresh token when provider returns one", async () => {
|
||||
const captured: { update?: any } = {};
|
||||
const upsertSecretByName = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => ({ id: "new-access" }))
|
||||
.mockImplementationOnce(async () => ({ id: "new-refresh" }));
|
||||
const result = await refreshConnection({
|
||||
connectionId: "conn-1",
|
||||
db: makeTxDb(fakeRow(), captured) as any,
|
||||
registry: { get: () => fakeProvider(true) } as any,
|
||||
secretService: {
|
||||
resolveSecretValue: async () => "OLD_REFRESH",
|
||||
upsertSecretByName,
|
||||
},
|
||||
exchangeFn: async () => ({
|
||||
access_token: "NEW_ACCESS",
|
||||
refresh_token: "NEW_REFRESH",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
});
|
||||
expect(result.outcome).toBe("success");
|
||||
expect(upsertSecretByName).toHaveBeenCalledTimes(2);
|
||||
expect(captured.update).toMatchObject({
|
||||
accessTokenSecretId: "new-access",
|
||||
refreshTokenSecretId: "new-refresh",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips when row has no refresh token secret", async () => {
|
||||
const result = await refreshConnection({
|
||||
connectionId: "conn-1",
|
||||
db: makeTxDb(fakeRow({ refreshTokenSecretId: null })) as any,
|
||||
registry: { get: () => fakeProvider(false) } as any,
|
||||
secretService: {
|
||||
resolveSecretValue: async () => "x",
|
||||
upsertSecretByName: vi.fn(),
|
||||
},
|
||||
exchangeFn: async () => ({ access_token: "a" }),
|
||||
});
|
||||
expect(result.outcome).toBe("skipped");
|
||||
if (result.outcome === "skipped") {
|
||||
expect(result.reason).toBe("no_refresh_token");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ProviderRegistry } from "../registry.js";
|
||||
import type { OAuthProviderConfig } from "../provider-config.js";
|
||||
|
||||
const cfg = (id: string): OAuthProviderConfig => ({
|
||||
id,
|
||||
displayName: id,
|
||||
clientCredentials: {
|
||||
clientIdEnv: `${id.toUpperCase()}_ID`,
|
||||
clientSecretEnv: `${id.toUpperCase()}_SECRET`,
|
||||
},
|
||||
endpoints: {
|
||||
authorize: "https://x.example/a",
|
||||
token: "https://x.example/t",
|
||||
accountInfo: "https://x.example/u",
|
||||
},
|
||||
scopes: { default: [], offered: [] },
|
||||
pkce: "required",
|
||||
authMethod: "post",
|
||||
responseFormat: "json",
|
||||
accountIdField: "id",
|
||||
accountLabelField: "name",
|
||||
refresh: { supported: false },
|
||||
});
|
||||
|
||||
describe("ProviderRegistry", () => {
|
||||
it("registers a provider when env vars set", () => {
|
||||
const r = new ProviderRegistry({
|
||||
env: { GH_ID: "id", GH_SECRET: "s" } as Record<string, string>,
|
||||
});
|
||||
r.register(cfg("gh"), "yaml");
|
||||
expect(r.get("gh")?.clientId).toBe("id");
|
||||
});
|
||||
|
||||
it("skips a provider when env vars missing", () => {
|
||||
const r = new ProviderRegistry({ env: {} });
|
||||
r.register(cfg("gh"), "yaml");
|
||||
expect(r.get("gh")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("file source wins over plugin source", () => {
|
||||
const env = { GH_ID: "id", GH_SECRET: "s" } as Record<string, string>;
|
||||
const r = new ProviderRegistry({ env });
|
||||
r.register(cfg("gh"), "yaml");
|
||||
r.register({ ...cfg("gh"), displayName: "Plugin GH" }, "plugin");
|
||||
expect(r.get("gh")?.config.displayName).toBe("gh");
|
||||
});
|
||||
|
||||
it("plugin loaded first then yaml: yaml replaces", () => {
|
||||
const env = { GH_ID: "id", GH_SECRET: "s" } as Record<string, string>;
|
||||
const r = new ProviderRegistry({ env });
|
||||
r.register({ ...cfg("gh"), displayName: "Plugin GH" }, "plugin");
|
||||
r.register(cfg("gh"), "yaml");
|
||||
expect(r.get("gh")?.config.displayName).toBe("gh");
|
||||
});
|
||||
|
||||
it("list() returns all registered providers", () => {
|
||||
const env = {
|
||||
A_ID: "i",
|
||||
A_SECRET: "s",
|
||||
B_ID: "i",
|
||||
B_SECRET: "s",
|
||||
} as Record<string, string>;
|
||||
const r = new ProviderRegistry({ env });
|
||||
r.register(cfg("a"), "yaml");
|
||||
r.register(cfg("b"), "yaml");
|
||||
expect(r.list().map((p) => p.config.id).sort()).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import type { Server } from "node:http";
|
||||
import type { RegisteredProvider } from "../types.js";
|
||||
import { revokeUpstreamToken } from "../revoke.js";
|
||||
|
||||
interface RevokeFixture {
|
||||
close: () => void;
|
||||
base: string;
|
||||
readonly lastUrl: string;
|
||||
readonly lastBody: string;
|
||||
readonly lastAuth: string;
|
||||
readonly hits: number;
|
||||
setStatus: (status: number) => void;
|
||||
}
|
||||
|
||||
function makeProvider(
|
||||
revokeEndpoint: string | undefined,
|
||||
clientId = "the-client",
|
||||
clientSecret = "the-secret",
|
||||
): RegisteredProvider {
|
||||
return {
|
||||
config: {
|
||||
id: "p",
|
||||
displayName: "P",
|
||||
clientCredentials: { clientIdEnv: "X", clientSecretEnv: "Y" },
|
||||
endpoints: {
|
||||
authorize: "https://x/a",
|
||||
token: "https://x/t",
|
||||
accountInfo: "https://x/me",
|
||||
...(revokeEndpoint ? { revoke: revokeEndpoint } : {}),
|
||||
},
|
||||
scopes: { default: [], offered: [] },
|
||||
pkce: "required",
|
||||
authMethod: "post",
|
||||
responseFormat: "json",
|
||||
accountIdField: "id",
|
||||
accountLabelField: "login",
|
||||
refresh: { supported: false },
|
||||
},
|
||||
clientId,
|
||||
clientSecret,
|
||||
shape: {},
|
||||
source: "yaml",
|
||||
} as unknown as RegisteredProvider;
|
||||
}
|
||||
|
||||
describe("revokeUpstreamToken", () => {
|
||||
let fixture: RevokeFixture;
|
||||
let raw: Server;
|
||||
|
||||
beforeEach(async () => {
|
||||
const http = await import("node:http");
|
||||
let lastUrl = "";
|
||||
let lastBody = "";
|
||||
let lastAuth = "";
|
||||
let hits = 0;
|
||||
let status = 200;
|
||||
const s = http.createServer((req, res) => {
|
||||
hits++;
|
||||
lastUrl = String(req.url ?? "");
|
||||
lastAuth = String(req.headers.authorization ?? "");
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (c) => chunks.push(c));
|
||||
req.on("end", () => {
|
||||
lastBody = Buffer.concat(chunks).toString("utf8");
|
||||
res.statusCode = status;
|
||||
res.end(status === 200 ? "" : "nope");
|
||||
});
|
||||
});
|
||||
raw = s;
|
||||
await new Promise<void>((r) => s.listen(0, r));
|
||||
const port = (s.address() as { port: number }).port;
|
||||
fixture = {
|
||||
close: () => s.close(),
|
||||
base: `http://127.0.0.1:${port}`,
|
||||
get lastUrl() { return lastUrl; },
|
||||
get lastBody() { return lastBody; },
|
||||
get lastAuth() { return lastAuth; },
|
||||
get hits() { return hits; },
|
||||
setStatus: (s) => { status = s; },
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => fixture.close());
|
||||
|
||||
it("is a no-op when endpoints.revoke is absent", async () => {
|
||||
const provider = makeProvider(undefined);
|
||||
await revokeUpstreamToken({
|
||||
provider,
|
||||
accessToken: "AT",
|
||||
refreshToken: "RT",
|
||||
});
|
||||
expect(fixture.hits).toBe(0);
|
||||
});
|
||||
|
||||
it("substitutes {client_id} placeholder in the URL", async () => {
|
||||
const provider = makeProvider(
|
||||
`${fixture.base}/applications/{client_id}/grant`,
|
||||
"abc-client",
|
||||
);
|
||||
await revokeUpstreamToken({ provider, accessToken: "AT" });
|
||||
expect(fixture.lastUrl).toBe("/applications/abc-client/grant");
|
||||
});
|
||||
|
||||
it("sends token_type_hint=refresh_token when refresh token is provided", async () => {
|
||||
const provider = makeProvider(`${fixture.base}/revoke`);
|
||||
await revokeUpstreamToken({
|
||||
provider,
|
||||
accessToken: "AT",
|
||||
refreshToken: "RT",
|
||||
});
|
||||
expect(fixture.lastBody).toContain("token=RT");
|
||||
expect(fixture.lastBody).toContain("token_type_hint=refresh_token");
|
||||
expect(fixture.lastBody).not.toContain("token=AT");
|
||||
const expected = `Basic ${Buffer.from("the-client:the-secret").toString("base64")}`;
|
||||
expect(fixture.lastAuth).toBe(expected);
|
||||
});
|
||||
|
||||
it("sends token_type_hint=access_token when only access token is provided", async () => {
|
||||
const provider = makeProvider(`${fixture.base}/revoke`);
|
||||
await revokeUpstreamToken({ provider, accessToken: "AT" });
|
||||
expect(fixture.lastBody).toContain("token=AT");
|
||||
expect(fixture.lastBody).toContain("token_type_hint=access_token");
|
||||
});
|
||||
|
||||
it("throws on a non-2xx upstream response", async () => {
|
||||
const provider = makeProvider(`${fixture.base}/revoke`);
|
||||
fixture.setStatus(401);
|
||||
await expect(
|
||||
revokeUpstreamToken({ provider, accessToken: "AT" }),
|
||||
).rejects.toThrow(/upstream revoke failed: 401/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { runStateSweep } from "../state-sweeper.js";
|
||||
|
||||
describe("runStateSweep", () => {
|
||||
it("issues a delete with an expiresAt < now() - 1 day predicate", async () => {
|
||||
const whereMock = vi.fn().mockResolvedValue(undefined);
|
||||
const deleteFn = vi.fn(() => ({ where: whereMock }));
|
||||
const db = { delete: deleteFn };
|
||||
await runStateSweep({ db } as never);
|
||||
expect(deleteFn).toHaveBeenCalledTimes(1);
|
||||
expect(whereMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("swallows and logs an error thrown by db.delete", async () => {
|
||||
const db = {
|
||||
delete: () => {
|
||||
throw new Error("postgres unavailable");
|
||||
},
|
||||
};
|
||||
// The sweep must not propagate the error — the caller (tick loop) relies
|
||||
// on this to keep ticking.
|
||||
await expect(runStateSweep({ db } as never)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("swallows and logs an error thrown by the where chain", async () => {
|
||||
const db = {
|
||||
delete: () => ({
|
||||
where: () => Promise.reject(new Error("pg timeout")),
|
||||
}),
|
||||
};
|
||||
await expect(runStateSweep({ db } as never)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadProviderConfigsFromDirectory } from "../yaml-loader.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const FIXTURE_DIR = path.join(__dirname, "fixtures", "oauth-providers");
|
||||
|
||||
describe("loadProviderConfigsFromDirectory", () => {
|
||||
it("loads and validates yaml files", async () => {
|
||||
const configs = await loadProviderConfigsFromDirectory(FIXTURE_DIR);
|
||||
expect(configs.map((c) => c.id)).toContain("mock");
|
||||
});
|
||||
|
||||
it("returns empty array for missing dir", async () => {
|
||||
const configs = await loadProviderConfigsFromDirectory(
|
||||
"/nonexistent/path/x",
|
||||
);
|
||||
expect(configs).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { ProviderRegistry } from "../registry.js";
|
||||
import { KNOWN_SHAPES } from "../shapes/index.js";
|
||||
import type { OAuthProviderConfig } from "../provider-config.js";
|
||||
import type { ProviderShape } from "../types.js";
|
||||
import { logger } from "../../middleware/logger.js";
|
||||
|
||||
/**
|
||||
* Mirrors the resolution helper in `app.ts`: given a YAML config with an
|
||||
* optional `shape` field, look the shape up in a static registry and pass it
|
||||
* to `ProviderRegistry.register`. Skips registration with an error log when
|
||||
* the shape name is not in the registry.
|
||||
*/
|
||||
function registerWithShape(
|
||||
registry: ProviderRegistry,
|
||||
cfg: OAuthProviderConfig,
|
||||
shapes: Readonly<Record<string, ProviderShape>>,
|
||||
): void {
|
||||
if (cfg.shape !== undefined) {
|
||||
const shape = shapes[cfg.shape];
|
||||
if (!shape) {
|
||||
logger.error(
|
||||
{ provider: cfg.id, shape: cfg.shape },
|
||||
"OAuth provider references unknown shape module; skipping",
|
||||
);
|
||||
return;
|
||||
}
|
||||
registry.register(cfg, "yaml", shape);
|
||||
return;
|
||||
}
|
||||
registry.register(cfg, "yaml");
|
||||
}
|
||||
|
||||
const baseCfg = (id: string): OAuthProviderConfig => ({
|
||||
id,
|
||||
displayName: id,
|
||||
clientCredentials: {
|
||||
clientIdEnv: `${id.toUpperCase().replace(/-/g, "_")}_ID`,
|
||||
clientSecretEnv: `${id.toUpperCase().replace(/-/g, "_")}_SECRET`,
|
||||
},
|
||||
endpoints: {
|
||||
authorize: "https://x.example/a",
|
||||
token: "https://x.example/t",
|
||||
accountInfo: "https://x.example/u",
|
||||
},
|
||||
scopes: { default: [], offered: [] },
|
||||
pkce: "required",
|
||||
authMethod: "post",
|
||||
responseFormat: "json",
|
||||
accountIdField: "id",
|
||||
accountLabelField: "name",
|
||||
refresh: { supported: false },
|
||||
});
|
||||
|
||||
describe("yaml shape wiring", () => {
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
errorSpy = vi.spyOn(logger, "error").mockImplementation(() => undefined);
|
||||
});
|
||||
afterEach(() => {
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("KNOWN_SHAPES exposes slack and microsoft", () => {
|
||||
expect(KNOWN_SHAPES.slack).toBeDefined();
|
||||
expect(KNOWN_SHAPES.microsoft).toBeDefined();
|
||||
});
|
||||
|
||||
it("registers a provider with the slack shape when shape: slack is set", () => {
|
||||
const env = { SLACK_ID: "id", SLACK_SECRET: "s" } as Record<string, string>;
|
||||
const registry = new ProviderRegistry({ env });
|
||||
const cfg: OAuthProviderConfig = {
|
||||
...baseCfg("slack"),
|
||||
shape: "slack",
|
||||
};
|
||||
|
||||
registerWithShape(registry, cfg, KNOWN_SHAPES);
|
||||
|
||||
const reg = registry.get("slack");
|
||||
expect(reg).toBeDefined();
|
||||
// The slack shape parses the nested `authed_user` form — a probe the
|
||||
// default shape (which only reads top-level `access_token`) cannot satisfy.
|
||||
const parsed = reg!.shape.parseTokenResponse!({
|
||||
authed_user: { access_token: "xoxp-USER", scope: "chat:write" },
|
||||
});
|
||||
expect(parsed.accessToken).toBe("xoxp-USER");
|
||||
expect(parsed.scope).toEqual(["chat:write"]);
|
||||
});
|
||||
|
||||
it("skips a provider whose shape is unknown and logs an error", () => {
|
||||
const env = { GHOST_ID: "id", GHOST_SECRET: "s" } as Record<string, string>;
|
||||
const registry = new ProviderRegistry({ env });
|
||||
const cfg: OAuthProviderConfig = {
|
||||
...baseCfg("ghost"),
|
||||
shape: "ghost",
|
||||
};
|
||||
|
||||
registerWithShape(registry, cfg, KNOWN_SHAPES);
|
||||
|
||||
expect(registry.get("ghost")).toBeUndefined();
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
const args = errorSpy.mock.calls[0]!;
|
||||
expect(args[0]).toMatchObject({ provider: "ghost", shape: "ghost" });
|
||||
});
|
||||
|
||||
it("registers without a shape override when shape is absent (default shape applies)", () => {
|
||||
const env = { PLAIN_ID: "id", PLAIN_SECRET: "s" } as Record<string, string>;
|
||||
const registry = new ProviderRegistry({ env });
|
||||
const cfg = baseCfg("plain");
|
||||
|
||||
registerWithShape(registry, cfg, KNOWN_SHAPES);
|
||||
|
||||
const reg = registry.get("plain");
|
||||
expect(reg).toBeDefined();
|
||||
// Default shape consumes RFC-6749 flat token responses.
|
||||
const parsed = reg!.shape.parseTokenResponse!({
|
||||
access_token: "T",
|
||||
expires_in: 60,
|
||||
scope: "a b",
|
||||
});
|
||||
expect(parsed.accessToken).toBe("T");
|
||||
expect(parsed.scope).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
const CAP_SECONDS = 3600;
|
||||
|
||||
export function backoffSeconds(attempts: number): number {
|
||||
if (attempts < 0 || !Number.isFinite(attempts)) return 30;
|
||||
const exp = Math.min(attempts, 30);
|
||||
return Math.min(2 ** exp * 30, CAP_SECONDS);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { getByPath } from "./dot-path.js";
|
||||
import type { ProviderShape } from "./types.js";
|
||||
|
||||
const ONE_YEAR_SECONDS = 60 * 60 * 24 * 365;
|
||||
|
||||
export function buildDefaultShape(cfg: {
|
||||
accountIdField: string;
|
||||
accountLabelField: string;
|
||||
}): ProviderShape {
|
||||
return {
|
||||
parseTokenResponse(raw) {
|
||||
if (typeof raw !== "object" || raw === null) {
|
||||
throw new Error("response_shape_violation: not an object");
|
||||
}
|
||||
const r = raw as Record<string, unknown>;
|
||||
if (typeof r.access_token !== "string" || r.access_token.length === 0) {
|
||||
throw new Error("response_shape_violation: missing access_token");
|
||||
}
|
||||
let expiresInSeconds: number | undefined;
|
||||
if (r.expires_in !== undefined) {
|
||||
const n = Number(r.expires_in);
|
||||
if (!Number.isFinite(n) || n < 0 || n > ONE_YEAR_SECONDS) {
|
||||
throw new Error("response_shape_violation: invalid expires_in");
|
||||
}
|
||||
expiresInSeconds = n;
|
||||
}
|
||||
let scope: string[] | undefined;
|
||||
if (typeof r.scope === "string") {
|
||||
if (/[^\x20-\x7E]/.test(r.scope)) {
|
||||
throw new Error(
|
||||
"response_shape_violation: scope contains non-printable characters",
|
||||
);
|
||||
}
|
||||
scope = r.scope.split(/[\s,]+/).filter(Boolean);
|
||||
}
|
||||
return {
|
||||
accessToken: r.access_token,
|
||||
refreshToken: typeof r.refresh_token === "string" ? r.refresh_token : undefined,
|
||||
expiresInSeconds,
|
||||
scope,
|
||||
};
|
||||
},
|
||||
parseAccountInfo(raw) {
|
||||
const idVal = getByPath(raw, cfg.accountIdField);
|
||||
const labelVal = getByPath(raw, cfg.accountLabelField);
|
||||
if (idVal === null || idVal === undefined) {
|
||||
throw new Error("response_shape_violation: missing account id");
|
||||
}
|
||||
const accountId =
|
||||
typeof idVal === "string" || typeof idVal === "number"
|
||||
? String(idVal)
|
||||
: (() => {
|
||||
throw new Error("response_shape_violation: non-scalar account id");
|
||||
})();
|
||||
const accountLabel =
|
||||
typeof labelVal === "string" || typeof labelVal === "number"
|
||||
? String(labelVal)
|
||||
: undefined;
|
||||
return { accountId, accountLabel };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
const BLOCKED = new Set(["__proto__", "prototype", "constructor"]);
|
||||
|
||||
export function getByPath(obj: unknown, path: string): unknown {
|
||||
if (obj === null || obj === undefined) return null;
|
||||
const parts = path.split(".");
|
||||
let current: unknown = obj;
|
||||
for (const part of parts) {
|
||||
if (BLOCKED.has(part)) return null;
|
||||
if (current === null || current === undefined) return null;
|
||||
if (typeof current !== "object") return null;
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
return current ?? null;
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { oauthLogger } from "./logger.js";
|
||||
|
||||
const TIMEOUT_MS = 30_000;
|
||||
const RETRY_DELAYS_MS = [200, 600]; // 2 retries on 5xx
|
||||
|
||||
export interface ExchangeTokenInput {
|
||||
url: string;
|
||||
params: Record<string, string>;
|
||||
authMethod: "post" | "basic";
|
||||
responseFormat: "json" | "form";
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
export class OAuthRequestError extends Error {
|
||||
status: number;
|
||||
body: string;
|
||||
parsed: Record<string, unknown>;
|
||||
providerErrorCode?: string;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
init: { status: number; body: string; parsed?: Record<string, unknown>; providerErrorCode?: string },
|
||||
) {
|
||||
super(message);
|
||||
this.name = "OAuthRequestError";
|
||||
this.status = init.status;
|
||||
this.body = init.body;
|
||||
this.parsed = init.parsed ?? {};
|
||||
this.providerErrorCode = init.providerErrorCode;
|
||||
}
|
||||
}
|
||||
|
||||
const SENSITIVE_KEYS = ["access_token", "refresh_token", "id_token", "code", "code_verifier", "client_secret"];
|
||||
|
||||
function sanitizeErrorBody(text: string): string {
|
||||
if (!text) return "";
|
||||
const jsonRe = new RegExp(`("(?:${SENSITIVE_KEYS.join("|")})"\\s*:\\s*)"[^"]*"`, "g");
|
||||
const formRe = new RegExp(`(${SENSITIVE_KEYS.join("|")})=([^&\\s]+)`, "g");
|
||||
const sanitized = text.replace(jsonRe, '$1"[REDACTED]"').replace(formRe, "$1=[REDACTED]");
|
||||
return sanitized.slice(0, 200);
|
||||
}
|
||||
|
||||
export async function exchangeToken(input: ExchangeTokenInput): Promise<Record<string, unknown>> {
|
||||
const body = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(input.params)) body.set(k, v);
|
||||
if (input.authMethod === "post") {
|
||||
body.set("client_id", input.clientId);
|
||||
body.set("client_secret", input.clientSecret);
|
||||
}
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
"accept": input.responseFormat === "json" ? "application/json" : "application/x-www-form-urlencoded",
|
||||
};
|
||||
if (input.authMethod === "basic") {
|
||||
const credentials = Buffer.from(`${input.clientId}:${input.clientSecret}`).toString("base64");
|
||||
headers.authorization = `Basic ${credentials}`;
|
||||
}
|
||||
|
||||
let attempt = 0;
|
||||
while (true) {
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(input.url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: body.toString(),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
clearTimeout(t);
|
||||
if (attempt < RETRY_DELAYS_MS.length) {
|
||||
await new Promise((r) => setTimeout(r, RETRY_DELAYS_MS[attempt]));
|
||||
attempt++;
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
clearTimeout(t);
|
||||
|
||||
const text = await res.text();
|
||||
let parsed: Record<string, unknown>;
|
||||
let parseFailed = false;
|
||||
try {
|
||||
parsed = input.responseFormat === "json" ? JSON.parse(text) : Object.fromEntries(new URLSearchParams(text));
|
||||
} catch {
|
||||
parsed = {};
|
||||
parseFailed = true;
|
||||
}
|
||||
|
||||
if (res.ok) {
|
||||
if (parseFailed) {
|
||||
throw new OAuthRequestError("token endpoint returned unparseable body", {
|
||||
status: res.status,
|
||||
body: sanitizeErrorBody(text),
|
||||
parsed: {},
|
||||
});
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
if (res.status >= 500 && attempt < RETRY_DELAYS_MS.length) {
|
||||
oauthLogger.warn({ status: res.status, attempt }, "token endpoint 5xx; retrying");
|
||||
await new Promise((r) => setTimeout(r, RETRY_DELAYS_MS[attempt]));
|
||||
attempt++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const safeBody = sanitizeErrorBody(text);
|
||||
const providerErrorCode = typeof parsed.error === "string" ? parsed.error : undefined;
|
||||
const messageSuffix = providerErrorCode ?? safeBody;
|
||||
throw new OAuthRequestError(
|
||||
`token exchange failed: ${res.status}${messageSuffix ? ` ${messageSuffix}` : ""}`,
|
||||
{
|
||||
status: res.status,
|
||||
body: safeBody,
|
||||
parsed,
|
||||
providerErrorCode,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAccountInfo(url: string, accessToken: string): Promise<unknown> {
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
authorization: `Bearer ${accessToken}`,
|
||||
accept: "application/json",
|
||||
"user-agent": "paperclip-oauth/1.0",
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
const safeBody = sanitizeErrorBody(text);
|
||||
throw new OAuthRequestError(
|
||||
`account info fetch failed: ${res.status}${safeBody ? ` ${safeBody}` : ""}`,
|
||||
{
|
||||
status: res.status,
|
||||
body: safeBody,
|
||||
},
|
||||
);
|
||||
}
|
||||
return await res.json();
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
export const OAUTH_REDACT_PATHS = [
|
||||
"access_token", "refresh_token", "id_token",
|
||||
"code", "code_verifier", "client_secret",
|
||||
"*.access_token", "*.refresh_token", "*.id_token",
|
||||
"*.code", "*.code_verifier", "*.client_secret",
|
||||
"data.access_token", "data.refresh_token", "data.id_token",
|
||||
];
|
||||
|
||||
export const oauthLogger = logger.child(
|
||||
{ component: "oauth" },
|
||||
{ redact: { paths: OAUTH_REDACT_PATHS, censor: "[REDACTED]" } },
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
|
||||
function base64url(buf: Buffer): string {
|
||||
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
export function generateCodeVerifier(): string {
|
||||
return base64url(randomBytes(64));
|
||||
}
|
||||
|
||||
export function deriveCodeChallenge(verifier: string): string {
|
||||
return base64url(createHash("sha256").update(verifier).digest());
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
import { OAuthProviderConfigSchema } from "./provider-config.js";
|
||||
import type { ProviderRegistry } from "./registry.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
/**
|
||||
* Validate and register OAuth provider contributions emitted by plugin
|
||||
* manifests. Only manifests whose `kind` is `oauth_provider` or `composite`
|
||||
* are inspected; everything else is ignored.
|
||||
*
|
||||
* Each contribution's `config` is parsed through the same Zod schema the
|
||||
* YAML loader uses, so plugins cannot bypass validation. A failed parse
|
||||
* throws synchronously — this is treated as a configuration error and is
|
||||
* surfaced loudly so plugins ship correct configs before installation.
|
||||
*/
|
||||
export function registerPluginContributions(
|
||||
registry: ProviderRegistry,
|
||||
manifests: PaperclipPluginManifestV1[],
|
||||
): void {
|
||||
for (const manifest of manifests) {
|
||||
if (manifest.kind !== "oauth_provider" && manifest.kind !== "composite") {
|
||||
if (manifest.oauthProviders && manifest.oauthProviders.length > 0) {
|
||||
logger.warn(
|
||||
{ plugin: manifest.id, kind: manifest.kind },
|
||||
"manifest has oauthProviders but kind is not 'oauth_provider' or 'composite'; contributions skipped",
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const contributions = manifest.oauthProviders ?? [];
|
||||
for (const c of contributions) {
|
||||
const result = OAuthProviderConfigSchema.safeParse(c.config);
|
||||
if (!result.success) {
|
||||
logger.error(
|
||||
{
|
||||
plugin: manifest.id,
|
||||
providerId: c.config.id,
|
||||
issues: result.error.issues,
|
||||
},
|
||||
"plugin OAuth contribution failed validation",
|
||||
);
|
||||
throw new Error(
|
||||
`plugin ${manifest.id} contributed invalid OAuth provider ${c.config.id}: ${result.error.message}`,
|
||||
);
|
||||
}
|
||||
registry.register(result.data, "plugin", c.shape);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const httpsUrl = z.string().url().refine((u) => u.startsWith("https://"), {
|
||||
message: "endpoint must use https://",
|
||||
});
|
||||
|
||||
export const OAuthProviderConfigSchema = z.object({
|
||||
id: z.string().regex(/^[a-z0-9-]+$/),
|
||||
displayName: z.string().min(1),
|
||||
iconUrl: z.string().url().optional(),
|
||||
docUrl: z.string().url().optional(),
|
||||
|
||||
clientCredentials: z.object({
|
||||
clientIdEnv: z.string().min(1),
|
||||
clientSecretEnv: z.string().min(1),
|
||||
}),
|
||||
|
||||
endpoints: z.object({
|
||||
authorize: httpsUrl,
|
||||
token: httpsUrl,
|
||||
revoke: httpsUrl.optional(),
|
||||
accountInfo: httpsUrl,
|
||||
}),
|
||||
|
||||
scopes: z
|
||||
.object({
|
||||
default: z.array(z.string()),
|
||||
offered: z.array(z.string()),
|
||||
})
|
||||
.refine((s) => s.default.every((d) => s.offered.includes(d)), {
|
||||
message: "scopes.default must be a subset of scopes.offered",
|
||||
}),
|
||||
|
||||
pkce: z.enum(["required", "optional", "unsupported"]),
|
||||
authMethod: z.enum(["post", "basic"]),
|
||||
responseFormat: z.enum(["json", "form"]),
|
||||
accountIdField: z.string().min(1),
|
||||
accountLabelField: z.string().min(1),
|
||||
|
||||
refresh: z.discriminatedUnion("supported", [
|
||||
z.object({ supported: z.literal(false) }),
|
||||
z.object({
|
||||
supported: z.literal(true),
|
||||
rotatesRefreshToken: z.boolean(),
|
||||
expirySeconds: z.number().int().positive().optional(),
|
||||
}),
|
||||
]),
|
||||
|
||||
shape: z.string().optional(),
|
||||
});
|
||||
|
||||
export type OAuthProviderConfig = z.infer<typeof OAuthProviderConfigSchema>;
|
||||
@@ -0,0 +1,27 @@
|
||||
export interface SlidingWindowLimiterOptions {
|
||||
limit: number;
|
||||
windowMs: number;
|
||||
}
|
||||
|
||||
export interface SlidingWindowLimiter {
|
||||
check(key: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export function createSlidingWindowLimiter(opts: SlidingWindowLimiterOptions): SlidingWindowLimiter {
|
||||
const buckets = new Map<string, number[]>();
|
||||
return {
|
||||
async check(key) {
|
||||
const now = Date.now();
|
||||
const cutoff = now - opts.windowMs;
|
||||
const bucket = buckets.get(key) ?? [];
|
||||
while (bucket.length > 0 && bucket[0] <= cutoff) bucket.shift();
|
||||
if (bucket.length >= opts.limit) {
|
||||
buckets.set(key, bucket);
|
||||
return false;
|
||||
}
|
||||
bucket.push(now);
|
||||
buckets.set(key, bucket);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
const ALLOWED_PREFIXES = ["/settings/", "/agents/", "/runs/"];
|
||||
const SAFE_DEFAULT = "/settings/connections";
|
||||
|
||||
export function validateReturnUrl(url: string | undefined, publicUrl: string): string {
|
||||
if (!url || typeof url !== "string") return SAFE_DEFAULT;
|
||||
if (/^\s*[/\\]{2,}/.test(url)) return SAFE_DEFAULT;
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url, publicUrl);
|
||||
} catch {
|
||||
return SAFE_DEFAULT;
|
||||
}
|
||||
|
||||
const expected = new URL(publicUrl);
|
||||
if (parsed.origin !== expected.origin) return SAFE_DEFAULT;
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return SAFE_DEFAULT;
|
||||
|
||||
const allowed = ALLOWED_PREFIXES.some((p) => parsed.pathname.startsWith(p));
|
||||
if (!allowed) return SAFE_DEFAULT;
|
||||
|
||||
return parsed.pathname + parsed.search + parsed.hash;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { backoffSeconds } from "./backoff.js";
|
||||
import { refreshConnection, type RefreshSecretService } from "./refresh.js";
|
||||
import { oauthLogger } from "./logger.js";
|
||||
import type { ProviderRegistry } from "./registry.js";
|
||||
|
||||
// Postgres advisory lock key. Picked to be a stable, distinct constant that
|
||||
// fits in signed int64 (`pg_try_advisory_xact_lock(bigint)`). Any process that
|
||||
// acquires this key inside its transaction acts as the worker leader for the
|
||||
// tick. We use the *transaction-scoped* variant so Postgres releases the lock
|
||||
// automatically at COMMIT/ROLLBACK — session-scoped advisory locks are tied
|
||||
// to the connection that took them, and postgres-js (the Drizzle driver in
|
||||
// this repo, see packages/db/src/client.ts) maintains a multi-connection
|
||||
// pool, so a session-scoped lock + unlock can land on different pool
|
||||
// connections and leak the held lock across ticks.
|
||||
const ADVISORY_LOCK_KEY = 0x074a17b4_c0bbac1en;
|
||||
const BATCH_LIMIT = 100;
|
||||
const TICK_INTERVAL_MS = 60_000;
|
||||
|
||||
export interface RefreshWorkerDeps {
|
||||
// db: Drizzle handle. Loosely typed so this module does not pull the full
|
||||
// @paperclipai/db Db type — same convention as refresh.ts and the routes.
|
||||
db: any;
|
||||
registry: ProviderRegistry;
|
||||
// Same shape as RefreshDeps.secretService — typed explicitly so a missing
|
||||
// method is a compile error instead of being silently swallowed by `any`.
|
||||
secretService: RefreshSecretService;
|
||||
// Optional injection for tests; defaults to the real refreshConnection.
|
||||
refreshFn?: typeof refreshConnection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single refresh tick.
|
||||
*
|
||||
* The whole tick runs inside one Postgres transaction so the
|
||||
* `pg_try_advisory_xact_lock` we acquire stays bound to the same backend
|
||||
* connection until COMMIT/ROLLBACK auto-releases it. Do NOT replace this
|
||||
* with `pg_try_advisory_lock`/`pg_advisory_unlock` — under the postgres-js
|
||||
* pool those calls can land on different connections and leak the lock
|
||||
* across subsequent ticks (the original bug this fix targets).
|
||||
*
|
||||
* `refreshConnection` itself opens a transaction; passing `tx` from the
|
||||
* outer transaction makes Drizzle nest it as a savepoint, so a per-row
|
||||
* failure rolls back only that row's work, not the whole tick.
|
||||
*/
|
||||
export async function runRefreshTick(deps: RefreshWorkerDeps): Promise<void> {
|
||||
await deps.db.transaction(async (tx: any) => {
|
||||
const lockResult = await tx.execute(
|
||||
sql`SELECT pg_try_advisory_xact_lock(${ADVISORY_LOCK_KEY}::bigint) as result`,
|
||||
);
|
||||
// postgres-js returns an iterable RowList directly; node-postgres wraps in {rows}.
|
||||
// Read both shapes so the worker is portable across drizzle drivers.
|
||||
const lockRows = Array.isArray(lockResult)
|
||||
? lockResult
|
||||
: ((lockResult as { rows?: unknown[] }).rows ?? []);
|
||||
const acquired = Boolean(
|
||||
(lockRows[0] as { result?: unknown } | undefined)?.result,
|
||||
);
|
||||
if (!acquired) return;
|
||||
|
||||
const candidates = await tx.query.oauthConnections.findMany({
|
||||
where: (
|
||||
t: any,
|
||||
{ and: A, eq: E, isNotNull: NN, lt: L, sql: S }: any,
|
||||
) =>
|
||||
A(
|
||||
E(t.status, "active"),
|
||||
NN(t.refreshTokenSecretId),
|
||||
NN(t.accessTokenExpiresAt),
|
||||
L(t.accessTokenExpiresAt, S`now() + interval '5 minutes'`),
|
||||
),
|
||||
orderBy: (t: any, { asc: A }: any) => [A(t.accessTokenExpiresAt)],
|
||||
limit: BATCH_LIMIT,
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
const eligible = candidates.filter((row: any) => {
|
||||
if (!row.lastErrorAt) return true;
|
||||
const minRetryAt =
|
||||
row.lastErrorAt.getTime() +
|
||||
backoffSeconds(row.refreshAttemptCount) * 1000;
|
||||
return minRetryAt <= now;
|
||||
});
|
||||
|
||||
const refreshFn = deps.refreshFn ?? refreshConnection;
|
||||
for (const row of eligible) {
|
||||
try {
|
||||
await refreshFn({
|
||||
connectionId: row.id,
|
||||
db: tx,
|
||||
registry: deps.registry,
|
||||
secretService: deps.secretService,
|
||||
});
|
||||
} catch (err) {
|
||||
oauthLogger.error(
|
||||
{
|
||||
connectionId: row.id,
|
||||
err: { message: (err as Error).message },
|
||||
},
|
||||
"worker refresh threw",
|
||||
);
|
||||
}
|
||||
}
|
||||
// No explicit unlock — pg_try_advisory_xact_lock releases at COMMIT/ROLLBACK.
|
||||
});
|
||||
}
|
||||
|
||||
export function startRefreshWorker(
|
||||
deps: RefreshWorkerDeps,
|
||||
): { stop: () => void } {
|
||||
let stopped = false;
|
||||
let timeout: NodeJS.Timeout;
|
||||
const tick = async () => {
|
||||
if (stopped) return;
|
||||
try {
|
||||
await runRefreshTick(deps);
|
||||
} catch (err) {
|
||||
oauthLogger.error(
|
||||
{ err: { message: (err as Error).message } },
|
||||
"refresh worker tick failed",
|
||||
);
|
||||
}
|
||||
if (!stopped) timeout = setTimeout(tick, TICK_INTERVAL_MS);
|
||||
};
|
||||
timeout = setTimeout(tick, TICK_INTERVAL_MS);
|
||||
return {
|
||||
stop: () => {
|
||||
stopped = true;
|
||||
clearTimeout(timeout);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { oauthConnections } from "@paperclipai/db/schema/oauth";
|
||||
import { exchangeToken, OAuthRequestError } from "./http.js";
|
||||
import { backoffSeconds } from "./backoff.js";
|
||||
import { oauthLogger } from "./logger.js";
|
||||
import type { ProviderRegistry } from "./registry.js";
|
||||
|
||||
export interface RefreshSecretService {
|
||||
resolveSecretValue: (
|
||||
companyId: string,
|
||||
secretId: string,
|
||||
version: number | "latest",
|
||||
) => Promise<string>;
|
||||
upsertSecretByName: (
|
||||
companyId: string,
|
||||
input: { name: string; value: string },
|
||||
actor?: { userId?: string | null; agentId?: string | null },
|
||||
) => Promise<{ id: string }>;
|
||||
remove?: (secretId: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface RefreshDeps {
|
||||
connectionId: string;
|
||||
// db: Drizzle handle. Kept loose so callers don't pull the full Db type into
|
||||
// this module (route + worker share this entrypoint).
|
||||
db: any;
|
||||
registry: ProviderRegistry;
|
||||
secretService: RefreshSecretService;
|
||||
exchangeFn?: typeof exchangeToken; // injectable for tests
|
||||
}
|
||||
|
||||
export type RefreshResult =
|
||||
| { outcome: "success"; accessToken: string }
|
||||
| { outcome: "revoked" }
|
||||
| { outcome: "transient"; error: string }
|
||||
| { outcome: "skipped"; reason: string };
|
||||
|
||||
// Treat HTTP 400 + provider error code "invalid_grant" / "invalid_token" as a
|
||||
// permanent failure (RFC 6749 §5.2 / §6). Anything else (network, 5xx, parse
|
||||
// failures) is transient and falls through to backoff.
|
||||
function isInvalidGrant(err: unknown): boolean {
|
||||
const e = err as { status?: unknown; providerErrorCode?: unknown } | null;
|
||||
if (!e || typeof e !== "object") return false;
|
||||
if (e.status !== 400) return false;
|
||||
return (
|
||||
e.providerErrorCode === "invalid_grant" ||
|
||||
e.providerErrorCode === "invalid_token"
|
||||
);
|
||||
}
|
||||
|
||||
export async function refreshConnection(deps: RefreshDeps): Promise<RefreshResult> {
|
||||
const exchange = deps.exchangeFn ?? exchangeToken;
|
||||
return await deps.db.transaction(async (tx: any) => {
|
||||
// Row-lock the connection so concurrent refreshes (worker tick + lazy
|
||||
// resolves racing on the same row) serialize at the DB level. Without
|
||||
// this, two refresh paths can both proceed and race on the
|
||||
// (secret_id, version) unique constraint when persisting the rotated
|
||||
// access-token version. We issue an explicit SELECT FOR UPDATE before
|
||||
// the relational query so existing tests that mock `.query.oauthConnections`
|
||||
// continue to work — the lock acquisition is best-effort and skipped
|
||||
// if the test mock doesn't implement `tx.execute`.
|
||||
if (typeof tx.execute === "function") {
|
||||
try {
|
||||
await tx.execute(
|
||||
sql`SELECT id FROM oauth_connections WHERE id = ${deps.connectionId} FOR UPDATE`,
|
||||
);
|
||||
} catch {
|
||||
// Best-effort lock; if the table or row isn't reachable from this
|
||||
// tx (e.g., test mocks), the relational read below still proceeds.
|
||||
}
|
||||
}
|
||||
const row = await tx.query.oauthConnections.findFirst({
|
||||
where: eq(oauthConnections.id, deps.connectionId),
|
||||
});
|
||||
if (!row) return { outcome: "skipped", reason: "not_found" } as const;
|
||||
if (!row.refreshTokenSecretId) {
|
||||
return { outcome: "skipped", reason: "no_refresh_token" } as const;
|
||||
}
|
||||
|
||||
const provider = deps.registry.get(row.providerId);
|
||||
if (!provider) {
|
||||
await tx
|
||||
.update(oauthConnections)
|
||||
.set({
|
||||
status: "error",
|
||||
lastError: "provider_unavailable",
|
||||
lastErrorAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(oauthConnections.id, row.id));
|
||||
return { outcome: "skipped", reason: "provider_unavailable" } as const;
|
||||
}
|
||||
if (provider.config.refresh.supported !== true) {
|
||||
return { outcome: "skipped", reason: "refresh_not_supported" } as const;
|
||||
}
|
||||
|
||||
const refreshTokenPlain = await deps.secretService.resolveSecretValue(
|
||||
row.companyId,
|
||||
row.refreshTokenSecretId,
|
||||
"latest",
|
||||
);
|
||||
|
||||
let raw: Record<string, unknown>;
|
||||
try {
|
||||
raw = await exchange({
|
||||
url: provider.config.endpoints.token,
|
||||
params: {
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshTokenPlain,
|
||||
},
|
||||
authMethod: provider.config.authMethod,
|
||||
responseFormat: provider.config.responseFormat,
|
||||
clientId: provider.clientId,
|
||||
clientSecret: provider.clientSecret,
|
||||
});
|
||||
} catch (err) {
|
||||
if (isInvalidGrant(err)) {
|
||||
const code =
|
||||
(err as OAuthRequestError).providerErrorCode ?? "invalid_grant";
|
||||
await tx
|
||||
.update(oauthConnections)
|
||||
.set({
|
||||
status: "revoked",
|
||||
lastError: code,
|
||||
lastErrorAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(oauthConnections.id, row.id));
|
||||
oauthLogger.warn(
|
||||
{ providerId: row.providerId, connectionId: row.id },
|
||||
"refresh permanently failed; revoked",
|
||||
);
|
||||
return { outcome: "revoked" } as const;
|
||||
}
|
||||
const message = (err as Error).message ?? "unknown_error";
|
||||
await tx
|
||||
.update(oauthConnections)
|
||||
.set({
|
||||
lastError: message.slice(0, 500),
|
||||
lastErrorAt: new Date(),
|
||||
refreshAttemptCount: row.refreshAttemptCount + 1,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(oauthConnections.id, row.id));
|
||||
return { outcome: "transient", error: message } as const;
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
const fn = provider.shape.parseTokenResponse;
|
||||
if (!fn) throw new Error("provider missing parseTokenResponse");
|
||||
parsed = fn(raw);
|
||||
} catch (err) {
|
||||
await tx
|
||||
.update(oauthConnections)
|
||||
.set({
|
||||
lastError: "response_shape_violation",
|
||||
lastErrorAt: new Date(),
|
||||
refreshAttemptCount: row.refreshAttemptCount + 1,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(oauthConnections.id, row.id));
|
||||
return {
|
||||
outcome: "transient",
|
||||
error: (err as Error).message,
|
||||
} as const;
|
||||
}
|
||||
|
||||
// accountId is nullable on oauth_connections; fall back to connection.id
|
||||
// so the secret-name scheme is stable even before account-info is resolved.
|
||||
const stableKey = row.accountId ?? row.id;
|
||||
const accessName = `oauth:${row.providerId}:${stableKey}:access`;
|
||||
const refreshName = `oauth:${row.providerId}:${stableKey}:refresh`;
|
||||
|
||||
// We've successfully exchanged the refresh token for new tokens at this
|
||||
// point. If persistence fails (DB write, encryption, etc.), the new
|
||||
// tokens are lost and — for providers that rotate refresh tokens on
|
||||
// use — the old refresh token may have been invalidated server-side,
|
||||
// leaving the connection unrecoverable via the worker. Treat this as a
|
||||
// fatal, non-retryable failure: mark the connection `error` with a
|
||||
// distinguishable lastError so operators can spot persistence failures
|
||||
// separately from provider-side errors. We deliberately do NOT bump
|
||||
// refreshAttemptCount — that drives backoff for retryable failures,
|
||||
// and this one is not.
|
||||
let access: { id: string };
|
||||
try {
|
||||
access = await deps.secretService.upsertSecretByName(row.companyId, {
|
||||
name: accessName,
|
||||
value: parsed.accessToken,
|
||||
});
|
||||
} catch (err) {
|
||||
await tx
|
||||
.update(oauthConnections)
|
||||
.set({
|
||||
status: "error",
|
||||
lastError: "token_persistence_failed",
|
||||
lastErrorAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(oauthConnections.id, row.id));
|
||||
oauthLogger.error(
|
||||
{
|
||||
connectionId: row.id,
|
||||
providerId: row.providerId,
|
||||
err: { message: (err as Error).message },
|
||||
},
|
||||
"OAuth token persistence failed after successful exchange — connection requires manual recovery",
|
||||
);
|
||||
return {
|
||||
outcome: "transient",
|
||||
error: (err as Error).message,
|
||||
} as const;
|
||||
}
|
||||
|
||||
let refreshSecretId: string = row.refreshTokenSecretId;
|
||||
if (parsed.refreshToken) {
|
||||
if (provider.config.refresh.rotatesRefreshToken !== true) {
|
||||
oauthLogger.warn(
|
||||
{ providerId: row.providerId },
|
||||
"provider returned refresh_token but rotatesRefreshToken=false; storing defensively",
|
||||
);
|
||||
}
|
||||
try {
|
||||
const newRefresh = await deps.secretService.upsertSecretByName(
|
||||
row.companyId,
|
||||
{ name: refreshName, value: parsed.refreshToken },
|
||||
);
|
||||
refreshSecretId = newRefresh.id;
|
||||
} catch (err) {
|
||||
await tx
|
||||
.update(oauthConnections)
|
||||
.set({
|
||||
status: "error",
|
||||
lastError: "token_persistence_failed",
|
||||
lastErrorAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(oauthConnections.id, row.id));
|
||||
oauthLogger.error(
|
||||
{
|
||||
connectionId: row.id,
|
||||
providerId: row.providerId,
|
||||
err: { message: (err as Error).message },
|
||||
},
|
||||
"OAuth refresh-token persistence failed after successful exchange — connection requires manual recovery",
|
||||
);
|
||||
return {
|
||||
outcome: "transient",
|
||||
error: (err as Error).message,
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
const expiresAt = parsed.expiresInSeconds
|
||||
? new Date(Date.now() + parsed.expiresInSeconds * 1000)
|
||||
: null;
|
||||
|
||||
await tx
|
||||
.update(oauthConnections)
|
||||
.set({
|
||||
status: "active",
|
||||
accessTokenSecretId: access.id,
|
||||
refreshTokenSecretId: refreshSecretId,
|
||||
accessTokenExpiresAt: expiresAt,
|
||||
scopes: parsed.scope ?? row.scopes,
|
||||
lastRefreshedAt: new Date(),
|
||||
lastError: null,
|
||||
lastErrorAt: null,
|
||||
refreshAttemptCount: 0,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(oauthConnections.id, row.id));
|
||||
|
||||
return {
|
||||
outcome: "success",
|
||||
accessToken: parsed.accessToken,
|
||||
} as const;
|
||||
});
|
||||
}
|
||||
|
||||
// Re-export for tests / consumers wanting the same backoff curve
|
||||
export { backoffSeconds };
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { OAuthProviderConfig } from "./provider-config.js";
|
||||
import type { RegisteredProvider, ProviderShape } from "./types.js";
|
||||
import { buildDefaultShape } from "./default-shape.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
export class ProviderRegistry {
|
||||
private readonly providers = new Map<string, RegisteredProvider>();
|
||||
private readonly env: Record<string, string | undefined>;
|
||||
|
||||
constructor(opts: { env: Record<string, string | undefined> }) {
|
||||
this.env = opts.env;
|
||||
}
|
||||
|
||||
register(
|
||||
config: OAuthProviderConfig,
|
||||
source: "yaml" | "plugin",
|
||||
shapeOverride?: ProviderShape,
|
||||
): void {
|
||||
const clientId = this.env[config.clientCredentials.clientIdEnv];
|
||||
const clientSecret = this.env[config.clientCredentials.clientSecretEnv];
|
||||
if (!clientId || !clientSecret) {
|
||||
logger.warn(
|
||||
{ provider: config.id, source },
|
||||
"OAuth provider env vars unset; skipping registration",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = this.providers.get(config.id);
|
||||
if (existing && existing.source === "yaml" && source === "plugin") {
|
||||
logger.warn(
|
||||
{ provider: config.id },
|
||||
"plugin contribution shadowed by yaml — plugin skipped",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (existing && source === "yaml") {
|
||||
logger.warn(
|
||||
{ provider: config.id },
|
||||
"yaml provider replaces previously-registered entry",
|
||||
);
|
||||
}
|
||||
|
||||
const defaultShape = buildDefaultShape({
|
||||
accountIdField: config.accountIdField,
|
||||
accountLabelField: config.accountLabelField,
|
||||
});
|
||||
const shape: ProviderShape = {
|
||||
parseTokenResponse:
|
||||
shapeOverride?.parseTokenResponse ?? defaultShape.parseTokenResponse,
|
||||
parseAccountInfo:
|
||||
shapeOverride?.parseAccountInfo ?? defaultShape.parseAccountInfo,
|
||||
};
|
||||
|
||||
this.providers.set(config.id, {
|
||||
config,
|
||||
clientId,
|
||||
clientSecret,
|
||||
shape,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
get(id: string): RegisteredProvider | undefined {
|
||||
return this.providers.get(id);
|
||||
}
|
||||
|
||||
list(): RegisteredProvider[] {
|
||||
return Array.from(this.providers.values());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { RegisteredProvider } from "./types.js";
|
||||
|
||||
const TIMEOUT_MS = 10_000;
|
||||
|
||||
export interface RevokeUpstreamInput {
|
||||
provider: RegisteredProvider;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort RFC 7009 token revocation against an upstream provider.
|
||||
*
|
||||
* - No-op if the provider has no `endpoints.revoke` configured.
|
||||
* - No-op if neither token is provided.
|
||||
* - Prefers revoking the refresh token (which implicitly revokes derived
|
||||
* access tokens too, per RFC 7009 §2.1).
|
||||
* - Substitutes `{client_id}` placeholder in the URL — required for providers
|
||||
* like GitHub that scope revocation under
|
||||
* `https://api.github.com/applications/{client_id}/grant`.
|
||||
*
|
||||
* Throws on non-2xx upstream responses; callers (e.g. the disconnect route)
|
||||
* should catch and log so local cleanup proceeds either way.
|
||||
*/
|
||||
export async function revokeUpstreamToken(
|
||||
input: RevokeUpstreamInput,
|
||||
): Promise<void> {
|
||||
const revokeUrl = input.provider.config.endpoints.revoke;
|
||||
if (!revokeUrl) return;
|
||||
if (!input.accessToken && !input.refreshToken) return;
|
||||
|
||||
const url = revokeUrl.replace("{client_id}", input.provider.clientId);
|
||||
|
||||
const body = new URLSearchParams();
|
||||
if (input.refreshToken) {
|
||||
body.set("token", input.refreshToken);
|
||||
body.set("token_type_hint", "refresh_token");
|
||||
} else if (input.accessToken) {
|
||||
body.set("token", input.accessToken);
|
||||
body.set("token_type_hint", "access_token");
|
||||
}
|
||||
|
||||
const credentials = Buffer.from(
|
||||
`${input.provider.clientId}:${input.provider.clientSecret}`,
|
||||
).toString("base64");
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
authorization: `Basic ${credentials}`,
|
||||
},
|
||||
body: body.toString(),
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`upstream revoke failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { microsoftShape } from "../microsoft.js";
|
||||
|
||||
describe("microsoftShape", () => {
|
||||
it("trims scope and splits on whitespace", () => {
|
||||
expect(
|
||||
microsoftShape.parseTokenResponse!({
|
||||
access_token: "T",
|
||||
expires_in: 3600,
|
||||
scope: " Mail.Read User.Read ",
|
||||
}).scope,
|
||||
).toEqual(["Mail.Read", "User.Read"]);
|
||||
});
|
||||
|
||||
it("uses displayName + tenant id for account label", () => {
|
||||
expect(
|
||||
microsoftShape.parseAccountInfo!({
|
||||
id: "u-1",
|
||||
displayName: "Alice",
|
||||
tid: "tenant-x",
|
||||
}),
|
||||
).toEqual({ accountId: "u-1", accountLabel: "Alice (tenant-x)" });
|
||||
});
|
||||
|
||||
it("falls back to displayName-only when no tid", () => {
|
||||
expect(
|
||||
microsoftShape.parseAccountInfo!({ id: "u-1", displayName: "Alice" }),
|
||||
).toEqual({ accountId: "u-1", accountLabel: "Alice" });
|
||||
});
|
||||
|
||||
it("returns no label when displayName is absent", () => {
|
||||
expect(microsoftShape.parseAccountInfo!({ id: "u-1" })).toEqual({
|
||||
accountId: "u-1",
|
||||
accountLabel: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when access_token is missing", () => {
|
||||
expect(() => microsoftShape.parseTokenResponse!({})).toThrow(
|
||||
/response_shape_violation/,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when account id is missing", () => {
|
||||
expect(() => microsoftShape.parseAccountInfo!({})).toThrow(
|
||||
/response_shape_violation/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { slackShape } from "../slack.js";
|
||||
|
||||
describe("slackShape", () => {
|
||||
it("parses user-token nested response", () => {
|
||||
expect(
|
||||
slackShape.parseTokenResponse!({
|
||||
ok: true,
|
||||
authed_user: {
|
||||
access_token: "xoxp-USER",
|
||||
refresh_token: "xoxe-1-USER",
|
||||
expires_in: 43200,
|
||||
scope: "channels:read,chat:write",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
accessToken: "xoxp-USER",
|
||||
refreshToken: "xoxe-1-USER",
|
||||
expiresInSeconds: 43200,
|
||||
scope: ["channels:read", "chat:write"],
|
||||
});
|
||||
});
|
||||
|
||||
it("parses bot-token flat response", () => {
|
||||
expect(
|
||||
slackShape.parseTokenResponse!({
|
||||
ok: true,
|
||||
access_token: "xoxb-BOT",
|
||||
expires_in: 43200,
|
||||
scope: "chat:write",
|
||||
}),
|
||||
).toMatchObject({ accessToken: "xoxb-BOT", scope: ["chat:write"] });
|
||||
});
|
||||
|
||||
it("parses team account info", () => {
|
||||
expect(
|
||||
slackShape.parseAccountInfo!({ team: { id: "T123", name: "Acme" } }),
|
||||
).toEqual({ accountId: "T123", accountLabel: "Acme" });
|
||||
});
|
||||
|
||||
it("throws when access_token is missing", () => {
|
||||
expect(() =>
|
||||
slackShape.parseTokenResponse!({ ok: false }),
|
||||
).toThrow(/response_shape_violation/);
|
||||
});
|
||||
|
||||
it("throws when team.id is missing", () => {
|
||||
expect(() => slackShape.parseAccountInfo!({})).toThrow(
|
||||
/response_shape_violation/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ProviderShape } from "../types.js";
|
||||
import { slackShape } from "./slack.js";
|
||||
import { microsoftShape } from "./microsoft.js";
|
||||
|
||||
/**
|
||||
* Registry of named shape modules referenced by `shape: <name>` in provider
|
||||
* YAML files. Resolved statically at startup to keep the bootstrap path free
|
||||
* of dynamic ESM imports (which behave differently between dev/`tsx` and
|
||||
* compiled `.js`).
|
||||
*
|
||||
* To add a new shape: write the module, import it here, and add an entry.
|
||||
*/
|
||||
export const KNOWN_SHAPES: Readonly<Record<string, ProviderShape>> = {
|
||||
slack: slackShape,
|
||||
microsoft: microsoftShape,
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { ProviderShape } from "../types.js";
|
||||
|
||||
/**
|
||||
* Microsoft Graph OAuth shape.
|
||||
*
|
||||
* Token responses follow RFC-6749 but Microsoft sometimes returns the `scope`
|
||||
* field with leading/trailing whitespace and tabs, so we split on whitespace
|
||||
* runs rather than a single space.
|
||||
*
|
||||
* `https://graph.microsoft.com/v1.0/me` returns `id`, `displayName`, and
|
||||
* (for tenanted apps) a `tid` claim. When both display name and tenant id are
|
||||
* present, we prefer the human-readable form `<displayName> (<tenant>)` so the
|
||||
* UI can disambiguate accounts across tenants.
|
||||
*/
|
||||
export const microsoftShape: ProviderShape = {
|
||||
parseTokenResponse(raw) {
|
||||
if (typeof raw !== "object" || raw === null) {
|
||||
throw new Error("response_shape_violation");
|
||||
}
|
||||
const r = raw as Record<string, unknown>;
|
||||
if (typeof r.access_token !== "string") {
|
||||
throw new Error("response_shape_violation");
|
||||
}
|
||||
return {
|
||||
accessToken: r.access_token,
|
||||
refreshToken:
|
||||
typeof r.refresh_token === "string" ? r.refresh_token : undefined,
|
||||
expiresInSeconds:
|
||||
typeof r.expires_in === "number" ? r.expires_in : undefined,
|
||||
scope:
|
||||
typeof r.scope === "string"
|
||||
? r.scope.trim().split(/\s+/).filter(Boolean)
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
parseAccountInfo(raw) {
|
||||
if (typeof raw !== "object" || raw === null) {
|
||||
throw new Error("response_shape_violation");
|
||||
}
|
||||
const r = raw as Record<string, unknown>;
|
||||
const id = r.id;
|
||||
if (typeof id !== "string") {
|
||||
throw new Error("response_shape_violation");
|
||||
}
|
||||
const displayName =
|
||||
typeof r.displayName === "string" ? r.displayName : undefined;
|
||||
const tid = typeof r.tid === "string" ? r.tid : undefined;
|
||||
let label: string | undefined;
|
||||
if (displayName && tid) label = `${displayName} (${tid})`;
|
||||
else label = displayName;
|
||||
return { accountId: id, accountLabel: label };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { ProviderShape } from "../types.js";
|
||||
|
||||
/**
|
||||
* Slack OAuth v2 token + account-info shape.
|
||||
*
|
||||
* Slack's `oauth.v2.access` response is unusual: when a user-token is granted
|
||||
* the access/refresh tokens live under `authed_user`, while bot-token responses
|
||||
* keep them at the top level. We probe for `authed_user.access_token` first and
|
||||
* fall back to the flat layout.
|
||||
*
|
||||
* `auth.test` returns `{ team: { id, name } }`; we surface those as the account
|
||||
* id/label.
|
||||
*/
|
||||
export const slackShape: ProviderShape = {
|
||||
parseTokenResponse(raw) {
|
||||
if (typeof raw !== "object" || raw === null) {
|
||||
throw new Error("response_shape_violation");
|
||||
}
|
||||
const r = raw as Record<string, unknown>;
|
||||
const user = (r as { authed_user?: Record<string, unknown> }).authed_user;
|
||||
if (user && typeof user.access_token === "string") {
|
||||
return {
|
||||
accessToken: user.access_token,
|
||||
refreshToken:
|
||||
typeof user.refresh_token === "string" ? user.refresh_token : undefined,
|
||||
expiresInSeconds:
|
||||
typeof user.expires_in === "number" ? user.expires_in : undefined,
|
||||
scope:
|
||||
typeof user.scope === "string"
|
||||
? user.scope.split(",").filter(Boolean)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
if (typeof r.access_token !== "string") {
|
||||
throw new Error("response_shape_violation: no access_token");
|
||||
}
|
||||
return {
|
||||
accessToken: r.access_token,
|
||||
refreshToken:
|
||||
typeof r.refresh_token === "string" ? r.refresh_token : undefined,
|
||||
expiresInSeconds:
|
||||
typeof r.expires_in === "number" ? r.expires_in : undefined,
|
||||
scope:
|
||||
typeof r.scope === "string"
|
||||
? r.scope.split(",").filter(Boolean)
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
parseAccountInfo(raw) {
|
||||
if (typeof raw !== "object" || raw === null) {
|
||||
throw new Error("response_shape_violation");
|
||||
}
|
||||
const r = raw as { team?: { id?: unknown; name?: unknown } };
|
||||
if (typeof r.team?.id !== "string") {
|
||||
throw new Error("response_shape_violation: no team.id");
|
||||
}
|
||||
return {
|
||||
accountId: r.team.id,
|
||||
accountLabel: typeof r.team.name === "string" ? r.team.name : undefined,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { sql, lt } from "drizzle-orm";
|
||||
import { oauthAuthorizationStates } from "@paperclipai/db/schema/oauth";
|
||||
import { oauthLogger } from "./logger.js";
|
||||
|
||||
const SWEEP_INTERVAL_MS = 60 * 60 * 1000; // hourly
|
||||
|
||||
export interface StateSweepDeps {
|
||||
// db: Drizzle handle. Loosely typed so this module does not pull the full
|
||||
// @paperclipai/db Db type — same convention as refresh.ts and the routes.
|
||||
db: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete OAuth authorization states whose `expires_at` is more than 1 day in
|
||||
* the past. The 10-minute TTL is enforced at insert time; this sweep only
|
||||
* exists so abandoned rows do not accumulate forever. Errors are swallowed:
|
||||
* the next tick retries, and a stuck row never blocks production traffic.
|
||||
*/
|
||||
export async function runStateSweep(deps: StateSweepDeps): Promise<void> {
|
||||
try {
|
||||
await deps.db
|
||||
.delete(oauthAuthorizationStates)
|
||||
.where(
|
||||
lt(oauthAuthorizationStates.expiresAt, sql`now() - interval '1 day'`),
|
||||
);
|
||||
} catch (err) {
|
||||
oauthLogger.error(
|
||||
{ err: { message: (err as Error).message } },
|
||||
"state sweep failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function startStateSweeper(
|
||||
deps: StateSweepDeps,
|
||||
): { stop: () => void } {
|
||||
let stopped = false;
|
||||
let timeout: NodeJS.Timeout;
|
||||
const tick = async () => {
|
||||
if (stopped) return;
|
||||
await runStateSweep(deps);
|
||||
if (!stopped) timeout = setTimeout(tick, SWEEP_INTERVAL_MS);
|
||||
};
|
||||
timeout = setTimeout(tick, SWEEP_INTERVAL_MS);
|
||||
return {
|
||||
stop: () => {
|
||||
stopped = true;
|
||||
clearTimeout(timeout);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { OAuthProviderConfig } from "./provider-config.js";
|
||||
|
||||
export interface ParsedTokenResponse {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresInSeconds?: number;
|
||||
scope?: string[];
|
||||
}
|
||||
|
||||
export interface ParsedAccountInfo {
|
||||
accountId: string;
|
||||
accountLabel?: string;
|
||||
}
|
||||
|
||||
export interface ProviderShape {
|
||||
parseTokenResponse?: (raw: unknown) => ParsedTokenResponse;
|
||||
parseAccountInfo?: (raw: unknown) => ParsedAccountInfo;
|
||||
}
|
||||
|
||||
export interface RegisteredProvider {
|
||||
config: OAuthProviderConfig;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
shape: ProviderShape; // resolved (default + override merged)
|
||||
source: "yaml" | "plugin";
|
||||
}
|
||||
|
||||
export type { OAuthProviderConfig };
|
||||
@@ -0,0 +1,60 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import {
|
||||
OAuthProviderConfigSchema,
|
||||
type OAuthProviderConfig,
|
||||
} from "./provider-config.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
/**
|
||||
* Load and validate OAuth provider configs from every `*.yaml`/`*.yml` file in
|
||||
* a directory. Returns an empty array when the directory does not exist —
|
||||
* operators may run Paperclip without any file-based OAuth providers.
|
||||
*
|
||||
* Throws synchronously on the first invalid file: bad YAML or a config that
|
||||
* fails Zod validation. Operators see the offending file in the log and the
|
||||
* server refuses to start, which matches the rest of the bootstrap path.
|
||||
*/
|
||||
export async function loadProviderConfigsFromDirectory(
|
||||
dir: string,
|
||||
): Promise<OAuthProviderConfig[]> {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(dir);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
|
||||
throw err;
|
||||
}
|
||||
|
||||
const yamlFiles = entries.filter(
|
||||
(f) => f.endsWith(".yaml") || f.endsWith(".yml"),
|
||||
);
|
||||
const configs: OAuthProviderConfig[] = [];
|
||||
for (const file of yamlFiles) {
|
||||
const fullPath = path.join(dir, file);
|
||||
const raw = await readFile(fullPath, "utf8");
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = parseYaml(raw);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ file: fullPath, err },
|
||||
"failed to parse OAuth provider yaml",
|
||||
);
|
||||
throw new Error(`Invalid YAML in ${fullPath}`);
|
||||
}
|
||||
const result = OAuthProviderConfigSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
logger.error(
|
||||
{ file: fullPath, issues: result.error.issues },
|
||||
"invalid OAuth provider config",
|
||||
);
|
||||
throw new Error(
|
||||
`Invalid provider config in ${fullPath}: ${result.error.message}`,
|
||||
);
|
||||
}
|
||||
configs.push(result.data);
|
||||
}
|
||||
return configs;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { oauthCallbackRoute } from "../oauth-callback.js";
|
||||
|
||||
// Helpers: the route now does an atomic claim
|
||||
// db.update(states).set({consumedAt}).where(id AND consumed_at IS NULL AND
|
||||
// expires_at > now()).returning()
|
||||
// followed by (only on claim failure) a SELECT to disambiguate the error.
|
||||
// `claimed` is the array the .returning() resolves to: [stateRow] on success,
|
||||
// [] on failure. `probe` is what findFirst returns to disambiguate.
|
||||
function buildDbMock(opts: {
|
||||
claimed: unknown[];
|
||||
probe?: unknown;
|
||||
}) {
|
||||
const findFirst = vi.fn().mockResolvedValue(opts.probe ?? null);
|
||||
const returning = vi.fn().mockResolvedValue(opts.claimed);
|
||||
const where = vi.fn().mockReturnValue({ returning });
|
||||
const set = vi.fn().mockReturnValue({ where });
|
||||
const update = vi.fn().mockReturnValue({ set });
|
||||
return {
|
||||
update,
|
||||
query: { oauthAuthorizationStates: { findFirst } },
|
||||
};
|
||||
}
|
||||
|
||||
describe("GET /api/oauth/callback/:providerId", () => {
|
||||
it("redirects to safe default with invalid_state when state row missing", async () => {
|
||||
const db = buildDbMock({ claimed: [], probe: null });
|
||||
const app = express();
|
||||
app.use(
|
||||
"/api/oauth/callback/:providerId",
|
||||
oauthCallbackRoute({
|
||||
db: db as unknown as never,
|
||||
registry: { get: () => undefined } as unknown as never,
|
||||
publicUrl: "https://app.paperclip.test",
|
||||
secretService: {
|
||||
upsertSecretByName: async () => ({ id: "s" }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
const res = await request(app).get(
|
||||
"/api/oauth/callback/github?state=missing&code=x",
|
||||
);
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain("oauth_error=invalid_state");
|
||||
});
|
||||
|
||||
it("redirects with replay error when consumed_at set", async () => {
|
||||
// Atomic claim returns [] because consumed_at IS NULL fails. The probe
|
||||
// SELECT returns the row with a non-null consumedAt → disambiguate to
|
||||
// replay.
|
||||
const db = buildDbMock({
|
||||
claimed: [],
|
||||
probe: {
|
||||
id: "s",
|
||||
providerId: "github",
|
||||
consumedAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
returnUrl: "/settings/connections",
|
||||
companyId: "c1",
|
||||
codeVerifier: "v",
|
||||
redirectUri: "x",
|
||||
scopesRequested: [],
|
||||
},
|
||||
});
|
||||
const app = express();
|
||||
app.use(
|
||||
"/api/oauth/callback/:providerId",
|
||||
oauthCallbackRoute({
|
||||
db: db as unknown as never,
|
||||
registry: {
|
||||
get: () => ({ config: { id: "github" } } as unknown as never),
|
||||
} as unknown as never,
|
||||
publicUrl: "https://app.paperclip.test",
|
||||
secretService: {
|
||||
upsertSecretByName: async () => ({ id: "s" }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
const res = await request(app).get(
|
||||
"/api/oauth/callback/github?state=s&code=x",
|
||||
);
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain("oauth_error=replay");
|
||||
});
|
||||
|
||||
it("redirects with invalid_state when expires_at predicate fails", async () => {
|
||||
// Atomic claim returns [] because expires_at > now() fails. The probe
|
||||
// SELECT returns a row with no consumedAt → disambiguate to expired
|
||||
// (rendered as invalid_state for users).
|
||||
const db = buildDbMock({
|
||||
claimed: [],
|
||||
probe: {
|
||||
id: "s",
|
||||
providerId: "github",
|
||||
consumedAt: null,
|
||||
expiresAt: new Date(Date.now() - 60_000),
|
||||
returnUrl: "/settings/connections",
|
||||
companyId: "c1",
|
||||
codeVerifier: "v",
|
||||
redirectUri: "x",
|
||||
scopesRequested: [],
|
||||
},
|
||||
});
|
||||
const app = express();
|
||||
app.use(
|
||||
"/api/oauth/callback/:providerId",
|
||||
oauthCallbackRoute({
|
||||
db: db as unknown as never,
|
||||
registry: { get: () => undefined } as unknown as never,
|
||||
publicUrl: "https://app.paperclip.test",
|
||||
secretService: {
|
||||
upsertSecretByName: async () => ({ id: "s" }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
const res = await request(app).get(
|
||||
"/api/oauth/callback/github?state=s&code=x",
|
||||
);
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain("oauth_error=invalid_state");
|
||||
});
|
||||
|
||||
it("does not call findFirst on the happy path — claim returns the row directly", async () => {
|
||||
// Atomic claim succeeds and returns the stateRow via RETURNING. There
|
||||
// should be NO follow-up SELECT in this path. We then hit
|
||||
// provider_mismatch because the registry says the provider is unknown
|
||||
// (returns undefined → provider_not_found instead — let us route to
|
||||
// provider_mismatch by giving a mismatched providerId on the row).
|
||||
const db = buildDbMock({
|
||||
claimed: [
|
||||
{
|
||||
id: "s",
|
||||
providerId: "slack", // mismatch with the URL providerId "github"
|
||||
consumedAt: new Date(),
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
returnUrl: "/settings/connections",
|
||||
companyId: "c1",
|
||||
codeVerifier: "v",
|
||||
redirectUri: "x",
|
||||
scopesRequested: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
const app = express();
|
||||
app.use(
|
||||
"/api/oauth/callback/:providerId",
|
||||
oauthCallbackRoute({
|
||||
db: db as unknown as never,
|
||||
registry: { get: () => undefined } as unknown as never,
|
||||
publicUrl: "https://app.paperclip.test",
|
||||
secretService: {
|
||||
upsertSecretByName: async () => ({ id: "s" }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
const res = await request(app).get(
|
||||
"/api/oauth/callback/github?state=s&code=x",
|
||||
);
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain("oauth_error=provider_mismatch");
|
||||
expect(db.query.oauthAuthorizationStates.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { oauthRoutes } from "../oauth.js";
|
||||
import { ProviderRegistry } from "../../oauth/registry.js";
|
||||
|
||||
function setup() {
|
||||
const insertMock = vi.fn().mockResolvedValue([{ id: "state-uuid-123" }]);
|
||||
const db = {
|
||||
insert: () => ({ values: () => ({ returning: insertMock }) }),
|
||||
};
|
||||
const env = { GH_ID: "id", GH_SECRET: "s" };
|
||||
const registry = new ProviderRegistry({ env });
|
||||
registry.register(
|
||||
{
|
||||
id: "github",
|
||||
displayName: "GitHub",
|
||||
clientCredentials: { clientIdEnv: "GH_ID", clientSecretEnv: "GH_SECRET" },
|
||||
endpoints: {
|
||||
authorize: "https://github.com/login/oauth/authorize",
|
||||
token: "https://x.example/t",
|
||||
accountInfo: "https://x.example/me",
|
||||
},
|
||||
scopes: { default: ["repo"], offered: ["repo", "workflow"] },
|
||||
pkce: "required",
|
||||
authMethod: "post",
|
||||
responseFormat: "json",
|
||||
accountIdField: "id",
|
||||
accountLabelField: "login",
|
||||
refresh: { supported: false },
|
||||
},
|
||||
"yaml",
|
||||
);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(
|
||||
"/api/companies/:companyId/oauth",
|
||||
(req, _res, next) => {
|
||||
(req as unknown as { actor: unknown }).actor = {
|
||||
type: "board",
|
||||
userId: "u1",
|
||||
memberships: [{ companyId: req.params.companyId, membershipRole: "admin" }],
|
||||
};
|
||||
next();
|
||||
},
|
||||
oauthRoutes({
|
||||
registry,
|
||||
db: db as unknown as never,
|
||||
publicUrl: "https://app.paperclip.test",
|
||||
rateLimiter: { check: async () => true },
|
||||
secretService: {},
|
||||
}),
|
||||
);
|
||||
return { app, insertMock };
|
||||
}
|
||||
|
||||
describe("POST /connect/:providerId", () => {
|
||||
it("returns authorize URL with PKCE challenge + state", async () => {
|
||||
const { app } = setup();
|
||||
const res = await request(app)
|
||||
.post("/api/companies/c1/oauth/connect/github")
|
||||
.send({ returnUrl: "/settings/connections" });
|
||||
expect(res.status).toBe(200);
|
||||
const url = new URL(res.body.authorizeUrl);
|
||||
expect(url.origin + url.pathname).toBe("https://github.com/login/oauth/authorize");
|
||||
expect(url.searchParams.get("client_id")).toBe("id");
|
||||
expect(url.searchParams.get("redirect_uri")).toBe(
|
||||
"https://app.paperclip.test/api/oauth/callback/github",
|
||||
);
|
||||
expect(url.searchParams.get("scope")).toBe("repo");
|
||||
expect(url.searchParams.get("state")).toBe("state-uuid-123");
|
||||
expect(url.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
expect(url.searchParams.get("code_challenge")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("rejects scopes not in offered", async () => {
|
||||
const { app } = setup();
|
||||
const res = await request(app)
|
||||
.post("/api/companies/c1/oauth/connect/github")
|
||||
.send({ scopes: ["admin:everything"] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.errorCode).toBe("invalid_scope");
|
||||
});
|
||||
|
||||
it("returns 404 for unknown provider", async () => {
|
||||
const { app } = setup();
|
||||
const res = await request(app).post("/api/companies/c1/oauth/connect/unknown");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 429 when rate limit exceeded", async () => {
|
||||
const insertMock = vi.fn().mockResolvedValue([{ id: "s" }]);
|
||||
const db = { insert: () => ({ values: () => ({ returning: insertMock }) }) };
|
||||
const env = { GH_ID: "id", GH_SECRET: "s" };
|
||||
const registry = new ProviderRegistry({ env });
|
||||
registry.register(
|
||||
{
|
||||
id: "github",
|
||||
displayName: "GitHub",
|
||||
clientCredentials: { clientIdEnv: "GH_ID", clientSecretEnv: "GH_SECRET" },
|
||||
endpoints: {
|
||||
authorize: "https://x.example/a",
|
||||
token: "https://x.example/t",
|
||||
accountInfo: "https://x.example/me",
|
||||
},
|
||||
scopes: { default: [], offered: [] },
|
||||
pkce: "required",
|
||||
authMethod: "post",
|
||||
responseFormat: "json",
|
||||
accountIdField: "id",
|
||||
accountLabelField: "login",
|
||||
refresh: { supported: false },
|
||||
},
|
||||
"yaml",
|
||||
);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(
|
||||
"/api/companies/:companyId/oauth",
|
||||
(req, _res, next) => {
|
||||
(req as unknown as { actor: unknown }).actor = {
|
||||
type: "board",
|
||||
userId: "u1",
|
||||
memberships: [{ companyId: req.params.companyId, membershipRole: "admin" }],
|
||||
};
|
||||
next();
|
||||
},
|
||||
oauthRoutes({
|
||||
registry,
|
||||
db: db as unknown as never,
|
||||
publicUrl: "https://app.paperclip.test",
|
||||
rateLimiter: { check: async () => false },
|
||||
secretService: {},
|
||||
}),
|
||||
);
|
||||
const res = await request(app).post("/api/companies/c1/oauth/connect/github");
|
||||
expect(res.status).toBe(429);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { oauthRoutes } from "../oauth.js";
|
||||
import { ProviderRegistry } from "../../oauth/registry.js";
|
||||
|
||||
const conn = {
|
||||
id: "conn-1",
|
||||
companyId: "c1",
|
||||
providerId: "github",
|
||||
status: "active",
|
||||
accountId: "42",
|
||||
accountLabel: "octocat",
|
||||
scopes: ["repo"],
|
||||
accessTokenSecretId: "s1",
|
||||
refreshTokenSecretId: null,
|
||||
accessTokenExpiresAt: new Date(Date.now() + 3_600_000),
|
||||
lastRefreshedAt: new Date(),
|
||||
lastError: null,
|
||||
lastErrorAt: null,
|
||||
refreshAttemptCount: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
function makeApp(rows: Array<typeof conn>) {
|
||||
const env = { GH_ID: "id", GH_SECRET: "s" };
|
||||
const registry = new ProviderRegistry({ env });
|
||||
registry.register(
|
||||
{
|
||||
id: "github",
|
||||
displayName: "GitHub",
|
||||
clientCredentials: { clientIdEnv: "GH_ID", clientSecretEnv: "GH_SECRET" },
|
||||
endpoints: {
|
||||
authorize: "https://x.example/a",
|
||||
token: "https://x.example/t",
|
||||
accountInfo: "https://x.example/me",
|
||||
},
|
||||
scopes: { default: [], offered: [] },
|
||||
pkce: "required",
|
||||
authMethod: "post",
|
||||
responseFormat: "json",
|
||||
accountIdField: "id",
|
||||
accountLabelField: "login",
|
||||
refresh: { supported: false },
|
||||
},
|
||||
"yaml",
|
||||
);
|
||||
const db = {
|
||||
query: {
|
||||
oauthConnections: {
|
||||
findMany: vi.fn().mockResolvedValue(rows),
|
||||
findFirst: vi.fn().mockResolvedValue(rows[0] ?? null),
|
||||
},
|
||||
},
|
||||
};
|
||||
const app = express();
|
||||
app.use(
|
||||
"/api/companies/:companyId/oauth",
|
||||
(req, _res, next) => {
|
||||
(req as unknown as { actor: unknown }).actor = {
|
||||
type: "board",
|
||||
userId: "u1",
|
||||
memberships: [{ companyId: req.params.companyId, membershipRole: "admin" }],
|
||||
};
|
||||
next();
|
||||
},
|
||||
oauthRoutes({
|
||||
registry,
|
||||
db: db as unknown as never,
|
||||
publicUrl: "https://app.paperclip.test",
|
||||
rateLimiter: { check: async () => true },
|
||||
secretService: {},
|
||||
}),
|
||||
);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("Connection management routes", () => {
|
||||
it("GET /connections returns no token material", async () => {
|
||||
const res = await request(makeApp([conn])).get("/api/companies/c1/oauth/connections");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.connections).toHaveLength(1);
|
||||
expect(JSON.stringify(res.body)).not.toContain("accessTokenSecretId");
|
||||
expect(JSON.stringify(res.body)).not.toContain("refreshTokenSecretId");
|
||||
});
|
||||
|
||||
it("GET /connections/:id returns 404 for missing", async () => {
|
||||
const res = await request(makeApp([])).get("/api/companies/c1/oauth/connections/missing");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("GET /connections/:id rejects cross-tenant", async () => {
|
||||
const otherTenantConn = { ...conn, companyId: "c2" };
|
||||
const res = await request(makeApp([otherTenantConn])).get(
|
||||
"/api/companies/c1/oauth/connections/conn-1",
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { oauthRoutes } from "../oauth.js";
|
||||
import { ProviderRegistry } from "../../oauth/registry.js";
|
||||
|
||||
vi.mock("../../oauth/revoke.js", () => ({
|
||||
revokeUpstreamToken: vi.fn(),
|
||||
}));
|
||||
|
||||
import { revokeUpstreamToken } from "../../oauth/revoke.js";
|
||||
|
||||
function makeApp({ revokeFails = false, membershipRole = "admin" }: { revokeFails?: boolean; membershipRole?: string } = {}) {
|
||||
vi.mocked(revokeUpstreamToken).mockReset();
|
||||
vi.mocked(revokeUpstreamToken).mockImplementation(() =>
|
||||
revokeFails ? Promise.reject(new Error("rev fail")) : Promise.resolve(),
|
||||
);
|
||||
|
||||
const env = { GH_ID: "id", GH_SECRET: "s" };
|
||||
const registry = new ProviderRegistry({ env });
|
||||
registry.register(
|
||||
{
|
||||
id: "github",
|
||||
displayName: "GitHub",
|
||||
clientCredentials: { clientIdEnv: "GH_ID", clientSecretEnv: "GH_SECRET" },
|
||||
endpoints: {
|
||||
authorize: "https://x.example/a",
|
||||
token: "https://x.example/t",
|
||||
accountInfo: "https://x.example/me",
|
||||
revoke: "https://api.github.com/applications/{client_id}/grant",
|
||||
},
|
||||
scopes: { default: [], offered: [] },
|
||||
pkce: "required",
|
||||
authMethod: "post",
|
||||
responseFormat: "json",
|
||||
accountIdField: "id",
|
||||
accountLabelField: "login",
|
||||
refresh: { supported: false },
|
||||
},
|
||||
"yaml",
|
||||
);
|
||||
|
||||
const conn = {
|
||||
id: "c",
|
||||
companyId: "c1",
|
||||
providerId: "github",
|
||||
accessTokenSecretId: "s1",
|
||||
refreshTokenSecretId: "s2",
|
||||
status: "active",
|
||||
};
|
||||
|
||||
const tx = {
|
||||
delete: vi.fn().mockReturnValue({
|
||||
where: () => Promise.resolve(),
|
||||
}),
|
||||
};
|
||||
const db = {
|
||||
query: {
|
||||
oauthConnections: {
|
||||
findFirst: vi.fn().mockResolvedValue(conn),
|
||||
},
|
||||
},
|
||||
transaction: vi.fn().mockImplementation(async (fn: any) => fn(tx)),
|
||||
};
|
||||
|
||||
const removeFn = vi.fn().mockResolvedValue(null);
|
||||
const resolveFn = vi
|
||||
.fn()
|
||||
.mockImplementation(async (_companyId: string, secretId: string) =>
|
||||
secretId === "s1" ? "access-token-value" : "refresh-token-value",
|
||||
);
|
||||
const getByIdFn = vi
|
||||
.fn()
|
||||
.mockImplementation(async (id: string) => ({ id, latestVersion: 1 }));
|
||||
|
||||
const app = express();
|
||||
app.use(
|
||||
"/api/companies/:companyId/oauth",
|
||||
(req, _res, next) => {
|
||||
(req as unknown as { actor: unknown }).actor = {
|
||||
type: "board",
|
||||
userId: "u1",
|
||||
memberships: [
|
||||
{ companyId: req.params.companyId, membershipRole },
|
||||
],
|
||||
};
|
||||
next();
|
||||
},
|
||||
oauthRoutes({
|
||||
registry,
|
||||
db: db as unknown as never,
|
||||
publicUrl: "https://app.paperclip.test",
|
||||
rateLimiter: { check: async () => true },
|
||||
secretService: {
|
||||
upsertSecretByName: vi.fn(),
|
||||
resolveSecretValue: resolveFn,
|
||||
remove: removeFn,
|
||||
getById: getByIdFn,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return { app, db, removeFn, resolveFn, getByIdFn, tx };
|
||||
}
|
||||
|
||||
describe("DELETE /connections/:id", () => {
|
||||
it("returns 204 on success and revokes upstream + removes secrets", async () => {
|
||||
const { app, removeFn, resolveFn } = makeApp();
|
||||
const res = await request(app).delete("/api/companies/c1/oauth/connections/c");
|
||||
expect(res.status).toBe(204);
|
||||
expect(vi.mocked(revokeUpstreamToken)).toHaveBeenCalledTimes(1);
|
||||
expect(resolveFn).toHaveBeenCalledTimes(2);
|
||||
expect(removeFn).toHaveBeenCalledWith("s1");
|
||||
expect(removeFn).toHaveBeenCalledWith("s2");
|
||||
});
|
||||
|
||||
it("still returns 204 when upstream revoke fails", async () => {
|
||||
const { app } = makeApp({ revokeFails: true });
|
||||
const res = await request(app).delete("/api/companies/c1/oauth/connections/c");
|
||||
expect(res.status).toBe(204);
|
||||
expect(vi.mocked(revokeUpstreamToken)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 403 for a member without admin/owner role", async () => {
|
||||
const { app } = makeApp({ membershipRole: "member" });
|
||||
const res = await request(app).delete("/api/companies/c1/oauth/connections/c");
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toMatchObject({ errorCode: "forbidden" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { oauthMarkRevokedRoute } from "../oauth-mark-revoked.js";
|
||||
|
||||
interface MakeAppOptions {
|
||||
allowedIds: string[];
|
||||
injectClaim?: boolean; // default true; set false to simulate missing JWT
|
||||
// Tri-state: undefined → use default "co-1"; null → omit companyId from
|
||||
// the injected claim; string → use that companyId verbatim.
|
||||
companyId?: string | null;
|
||||
}
|
||||
|
||||
function makeApp({
|
||||
allowedIds,
|
||||
injectClaim = true,
|
||||
companyId,
|
||||
}: MakeAppOptions) {
|
||||
const effectiveCompany = companyId === undefined ? "co-1" : companyId;
|
||||
const whereMock = vi.fn().mockResolvedValue(undefined);
|
||||
const setMock = vi.fn().mockReturnValue({ where: whereMock });
|
||||
const db = {
|
||||
update: vi.fn().mockReturnValue({ set: setMock }),
|
||||
};
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(
|
||||
"/api/oauth/connections/:id/mark-revoked",
|
||||
(req, _res, next) => {
|
||||
if (injectClaim) {
|
||||
const claim: Record<string, unknown> = {
|
||||
connectionIds: allowedIds,
|
||||
runId: "r1",
|
||||
};
|
||||
if (effectiveCompany !== null) claim.companyId = effectiveCompany;
|
||||
(req as unknown as { runJwt: unknown }).runJwt = claim;
|
||||
}
|
||||
next();
|
||||
},
|
||||
oauthMarkRevokedRoute({ db } as never),
|
||||
);
|
||||
return { app, whereMock, setMock, db };
|
||||
}
|
||||
|
||||
describe("POST /api/oauth/connections/:id/mark-revoked", () => {
|
||||
it("204 when JWT scopes the connection", async () => {
|
||||
const { app, whereMock, setMock } = makeApp({ allowedIds: ["c-1"] });
|
||||
const res = await request(app).post("/api/oauth/connections/c-1/mark-revoked");
|
||||
expect(res.status).toBe(204);
|
||||
expect(whereMock).toHaveBeenCalledTimes(1);
|
||||
expect(setMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: "revoked",
|
||||
lastError: "runtime_401",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("403 when JWT does not include the connection", async () => {
|
||||
const { app, whereMock } = makeApp({ allowedIds: ["other"] });
|
||||
const res = await request(app).post("/api/oauth/connections/c-1/mark-revoked");
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.errorCode).toBe("forbidden");
|
||||
expect(whereMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("401 when no run-JWT claim is present on the request", async () => {
|
||||
const { app, whereMock } = makeApp({
|
||||
allowedIds: ["c-1"],
|
||||
injectClaim: false,
|
||||
});
|
||||
const res = await request(app).post("/api/oauth/connections/c-1/mark-revoked");
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.errorCode).toBe("unauthenticated");
|
||||
expect(whereMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("401 when JWT lacks a companyId so the UPDATE cannot be scoped to a tenant", async () => {
|
||||
const { app, whereMock } = makeApp({
|
||||
allowedIds: ["c-1"],
|
||||
companyId: null,
|
||||
});
|
||||
const res = await request(app).post("/api/oauth/connections/c-1/mark-revoked");
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.errorCode).toBe("unauthenticated");
|
||||
expect(whereMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scopes the UPDATE to the JWT companyId as defense-in-depth", async () => {
|
||||
// Verifies the WHERE clause is built with both id and companyId so a
|
||||
// stale or crafted JWT containing a connection ID from another tenant
|
||||
// cannot mutate a cross-tenant row.
|
||||
const { app, db } = makeApp({
|
||||
allowedIds: ["c-1"],
|
||||
companyId: "co-X",
|
||||
});
|
||||
await request(app).post("/api/oauth/connections/c-1/mark-revoked");
|
||||
expect(db.update).toHaveBeenCalledTimes(1);
|
||||
// The WHERE clause is an `and(eq(id, ...), eq(companyId, ...))`. Drizzle
|
||||
// returns an opaque SQL builder object; we can't easily inspect it, so
|
||||
// assert via the chained call shape: a single .set().where() — and trust
|
||||
// that the route file calls `and(eq, eq)` (covered by integration test
|
||||
// when DB is real).
|
||||
expect(db.update.mock.calls[0]?.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { oauthRoutes } from "../oauth.js";
|
||||
import { ProviderRegistry } from "../../oauth/registry.js";
|
||||
|
||||
function makeApp() {
|
||||
const env = { GH_ID: "id", GH_SECRET: "s" };
|
||||
const registry = new ProviderRegistry({ env });
|
||||
registry.register(
|
||||
{
|
||||
id: "github",
|
||||
displayName: "GitHub",
|
||||
iconUrl: "https://example.com/icon.png",
|
||||
docUrl: "https://example.com/docs",
|
||||
clientCredentials: { clientIdEnv: "GH_ID", clientSecretEnv: "GH_SECRET" },
|
||||
endpoints: {
|
||||
authorize: "https://x.example/a",
|
||||
token: "https://x.example/t",
|
||||
accountInfo: "https://x.example/me",
|
||||
},
|
||||
scopes: { default: ["repo"], offered: ["repo", "workflow"] },
|
||||
pkce: "required",
|
||||
authMethod: "post",
|
||||
responseFormat: "json",
|
||||
accountIdField: "id",
|
||||
accountLabelField: "login",
|
||||
refresh: { supported: false },
|
||||
},
|
||||
"yaml",
|
||||
);
|
||||
const app = express();
|
||||
app.use(
|
||||
"/api/companies/:companyId/oauth",
|
||||
(req, _res, next) => {
|
||||
(req as unknown as { actor: unknown }).actor = {
|
||||
type: "board",
|
||||
userId: "u1",
|
||||
memberships: [{ companyId: req.params.companyId, membershipRole: "admin" }],
|
||||
};
|
||||
next();
|
||||
},
|
||||
oauthRoutes({ registry } as unknown as Parameters<typeof oauthRoutes>[0]),
|
||||
);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("GET /providers", () => {
|
||||
it("returns provider summaries (no client secrets)", async () => {
|
||||
const res = await request(makeApp()).get("/api/companies/c1/oauth/providers");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.providers).toHaveLength(1);
|
||||
expect(res.body.providers[0]).toMatchObject({ id: "github", displayName: "GitHub" });
|
||||
expect(JSON.stringify(res.body)).not.toContain("clientSecret");
|
||||
});
|
||||
|
||||
it("GET /providers/:id returns single", async () => {
|
||||
const res = await request(makeApp()).get("/api/companies/c1/oauth/providers/github");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.id).toBe("github");
|
||||
});
|
||||
|
||||
it("404 for unknown provider", async () => {
|
||||
const res = await request(makeApp()).get("/api/companies/c1/oauth/providers/unknown");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { oauthRoutes } from "../oauth.js";
|
||||
import { ProviderRegistry } from "../../oauth/registry.js";
|
||||
|
||||
interface MakeAppOptions {
|
||||
refreshOutcome?:
|
||||
| { outcome: "success"; accessToken: string }
|
||||
| { outcome: "revoked" }
|
||||
| { outcome: "transient"; error: string }
|
||||
| { outcome: "skipped"; reason: string };
|
||||
conn?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
function makeApp(opts: MakeAppOptions = {}) {
|
||||
const env = { GH_ID: "id", GH_SECRET: "s" };
|
||||
const registry = new ProviderRegistry({ env });
|
||||
registry.register(
|
||||
{
|
||||
id: "github",
|
||||
displayName: "GitHub",
|
||||
clientCredentials: { clientIdEnv: "GH_ID", clientSecretEnv: "GH_SECRET" },
|
||||
endpoints: {
|
||||
authorize: "https://x/a",
|
||||
token: "https://x/t",
|
||||
accountInfo: "https://x/me",
|
||||
},
|
||||
scopes: { default: [], offered: [] },
|
||||
pkce: "required",
|
||||
authMethod: "post",
|
||||
responseFormat: "json",
|
||||
accountIdField: "id",
|
||||
accountLabelField: "login",
|
||||
refresh: { supported: true, rotatesRefreshToken: false },
|
||||
},
|
||||
"yaml",
|
||||
);
|
||||
|
||||
const conn =
|
||||
opts.conn === undefined
|
||||
? {
|
||||
id: "conn",
|
||||
companyId: "c1",
|
||||
providerId: "github",
|
||||
status: "active",
|
||||
accountId: null,
|
||||
accountLabel: null,
|
||||
scopes: [],
|
||||
accessTokenExpiresAt: null,
|
||||
lastRefreshedAt: null,
|
||||
lastError: null,
|
||||
lastErrorAt: null,
|
||||
refreshAttemptCount: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
: opts.conn;
|
||||
const findFirst = vi.fn().mockResolvedValue(conn);
|
||||
const db = {
|
||||
query: {
|
||||
oauthConnections: { findFirst },
|
||||
},
|
||||
};
|
||||
const refreshFn = vi
|
||||
.fn()
|
||||
.mockResolvedValue(opts.refreshOutcome ?? { outcome: "transient", error: "boom" });
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(
|
||||
"/api/companies/:companyId/oauth",
|
||||
(req, _res, next) => {
|
||||
(req as unknown as { actor: unknown }).actor = {
|
||||
type: "board",
|
||||
userId: "u1",
|
||||
memberships: [{ companyId: req.params.companyId, membershipRole: "admin" }],
|
||||
};
|
||||
next();
|
||||
},
|
||||
oauthRoutes({
|
||||
registry,
|
||||
db: db as unknown as never,
|
||||
publicUrl: "https://app.paperclip.test",
|
||||
rateLimiter: { check: async () => true },
|
||||
secretService: {},
|
||||
refreshFn,
|
||||
}),
|
||||
);
|
||||
return { app, refreshFn, findFirst };
|
||||
}
|
||||
|
||||
describe("POST /connections/:id/refresh", () => {
|
||||
it("returns 200 on success", async () => {
|
||||
const { app, refreshFn } = makeApp({
|
||||
refreshOutcome: { outcome: "success", accessToken: "x" },
|
||||
});
|
||||
const res = await request(app).post(
|
||||
"/api/companies/c1/oauth/connections/conn/refresh",
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(refreshFn).toHaveBeenCalledTimes(1);
|
||||
expect(res.body.id).toBe("conn");
|
||||
});
|
||||
|
||||
it("returns 429 when in backoff window", async () => {
|
||||
const { app, refreshFn } = makeApp({
|
||||
conn: {
|
||||
id: "conn",
|
||||
companyId: "c1",
|
||||
providerId: "github",
|
||||
status: "error",
|
||||
accountId: null,
|
||||
accountLabel: null,
|
||||
scopes: [],
|
||||
accessTokenExpiresAt: null,
|
||||
lastRefreshedAt: null,
|
||||
lastError: "boom",
|
||||
lastErrorAt: new Date(Date.now() - 1_000), // 1s ago, far inside backoff
|
||||
refreshAttemptCount: 3,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const res = await request(app).post(
|
||||
"/api/companies/c1/oauth/connections/conn/refresh",
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.body.errorCode).toBe("in_backoff");
|
||||
expect(res.headers["retry-after"]).toBeDefined();
|
||||
expect(refreshFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 409 when refresh result is revoked", async () => {
|
||||
const { app } = makeApp({ refreshOutcome: { outcome: "revoked" } });
|
||||
const res = await request(app).post(
|
||||
"/api/companies/c1/oauth/connections/conn/refresh",
|
||||
);
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.errorCode).toBe("connection_revoked");
|
||||
});
|
||||
|
||||
it("returns 404 when connection does not belong to company", async () => {
|
||||
const { app } = makeApp({ conn: null });
|
||||
const res = await request(app).post(
|
||||
"/api/companies/c1/oauth/connections/conn/refresh",
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
import { Router, type RequestHandler } from "express";
|
||||
import { and, eq, gt, isNull } from "drizzle-orm";
|
||||
import {
|
||||
oauthAuthorizationStates,
|
||||
oauthConnections,
|
||||
} from "@paperclipai/db/schema/oauth";
|
||||
import { exchangeToken, fetchAccountInfo } from "../oauth/http.js";
|
||||
import { oauthLogger } from "../oauth/logger.js";
|
||||
import { validateReturnUrl } from "../oauth/redirect-allowlist.js";
|
||||
import type { ProviderRegistry } from "../oauth/registry.js";
|
||||
|
||||
export interface OAuthCallbackDeps {
|
||||
// Drizzle db handle; loosely typed so the route does not pull the full
|
||||
// @paperclipai/db Db type into route code.
|
||||
db: any;
|
||||
registry: ProviderRegistry;
|
||||
publicUrl: string;
|
||||
// Narrow method bag — the callback needs to upsert OAuth token secrets and
|
||||
// (on account-mismatch rollback) remove freshly-written ones to avoid
|
||||
// orphans referenced by no connection row.
|
||||
secretService: {
|
||||
upsertSecretByName: (
|
||||
companyId: string,
|
||||
input: { name: string; value: string },
|
||||
) => Promise<{ id: string }>;
|
||||
remove?: (secretId: string) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
function back(
|
||||
deps: OAuthCallbackDeps,
|
||||
returnUrl: string | null | undefined,
|
||||
query: Record<string, string>,
|
||||
): string {
|
||||
const safe = validateReturnUrl(returnUrl ?? undefined, deps.publicUrl);
|
||||
const url = new URL(safe, deps.publicUrl);
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
url.searchParams.set(k, v);
|
||||
}
|
||||
return url.pathname + url.search;
|
||||
}
|
||||
|
||||
function secretName(
|
||||
providerId: string,
|
||||
accountId: string,
|
||||
kind: "access" | "refresh",
|
||||
): string {
|
||||
return `oauth:${providerId}:${accountId}:${kind}`;
|
||||
}
|
||||
|
||||
export function oauthCallbackRoute(deps: OAuthCallbackDeps): RequestHandler {
|
||||
const r = Router({ mergeParams: true });
|
||||
|
||||
r.get("/", async (req, res) => {
|
||||
const providerId = (req.params as { providerId: string }).providerId;
|
||||
const stateId = typeof req.query.state === "string" ? req.query.state : "";
|
||||
const code = typeof req.query.code === "string" ? req.query.code : "";
|
||||
const providerError =
|
||||
typeof req.query.error === "string" ? req.query.error : "";
|
||||
|
||||
// Atomic state claim: mark consumed_at in a single UPDATE that requires
|
||||
// consumed_at IS NULL and expires_at > now(). Closes the TOCTOU window
|
||||
// between SELECT-time expiry/consumed checks and the eventual UPDATE that
|
||||
// would otherwise let two concurrent callbacks both pass the checks and
|
||||
// both attempt token exchange. RETURNING gives us the row contents we
|
||||
// need without a second SELECT on the happy path.
|
||||
const claimNow = new Date();
|
||||
const claimResult: Array<typeof oauthAuthorizationStates.$inferSelect> =
|
||||
await deps.db
|
||||
.update(oauthAuthorizationStates)
|
||||
.set({ consumedAt: claimNow })
|
||||
.where(
|
||||
and(
|
||||
eq(oauthAuthorizationStates.id, stateId),
|
||||
isNull(oauthAuthorizationStates.consumedAt),
|
||||
gt(oauthAuthorizationStates.expiresAt, claimNow),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (claimResult.length === 0) {
|
||||
// Lost the race, already consumed, expired, or missing. SELECT to
|
||||
// disambiguate so we can return the right user-facing error code.
|
||||
const probe = await deps.db.query.oauthAuthorizationStates.findFirst({
|
||||
where: eq(oauthAuthorizationStates.id, stateId),
|
||||
});
|
||||
if (!probe) {
|
||||
return res.redirect(
|
||||
302,
|
||||
back(deps, null, { oauth_error: "invalid_state" }),
|
||||
);
|
||||
}
|
||||
if (probe.consumedAt) {
|
||||
return res.redirect(
|
||||
302,
|
||||
back(deps, probe.returnUrl, { oauth_error: "replay" }),
|
||||
);
|
||||
}
|
||||
// Row exists, not consumed, but failed the expires_at predicate.
|
||||
return res.redirect(
|
||||
302,
|
||||
back(deps, probe.returnUrl, { oauth_error: "invalid_state" }),
|
||||
);
|
||||
}
|
||||
|
||||
const stateRow = claimResult[0];
|
||||
|
||||
// Provider-mismatch check happens after the claim: a callback that
|
||||
// arrives at a different provider's URL has already used the state in an
|
||||
// unauthorized way, so consuming it here is the right outcome — the
|
||||
// legitimate provider's callback would just hit "replay" and the user
|
||||
// would restart the flow.
|
||||
if (stateRow.providerId !== providerId) {
|
||||
return res.redirect(
|
||||
302,
|
||||
back(deps, stateRow.returnUrl, { oauth_error: "provider_mismatch" }),
|
||||
);
|
||||
}
|
||||
|
||||
if (providerError === "access_denied") {
|
||||
return res.redirect(
|
||||
302,
|
||||
back(deps, stateRow.returnUrl, { oauth_error: "user_cancelled" }),
|
||||
);
|
||||
}
|
||||
|
||||
const provider = deps.registry.get(providerId);
|
||||
if (!provider) {
|
||||
return res.redirect(
|
||||
302,
|
||||
back(deps, stateRow.returnUrl, { oauth_error: "provider_not_found" }),
|
||||
);
|
||||
}
|
||||
|
||||
let tokenRaw: Record<string, unknown>;
|
||||
try {
|
||||
tokenRaw = await exchangeToken({
|
||||
url: provider.config.endpoints.token,
|
||||
params: {
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: stateRow.redirectUri,
|
||||
code_verifier: stateRow.codeVerifier,
|
||||
},
|
||||
authMethod: provider.config.authMethod,
|
||||
responseFormat: provider.config.responseFormat,
|
||||
clientId: provider.clientId,
|
||||
clientSecret: provider.clientSecret,
|
||||
});
|
||||
} catch (err) {
|
||||
oauthLogger.error(
|
||||
{ provider: providerId, err: { message: (err as Error).message } },
|
||||
"token exchange failed",
|
||||
);
|
||||
return res.redirect(
|
||||
302,
|
||||
back(deps, stateRow.returnUrl, { oauth_error: "token_exchange_failed" }),
|
||||
);
|
||||
}
|
||||
|
||||
let parsedToken;
|
||||
try {
|
||||
if (!provider.shape.parseTokenResponse) {
|
||||
throw new Error("provider shape missing parseTokenResponse");
|
||||
}
|
||||
parsedToken = provider.shape.parseTokenResponse(tokenRaw);
|
||||
} catch (err) {
|
||||
oauthLogger.error(
|
||||
{ provider: providerId, err: { message: (err as Error).message } },
|
||||
"token shape violation",
|
||||
);
|
||||
return res.redirect(
|
||||
302,
|
||||
back(deps, stateRow.returnUrl, {
|
||||
oauth_error: "token_exchange_failed",
|
||||
detail: "response_shape_violation",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let accountRaw: unknown;
|
||||
try {
|
||||
accountRaw = await fetchAccountInfo(
|
||||
provider.config.endpoints.accountInfo,
|
||||
parsedToken.accessToken,
|
||||
);
|
||||
} catch (err) {
|
||||
oauthLogger.error(
|
||||
{ provider: providerId, err: { message: (err as Error).message } },
|
||||
"account info fetch failed",
|
||||
);
|
||||
return res.redirect(
|
||||
302,
|
||||
back(deps, stateRow.returnUrl, { oauth_error: "account_info_failed" }),
|
||||
);
|
||||
}
|
||||
|
||||
let parsedAccount;
|
||||
try {
|
||||
if (!provider.shape.parseAccountInfo) {
|
||||
throw new Error("provider shape missing parseAccountInfo");
|
||||
}
|
||||
parsedAccount = provider.shape.parseAccountInfo(accountRaw);
|
||||
} catch {
|
||||
return res.redirect(
|
||||
302,
|
||||
back(deps, stateRow.returnUrl, { oauth_error: "account_info_failed" }),
|
||||
);
|
||||
}
|
||||
|
||||
// Persist secrets via upsert (deterministic name → idempotent reconnects).
|
||||
const accessName = secretName(providerId, parsedAccount.accountId, "access");
|
||||
const accessSecret = await deps.secretService.upsertSecretByName(
|
||||
stateRow.companyId,
|
||||
{ name: accessName, value: parsedToken.accessToken },
|
||||
);
|
||||
let refreshSecret: { id: string } | undefined;
|
||||
if (parsedToken.refreshToken) {
|
||||
const refreshName = secretName(
|
||||
providerId,
|
||||
parsedAccount.accountId,
|
||||
"refresh",
|
||||
);
|
||||
refreshSecret = await deps.secretService.upsertSecretByName(
|
||||
stateRow.companyId,
|
||||
{ name: refreshName, value: parsedToken.refreshToken },
|
||||
);
|
||||
}
|
||||
|
||||
const expiresAt = parsedToken.expiresInSeconds
|
||||
? new Date(Date.now() + parsedToken.expiresInSeconds * 1000)
|
||||
: null;
|
||||
const finalScopes = parsedToken.scope ?? stateRow.scopesRequested ?? [];
|
||||
|
||||
// Track the secret IDs we just wrote so that, on ACCOUNT_MISMATCH, we
|
||||
// can roll them back. Without this, a mismatched callback leaves
|
||||
// orphaned encrypted access/refresh tokens that no connection row
|
||||
// references — names matching `oauth:<provider>:<accountId>:access` /
|
||||
// `:refresh`. The connection upsert runs in its own transaction below;
|
||||
// those secret writes are NOT inside that transaction (they require
|
||||
// their own DB writes for versions/encryption metadata via the
|
||||
// secret-service factory bound to the outer db), so a transactional
|
||||
// rollback alone cannot undo them.
|
||||
//
|
||||
// We use Option C from the design notes: connection writes stay in a
|
||||
// single transaction; on rollback we explicitly `secretService.remove`
|
||||
// any freshly-persisted secrets. Option B (passing `tx` through the
|
||||
// secret service) is the cleaner long-term fix but requires a wider
|
||||
// refactor of the secret-service factory.
|
||||
const newSecretIds: string[] = [accessSecret.id];
|
||||
if (refreshSecret) newSecretIds.push(refreshSecret.id);
|
||||
|
||||
try {
|
||||
await deps.db.transaction(async (tx: any) => {
|
||||
const existing = await tx.query.oauthConnections.findFirst({
|
||||
where: and(
|
||||
eq(oauthConnections.companyId, stateRow.companyId),
|
||||
eq(oauthConnections.providerId, providerId),
|
||||
),
|
||||
});
|
||||
if (
|
||||
existing &&
|
||||
existing.accountId &&
|
||||
parsedAccount.accountId !== existing.accountId
|
||||
) {
|
||||
throw new Error("ACCOUNT_MISMATCH");
|
||||
}
|
||||
if (existing) {
|
||||
await tx
|
||||
.update(oauthConnections)
|
||||
.set({
|
||||
status: "active",
|
||||
scopes: finalScopes,
|
||||
accountId: parsedAccount.accountId,
|
||||
accountLabel: parsedAccount.accountLabel ?? null,
|
||||
accessTokenSecretId: accessSecret.id,
|
||||
refreshTokenSecretId:
|
||||
refreshSecret?.id ?? existing.refreshTokenSecretId,
|
||||
accessTokenExpiresAt: expiresAt,
|
||||
lastRefreshedAt: new Date(),
|
||||
lastError: null,
|
||||
lastErrorAt: null,
|
||||
refreshAttemptCount: 0,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(oauthConnections.id, existing.id));
|
||||
} else {
|
||||
await tx.insert(oauthConnections).values({
|
||||
companyId: stateRow.companyId,
|
||||
providerId,
|
||||
status: "active",
|
||||
accountId: parsedAccount.accountId,
|
||||
accountLabel: parsedAccount.accountLabel ?? null,
|
||||
scopes: finalScopes,
|
||||
accessTokenSecretId: accessSecret.id,
|
||||
refreshTokenSecretId: refreshSecret?.id ?? null,
|
||||
accessTokenExpiresAt: expiresAt,
|
||||
lastRefreshedAt: new Date(),
|
||||
});
|
||||
}
|
||||
// State row was already atomically consumed at the top of this
|
||||
// handler, so there is no separate UPDATE to consume it here.
|
||||
});
|
||||
} catch (err) {
|
||||
if ((err as Error).message === "ACCOUNT_MISMATCH") {
|
||||
// Roll back the secret writes that landed before mismatch was
|
||||
// detected. `remove` is idempotent and best-effort — swallow
|
||||
// failures so a secrets cleanup hiccup doesn't override the
|
||||
// user-visible mismatch error redirect.
|
||||
if (typeof deps.secretService.remove === "function") {
|
||||
for (const secretId of newSecretIds) {
|
||||
await deps.secretService.remove(secretId).catch((removeErr) => {
|
||||
oauthLogger.warn(
|
||||
{
|
||||
provider: providerId,
|
||||
secretId,
|
||||
err: { message: (removeErr as Error).message },
|
||||
},
|
||||
"failed to roll back orphan OAuth secret after account mismatch",
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
return res.redirect(
|
||||
302,
|
||||
back(deps, stateRow.returnUrl, { oauth_error: "account_mismatch" }),
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
return res.redirect(
|
||||
302,
|
||||
back(deps, stateRow.returnUrl, { oauth_connected: providerId }),
|
||||
);
|
||||
});
|
||||
|
||||
return r;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Router, type RequestHandler } from "express";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { oauthConnections } from "@paperclipai/db/schema/oauth";
|
||||
|
||||
export interface MarkRevokedDeps {
|
||||
// db: Drizzle handle. Kept loose so this route does not pull the full Db
|
||||
// type into the module; wired up in app.ts (T28) with the real instance.
|
||||
db: any;
|
||||
}
|
||||
|
||||
interface RunJwtClaim {
|
||||
connectionIds?: unknown;
|
||||
runId?: unknown;
|
||||
companyId?: unknown;
|
||||
}
|
||||
|
||||
// The run-JWT middleware attaches `req.runJwt` with the OAuth connection IDs
|
||||
// scoped to this run plus the issuing company. The field is not on
|
||||
// Express.Request in this module's TS view — we read it via a local cast and
|
||||
// tests inject it directly.
|
||||
export function oauthMarkRevokedRoute(deps: MarkRevokedDeps): RequestHandler {
|
||||
const r = Router({ mergeParams: true });
|
||||
r.post("/", async (req, res) => {
|
||||
const claim = (req as unknown as { runJwt?: RunJwtClaim }).runJwt;
|
||||
if (!claim) {
|
||||
res.status(401).json({ errorCode: "unauthenticated" });
|
||||
return;
|
||||
}
|
||||
const allowed: string[] = Array.isArray(claim.connectionIds)
|
||||
? claim.connectionIds.filter((x): x is string => typeof x === "string")
|
||||
: [];
|
||||
const id = (req.params as { id?: string }).id ?? "";
|
||||
if (!allowed.includes(id)) {
|
||||
res.status(403).json({ errorCode: "forbidden" });
|
||||
return;
|
||||
}
|
||||
const companyId = typeof claim.companyId === "string" ? claim.companyId : "";
|
||||
if (!companyId) {
|
||||
res.status(401).json({ errorCode: "unauthenticated" });
|
||||
return;
|
||||
}
|
||||
// Defense-in-depth: scope the UPDATE to the JWT's company too. The
|
||||
// connectionIds list is the primary boundary, but pinning company_id
|
||||
// prevents a stale or crafted JWT containing a connection ID from
|
||||
// another tenant from silently mutating a cross-tenant row.
|
||||
await deps.db
|
||||
.update(oauthConnections)
|
||||
.set({
|
||||
status: "revoked",
|
||||
lastError: "runtime_401",
|
||||
lastErrorAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(oauthConnections.id, id),
|
||||
eq(oauthConnections.companyId, companyId),
|
||||
),
|
||||
);
|
||||
res.status(204).end();
|
||||
});
|
||||
return r;
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import type { ProviderRegistry } from "../oauth/registry.js";
|
||||
import type { RegisteredProvider } from "../oauth/types.js";
|
||||
import type { SlidingWindowLimiter } from "../oauth/rate-limiter.js";
|
||||
import { generateCodeVerifier, deriveCodeChallenge } from "../oauth/pkce.js";
|
||||
import { validateReturnUrl } from "../oauth/redirect-allowlist.js";
|
||||
import { oauthAuthorizationStates, oauthConnections } from "@paperclipai/db/schema/oauth";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { revokeUpstreamToken } from "../oauth/revoke.js";
|
||||
import { oauthLogger } from "../oauth/logger.js";
|
||||
import { refreshConnection } from "../oauth/refresh.js";
|
||||
import { backoffSeconds } from "../oauth/backoff.js";
|
||||
|
||||
// Narrow method-bag used by OAuth routes — keep this loose so route code does
|
||||
// not pull the full secretService type, and so tests can substitute a stub.
|
||||
export interface OAuthRouteSecretService {
|
||||
upsertSecretByName: (
|
||||
companyId: string,
|
||||
input: { name: string; value: string },
|
||||
) => Promise<{ id: string }>;
|
||||
resolveSecretValue: (
|
||||
companyId: string,
|
||||
secretId: string,
|
||||
version: number | "latest",
|
||||
) => Promise<string>;
|
||||
remove: (secretId: string) => Promise<unknown>;
|
||||
getById: (id: string) => Promise<{ id: string; latestVersion: number } | null>;
|
||||
}
|
||||
|
||||
export interface OAuthRouteDeps {
|
||||
registry: ProviderRegistry;
|
||||
// db: Drizzle handle (typed via @paperclipai/db); kept loose here so the
|
||||
// router factory does not require pulling the full Db type into route code.
|
||||
db: any;
|
||||
publicUrl: string;
|
||||
rateLimiter: SlidingWindowLimiter;
|
||||
// Optional per-tenant flood limiter for `POST /connect/:providerId`. Keyed
|
||||
// by `companyId`, this guards against `oauth_authorization_states` row-flood
|
||||
// abuse (50 / 5min per spec §10.4). Optional so existing test harnesses and
|
||||
// older app.ts wirings continue to work; production wires it in app.ts.
|
||||
connectFloodLimiter?: SlidingWindowLimiter;
|
||||
// The connect route does not use secretService, so the bag is partial here.
|
||||
// Routes that need a method assert it at call time.
|
||||
secretService: Partial<OAuthRouteSecretService>;
|
||||
// Optional injection for tests; defaults to the real refreshConnection.
|
||||
refreshFn?: typeof refreshConnection;
|
||||
}
|
||||
|
||||
const STATE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
function summary(p: RegisteredProvider) {
|
||||
return {
|
||||
id: p.config.id,
|
||||
displayName: p.config.displayName,
|
||||
iconUrl: p.config.iconUrl,
|
||||
docUrl: p.config.docUrl,
|
||||
scopesOffered: p.config.scopes.offered,
|
||||
scopesDefault: p.config.scopes.default,
|
||||
};
|
||||
}
|
||||
|
||||
type ActorMembership = {
|
||||
companyId: string;
|
||||
membershipRole?: string | null;
|
||||
status?: string;
|
||||
};
|
||||
|
||||
const ADMIN_ROLES = new Set(["owner", "admin"]);
|
||||
|
||||
function actorMembership(req: Request, companyId: string): ActorMembership | null {
|
||||
const actor = (req as Request & {
|
||||
actor?: { type: string; memberships?: ActorMembership[] };
|
||||
}).actor;
|
||||
if (!actor || actor.type !== "board") return null;
|
||||
return (actor.memberships ?? []).find((m) => m.companyId === companyId) ?? null;
|
||||
}
|
||||
|
||||
function ensureMember(req: Request, res: Response): boolean {
|
||||
// Whitelist (not blacklist) the legitimate human-admin actor type. The
|
||||
// OAuth admin routes are board-only; allowing any other actor (e.g. an
|
||||
// `agent` token without `memberships`) past the type check would let it
|
||||
// reach the membership lookup and surface a 404 — leaking resource
|
||||
// existence — instead of being rejected with 401. Be strict here.
|
||||
const actor = (req as Request & { actor?: { type: string } }).actor;
|
||||
if (!actor || actor.type !== "board") {
|
||||
res.status(401).json({ errorCode: "unauthenticated" });
|
||||
return false;
|
||||
}
|
||||
const companyId = (req.params as unknown as { companyId: string }).companyId;
|
||||
if (!actorMembership(req, companyId)) {
|
||||
// 404 not 403, per spec 9.8
|
||||
res.status(404).end();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Like `ensureMember` but additionally requires the caller is an admin/owner
|
||||
// of the company. Used by routes that mutate state (initiate connect,
|
||||
// disconnect, manual refresh) — the UI hides these from non-admins, but the
|
||||
// server is the source of truth.
|
||||
function ensureCompanyAdmin(req: Request, res: Response): boolean {
|
||||
const actor = (req as Request & { actor?: { type: string } }).actor;
|
||||
if (!actor || actor.type !== "board") {
|
||||
res.status(401).json({ errorCode: "unauthenticated" });
|
||||
return false;
|
||||
}
|
||||
const companyId = (req.params as unknown as { companyId: string }).companyId;
|
||||
const membership = actorMembership(req, companyId);
|
||||
if (!membership) {
|
||||
res.status(404).end();
|
||||
return false;
|
||||
}
|
||||
if (!ADMIN_ROLES.has(String(membership.membershipRole ?? ""))) {
|
||||
res.status(403).json({ errorCode: "forbidden" });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function oauthRoutes(deps: OAuthRouteDeps): Router {
|
||||
const r = Router({ mergeParams: true });
|
||||
|
||||
r.get("/providers", (req, res) => {
|
||||
if (!ensureMember(req, res)) return;
|
||||
res.json({ providers: deps.registry.list().map(summary) });
|
||||
});
|
||||
|
||||
r.get("/providers/:providerId", (req, res) => {
|
||||
if (!ensureMember(req, res)) return;
|
||||
const p = deps.registry.get(req.params.providerId);
|
||||
if (!p) {
|
||||
res.status(404).json({ errorCode: "provider_not_found" });
|
||||
return;
|
||||
}
|
||||
res.json(summary(p));
|
||||
});
|
||||
|
||||
r.post("/connect/:providerId", async (req, res) => {
|
||||
if (!ensureCompanyAdmin(req, res)) return;
|
||||
const provider = deps.registry.get(req.params.providerId);
|
||||
if (!provider) {
|
||||
res.status(404).json({ errorCode: "provider_not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const actor = (req as Request & { actor: { userId: string } }).actor;
|
||||
const ok = await deps.rateLimiter.check(`connect:${actor.userId}`);
|
||||
if (!ok) {
|
||||
res.status(429).json({ errorCode: "rate_limited" });
|
||||
return;
|
||||
}
|
||||
if (deps.connectFloodLimiter) {
|
||||
const floodOk = await deps.connectFloodLimiter.check(
|
||||
`connect-flood:${(req.params as unknown as { companyId: string }).companyId}`,
|
||||
);
|
||||
if (!floodOk) {
|
||||
res.status(429).json({ errorCode: "connect_flood" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { scopes, returnUrl } = (req.body ?? {}) as {
|
||||
scopes?: unknown;
|
||||
returnUrl?: unknown;
|
||||
};
|
||||
const requestedScopes =
|
||||
Array.isArray(scopes) && scopes.every((s) => typeof s === "string")
|
||||
? (scopes as string[])
|
||||
: provider.config.scopes.default;
|
||||
const offered = new Set(provider.config.scopes.offered);
|
||||
if (!requestedScopes.every((s) => offered.has(s))) {
|
||||
res.status(400).json({ errorCode: "invalid_scope" });
|
||||
return;
|
||||
}
|
||||
|
||||
const verifier = generateCodeVerifier();
|
||||
const challenge = deriveCodeChallenge(verifier);
|
||||
const redirectUri = `${deps.publicUrl}/api/oauth/callback/${provider.config.id}`;
|
||||
const safeReturnUrl =
|
||||
typeof returnUrl === "string"
|
||||
? validateReturnUrl(returnUrl, deps.publicUrl)
|
||||
: "/settings/connections";
|
||||
const expiresAt = new Date(Date.now() + STATE_TTL_MS);
|
||||
|
||||
const companyId = (req.params as unknown as { companyId: string }).companyId;
|
||||
const [row] = await deps.db
|
||||
.insert(oauthAuthorizationStates)
|
||||
.values({
|
||||
companyId,
|
||||
providerId: provider.config.id,
|
||||
codeVerifier: verifier,
|
||||
redirectUri,
|
||||
scopesRequested: requestedScopes,
|
||||
initiatedByUserId: actor.userId,
|
||||
returnUrl: safeReturnUrl,
|
||||
expiresAt,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const authorizeUrl = new URL(provider.config.endpoints.authorize);
|
||||
authorizeUrl.searchParams.set("response_type", "code");
|
||||
authorizeUrl.searchParams.set("client_id", provider.clientId);
|
||||
authorizeUrl.searchParams.set("redirect_uri", redirectUri);
|
||||
authorizeUrl.searchParams.set("scope", requestedScopes.join(" "));
|
||||
authorizeUrl.searchParams.set("state", row.id);
|
||||
if (provider.config.pkce !== "unsupported") {
|
||||
authorizeUrl.searchParams.set("code_challenge", challenge);
|
||||
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
||||
}
|
||||
|
||||
res.json({ authorizeUrl: authorizeUrl.toString(), state: row.id });
|
||||
});
|
||||
|
||||
r.get("/connections", async (req, res) => {
|
||||
if (!ensureMember(req, res)) return;
|
||||
const companyId = (req.params as unknown as { companyId: string }).companyId;
|
||||
const rows = await deps.db.query.oauthConnections.findMany({
|
||||
where: eq(oauthConnections.companyId, companyId),
|
||||
});
|
||||
res.json({ connections: (rows as unknown[]).map((c) => publicConnection(c)) });
|
||||
});
|
||||
|
||||
r.get("/connections/:id", async (req, res) => {
|
||||
if (!ensureMember(req, res)) return;
|
||||
const companyId = (req.params as unknown as { companyId: string; id: string }).companyId;
|
||||
const row = await deps.db.query.oauthConnections.findFirst({
|
||||
where: and(
|
||||
eq(oauthConnections.id, req.params.id),
|
||||
eq(oauthConnections.companyId, companyId),
|
||||
),
|
||||
});
|
||||
if (!row || (row as { companyId: string }).companyId !== companyId) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
res.json(publicConnection(row));
|
||||
});
|
||||
|
||||
r.post("/connections/:id/refresh", async (req, res) => {
|
||||
if (!ensureCompanyAdmin(req, res)) return;
|
||||
const companyId = (req.params as unknown as { companyId: string; id: string }).companyId;
|
||||
const conn = await deps.db.query.oauthConnections.findFirst({
|
||||
where: and(
|
||||
eq(oauthConnections.id, req.params.id),
|
||||
eq(oauthConnections.companyId, companyId),
|
||||
),
|
||||
});
|
||||
if (!conn) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
const row = conn as {
|
||||
id: string;
|
||||
lastErrorAt: Date | null;
|
||||
refreshAttemptCount: number;
|
||||
};
|
||||
|
||||
if (row.lastErrorAt) {
|
||||
const minRetryAt = new Date(
|
||||
row.lastErrorAt.getTime() + backoffSeconds(row.refreshAttemptCount) * 1000,
|
||||
);
|
||||
if (minRetryAt > new Date()) {
|
||||
const retryAfter = Math.ceil((minRetryAt.getTime() - Date.now()) / 1000);
|
||||
res.setHeader("retry-after", String(retryAfter));
|
||||
res.status(429).json({
|
||||
errorCode: "in_backoff",
|
||||
retryAfterSeconds: retryAfter,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const ok = await deps.rateLimiter.check(`refresh:${row.id}`);
|
||||
if (!ok) {
|
||||
res.status(429).json({ errorCode: "rate_limited" });
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshFn = deps.refreshFn ?? refreshConnection;
|
||||
// refreshConnection requires the full RefreshSecretService surface — at
|
||||
// wire time (T28) the real secretService satisfies this; tests inject a
|
||||
// stubbed refreshFn so the cast here is safe.
|
||||
const result = await refreshFn({
|
||||
connectionId: row.id,
|
||||
db: deps.db,
|
||||
registry: deps.registry,
|
||||
secretService: deps.secretService as unknown as Parameters<
|
||||
typeof refreshConnection
|
||||
>[0]["secretService"],
|
||||
});
|
||||
const updated = await deps.db.query.oauthConnections.findFirst({
|
||||
where: eq(oauthConnections.id, row.id),
|
||||
});
|
||||
if (result.outcome === "success") {
|
||||
res.json(publicConnection(updated));
|
||||
return;
|
||||
}
|
||||
if (result.outcome === "revoked") {
|
||||
res.status(409).json({
|
||||
errorCode: "connection_revoked",
|
||||
connection: publicConnection(updated),
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.status(503).json({
|
||||
errorCode: "refresh_failed",
|
||||
connection: publicConnection(updated),
|
||||
});
|
||||
});
|
||||
|
||||
r.delete("/connections/:id", async (req, res) => {
|
||||
if (!ensureCompanyAdmin(req, res)) return;
|
||||
const companyId = (req.params as unknown as { companyId: string; id: string }).companyId;
|
||||
const row = await deps.db.query.oauthConnections.findFirst({
|
||||
where: and(
|
||||
eq(oauthConnections.id, req.params.id),
|
||||
eq(oauthConnections.companyId, companyId),
|
||||
),
|
||||
});
|
||||
if (!row) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
|
||||
const conn = row as {
|
||||
id: string;
|
||||
providerId: string;
|
||||
accessTokenSecretId: string | null;
|
||||
refreshTokenSecretId: string | null;
|
||||
};
|
||||
|
||||
const svc = deps.secretService;
|
||||
let accessToken: string | undefined;
|
||||
let refreshToken: string | undefined;
|
||||
|
||||
if (conn.accessTokenSecretId && svc.resolveSecretValue && svc.getById) {
|
||||
try {
|
||||
const secret = await svc.getById(conn.accessTokenSecretId);
|
||||
const version = secret?.latestVersion ?? "latest";
|
||||
accessToken = await svc.resolveSecretValue(
|
||||
companyId,
|
||||
conn.accessTokenSecretId,
|
||||
version,
|
||||
);
|
||||
} catch (err) {
|
||||
oauthLogger.warn(
|
||||
{
|
||||
providerId: conn.providerId,
|
||||
err: { message: (err as Error).message },
|
||||
},
|
||||
"failed to resolve access token for upstream revoke; continuing",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (conn.refreshTokenSecretId && svc.resolveSecretValue && svc.getById) {
|
||||
try {
|
||||
const secret = await svc.getById(conn.refreshTokenSecretId);
|
||||
const version = secret?.latestVersion ?? "latest";
|
||||
refreshToken = await svc.resolveSecretValue(
|
||||
companyId,
|
||||
conn.refreshTokenSecretId,
|
||||
version,
|
||||
);
|
||||
} catch (err) {
|
||||
oauthLogger.warn(
|
||||
{
|
||||
providerId: conn.providerId,
|
||||
err: { message: (err as Error).message },
|
||||
},
|
||||
"failed to resolve refresh token for upstream revoke; continuing",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const provider = deps.registry.get(conn.providerId);
|
||||
if (provider?.config.endpoints.revoke) {
|
||||
try {
|
||||
await revokeUpstreamToken({ provider, accessToken, refreshToken });
|
||||
} catch (err) {
|
||||
oauthLogger.warn(
|
||||
{
|
||||
providerId: conn.providerId,
|
||||
err: { message: (err as Error).message },
|
||||
},
|
||||
"upstream revoke failed; continuing local delete",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await deps.db.transaction(async (tx: any) => {
|
||||
await tx.delete(oauthConnections).where(eq(oauthConnections.id, conn.id));
|
||||
});
|
||||
|
||||
if (conn.accessTokenSecretId && svc.remove) {
|
||||
try {
|
||||
await svc.remove(conn.accessTokenSecretId);
|
||||
} catch (err) {
|
||||
oauthLogger.warn(
|
||||
{
|
||||
secretId: conn.accessTokenSecretId,
|
||||
err: { message: (err as Error).message },
|
||||
},
|
||||
"secret remove failed during disconnect; continuing",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (conn.refreshTokenSecretId && svc.remove) {
|
||||
try {
|
||||
await svc.remove(conn.refreshTokenSecretId);
|
||||
} catch (err) {
|
||||
oauthLogger.warn(
|
||||
{
|
||||
secretId: conn.refreshTokenSecretId,
|
||||
err: { message: (err as Error).message },
|
||||
},
|
||||
"secret remove failed during disconnect; continuing",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
function publicConnection(c: unknown) {
|
||||
const row = c as {
|
||||
id: string;
|
||||
providerId: string;
|
||||
status: string;
|
||||
accountId: string | null;
|
||||
accountLabel: string | null;
|
||||
scopes: string[];
|
||||
accessTokenExpiresAt: Date | null;
|
||||
lastRefreshedAt: Date | null;
|
||||
lastError: string | null;
|
||||
lastErrorAt: Date | null;
|
||||
refreshAttemptCount: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
return {
|
||||
id: row.id,
|
||||
providerId: row.providerId,
|
||||
status: row.status,
|
||||
accountId: row.accountId,
|
||||
accountLabel: row.accountLabel,
|
||||
scopes: row.scopes,
|
||||
accessTokenExpiresAt: row.accessTokenExpiresAt,
|
||||
lastRefreshedAt: row.lastRefreshedAt,
|
||||
lastError: row.lastError,
|
||||
lastErrorAt: row.lastErrorAt,
|
||||
refreshAttemptCount: row.refreshAttemptCount,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
companies,
|
||||
companySecretBindings,
|
||||
companySecretProviderConfigs,
|
||||
companySecretVersions,
|
||||
companySecrets,
|
||||
createDb,
|
||||
secretAccessEvents,
|
||||
} from "@paperclipai/db";
|
||||
import { oauthConnections } from "@paperclipai/db/schema/oauth";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "../../__tests__/helpers/embedded-postgres.js";
|
||||
import { ProviderRegistry } from "../../oauth/registry.js";
|
||||
import { secretService } from "../secrets.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported
|
||||
? describe
|
||||
: describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping oauth_token resolver tests on this host: ${
|
||||
embeddedPostgresSupport.reason ?? "unsupported environment"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("resolveAdapterConfigForRuntime — oauth_token bindings", () => {
|
||||
let stopDb: (() => Promise<void>) | null = null;
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
const secretsTmpDir = path.join(
|
||||
os.tmpdir(),
|
||||
`paperclip-secrets-oauth-binding-${randomUUID()}`,
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
mkdirSync(secretsTmpDir, { recursive: true });
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(
|
||||
secretsTmpDir,
|
||||
"master.key",
|
||||
);
|
||||
const started = await startEmbeddedPostgresTestDatabase("secrets-oauth-binding");
|
||||
stopDb = started.cleanup;
|
||||
db = createDb(started.connectionString);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(oauthConnections);
|
||||
await db.delete(secretAccessEvents);
|
||||
await db.delete(companySecretBindings);
|
||||
await db.delete(companySecretVersions);
|
||||
await db.delete(companySecrets);
|
||||
await db.delete(companySecretProviderConfigs);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await stopDb?.();
|
||||
if (previousKeyFile === undefined) {
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
} else {
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile;
|
||||
}
|
||||
rmSync(secretsTmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function seedCompany(name = "Acme") {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name,
|
||||
issuePrefix: `T${companyId.slice(0, 7)}`.toUpperCase(),
|
||||
status: "active",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
return companyId;
|
||||
}
|
||||
|
||||
function makeOauthDeps() {
|
||||
return { registry: new ProviderRegistry({ env: {} }) };
|
||||
}
|
||||
|
||||
async function seedConnection(input: {
|
||||
companyId: string;
|
||||
accessSecretId: string;
|
||||
status: "active" | "expired" | "revoked" | "error";
|
||||
accessTokenExpiresAt?: Date;
|
||||
}) {
|
||||
const connectionId = randomUUID();
|
||||
await db.insert(oauthConnections).values({
|
||||
id: connectionId,
|
||||
companyId: input.companyId,
|
||||
providerId: "github",
|
||||
status: input.status,
|
||||
accessTokenSecretId: input.accessSecretId,
|
||||
refreshTokenSecretId: null,
|
||||
accountId: "acct-42",
|
||||
accountLabel: "Octo",
|
||||
scopes: ["repo"],
|
||||
accessTokenExpiresAt:
|
||||
input.accessTokenExpiresAt ?? new Date(Date.now() + 3600_000),
|
||||
lastError: null,
|
||||
lastErrorAt: null,
|
||||
lastRefreshedAt: new Date(),
|
||||
refreshAttemptCount: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
return connectionId;
|
||||
}
|
||||
|
||||
it("resolves an oauth_token binding to the access-token plaintext", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db, makeOauthDeps());
|
||||
const accessSecret = await svc.upsertSecretByName(companyId, {
|
||||
name: `oauth:github:acct-42:access-${randomUUID()}`,
|
||||
value: "ACCESS-PLAINTEXT-XYZ",
|
||||
});
|
||||
const connectionId = await seedConnection({
|
||||
companyId,
|
||||
accessSecretId: accessSecret.id,
|
||||
status: "active",
|
||||
});
|
||||
|
||||
const result = await svc.resolveAdapterConfigForRuntime(companyId, {
|
||||
env: {
|
||||
GITHUB_TOKEN: { type: "oauth_token", connectionId, field: "access" },
|
||||
},
|
||||
});
|
||||
|
||||
expect((result.config.env as Record<string, string>).GITHUB_TOKEN).toBe(
|
||||
"ACCESS-PLAINTEXT-XYZ",
|
||||
);
|
||||
expect(result.oauthConnectionIds).toEqual([connectionId]);
|
||||
expect(result.secretKeys.has("GITHUB_TOKEN")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an oauth_token binding for a missing connection", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db, makeOauthDeps());
|
||||
const missingId = randomUUID();
|
||||
|
||||
await expect(
|
||||
svc.resolveAdapterConfigForRuntime(companyId, {
|
||||
env: {
|
||||
GITHUB_TOKEN: { type: "oauth_token", connectionId: missingId, field: "access" },
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ errorCode: "oauth_connection_missing" });
|
||||
});
|
||||
|
||||
it("rejects an oauth_token binding when the connection is revoked", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db, makeOauthDeps());
|
||||
const accessSecret = await svc.upsertSecretByName(companyId, {
|
||||
name: `oauth:github:acct-42:access-${randomUUID()}`,
|
||||
value: "ACCESS-PLAINTEXT-XYZ",
|
||||
});
|
||||
const connectionId = await seedConnection({
|
||||
companyId,
|
||||
accessSecretId: accessSecret.id,
|
||||
status: "revoked",
|
||||
});
|
||||
|
||||
await expect(
|
||||
svc.resolveAdapterConfigForRuntime(companyId, {
|
||||
env: {
|
||||
GITHUB_TOKEN: { type: "oauth_token", connectionId, field: "access" },
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ errorCode: "oauth_connection_revoked" });
|
||||
});
|
||||
|
||||
it("rejects a cross-company oauth_token binding (treated as missing)", async () => {
|
||||
const companyA = await seedCompany("A");
|
||||
const companyB = await seedCompany("B");
|
||||
const svc = secretService(db, makeOauthDeps());
|
||||
const accessSecret = await svc.upsertSecretByName(companyB, {
|
||||
name: `oauth:github:acct-42:access-${randomUUID()}`,
|
||||
value: "ACCESS-PLAINTEXT-XYZ",
|
||||
});
|
||||
const connectionId = await seedConnection({
|
||||
companyId: companyB,
|
||||
accessSecretId: accessSecret.id,
|
||||
status: "active",
|
||||
});
|
||||
|
||||
await expect(
|
||||
svc.resolveAdapterConfigForRuntime(companyA, {
|
||||
env: {
|
||||
GITHUB_TOKEN: { type: "oauth_token", connectionId, field: "access" },
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ errorCode: "oauth_connection_missing" });
|
||||
});
|
||||
|
||||
it("rejects an expired oauth_token when no refresh secret is on file", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db, makeOauthDeps());
|
||||
const accessSecret = await svc.upsertSecretByName(companyId, {
|
||||
name: `oauth:github:acct-42:access-${randomUUID()}`,
|
||||
value: "EXPIRED-PLAINTEXT",
|
||||
});
|
||||
const connectionId = await seedConnection({
|
||||
companyId,
|
||||
accessSecretId: accessSecret.id,
|
||||
status: "active",
|
||||
accessTokenExpiresAt: new Date(Date.now() - 60_000), // expired 1 minute ago
|
||||
});
|
||||
|
||||
await expect(
|
||||
svc.resolveAdapterConfigForRuntime(companyId, {
|
||||
env: {
|
||||
GITHUB_TOKEN: { type: "oauth_token", connectionId, field: "access" },
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ errorCode: "oauth_access_token_expired" });
|
||||
});
|
||||
|
||||
it("throws unprocessable when oauthDeps is not wired", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db); // no oauthDeps
|
||||
const connectionId = randomUUID();
|
||||
|
||||
await expect(
|
||||
svc.resolveAdapterConfigForRuntime(companyId, {
|
||||
env: {
|
||||
GITHUB_TOKEN: { type: "oauth_token", connectionId, field: "access" },
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/oauth_token bindings require server OAuth wiring/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
companies,
|
||||
companySecretBindings,
|
||||
companySecretProviderConfigs,
|
||||
companySecretVersions,
|
||||
companySecrets,
|
||||
createDb,
|
||||
secretAccessEvents,
|
||||
} from "@paperclipai/db";
|
||||
import { oauthConnections } from "@paperclipai/db/schema/oauth";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "../../__tests__/helpers/embedded-postgres.js";
|
||||
import { ProviderRegistry } from "../../oauth/registry.js";
|
||||
import { secretService } from "../secrets.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported
|
||||
? describe
|
||||
: describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping oauth_token lazy-refresh tests on this host: ${
|
||||
embeddedPostgresSupport.reason ?? "unsupported environment"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("resolveAdapterConfigForRuntime — lazy refresh", () => {
|
||||
let stopDb: (() => Promise<void>) | null = null;
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
const secretsTmpDir = path.join(
|
||||
os.tmpdir(),
|
||||
`paperclip-secrets-oauth-lazy-${randomUUID()}`,
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
mkdirSync(secretsTmpDir, { recursive: true });
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(
|
||||
secretsTmpDir,
|
||||
"master.key",
|
||||
);
|
||||
const started = await startEmbeddedPostgresTestDatabase("secrets-oauth-lazy");
|
||||
stopDb = started.cleanup;
|
||||
db = createDb(started.connectionString);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(oauthConnections);
|
||||
await db.delete(secretAccessEvents);
|
||||
await db.delete(companySecretBindings);
|
||||
await db.delete(companySecretVersions);
|
||||
await db.delete(companySecrets);
|
||||
await db.delete(companySecretProviderConfigs);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await stopDb?.();
|
||||
if (previousKeyFile === undefined) {
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
} else {
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile;
|
||||
}
|
||||
rmSync(secretsTmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function seedCompany(name = "Acme") {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name,
|
||||
issuePrefix: `T${companyId.slice(0, 7)}`.toUpperCase(),
|
||||
status: "active",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
return companyId;
|
||||
}
|
||||
|
||||
async function seedConnection(input: {
|
||||
companyId: string;
|
||||
accessSecretId: string;
|
||||
refreshSecretId: string | null;
|
||||
expiresAt: Date | null;
|
||||
status?: "active" | "expired" | "revoked" | "error";
|
||||
}) {
|
||||
const connectionId = randomUUID();
|
||||
await db.insert(oauthConnections).values({
|
||||
id: connectionId,
|
||||
companyId: input.companyId,
|
||||
providerId: "github",
|
||||
status: input.status ?? "active",
|
||||
accessTokenSecretId: input.accessSecretId,
|
||||
refreshTokenSecretId: input.refreshSecretId,
|
||||
accountId: "acct-42",
|
||||
accountLabel: "Octo",
|
||||
scopes: ["repo"],
|
||||
accessTokenExpiresAt: input.expiresAt,
|
||||
lastError: null,
|
||||
lastErrorAt: null,
|
||||
lastRefreshedAt: new Date(),
|
||||
refreshAttemptCount: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
return connectionId;
|
||||
}
|
||||
|
||||
it("calls refreshFn when access token expires inside the lazy window", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const refreshFn = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ outcome: "success", accessToken: "FRESH-TOKEN" });
|
||||
const svc = secretService(db, {
|
||||
registry: new ProviderRegistry({ env: {} }),
|
||||
refreshFn,
|
||||
});
|
||||
const accessSecret = await svc.upsertSecretByName(companyId, {
|
||||
name: `oauth:github:acct-42:access-${randomUUID()}`,
|
||||
value: "STALE-TOKEN",
|
||||
});
|
||||
const refreshSecret = await svc.upsertSecretByName(companyId, {
|
||||
name: `oauth:github:acct-42:refresh-${randomUUID()}`,
|
||||
value: "REFRESH-TOKEN",
|
||||
});
|
||||
const connectionId = await seedConnection({
|
||||
companyId,
|
||||
accessSecretId: accessSecret.id,
|
||||
refreshSecretId: refreshSecret.id,
|
||||
// Expires in 30s — inside the 60s lazy window.
|
||||
expiresAt: new Date(Date.now() + 30_000),
|
||||
});
|
||||
|
||||
const result = await svc.resolveAdapterConfigForRuntime(companyId, {
|
||||
env: {
|
||||
GITHUB_TOKEN: { type: "oauth_token", connectionId, field: "access" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(refreshFn).toHaveBeenCalledTimes(1);
|
||||
expect(refreshFn.mock.calls[0]?.[0]?.connectionId).toBe(connectionId);
|
||||
expect((result.config.env as Record<string, string>).GITHUB_TOKEN).toBe(
|
||||
"FRESH-TOKEN",
|
||||
);
|
||||
expect(result.oauthConnectionIds).toEqual([connectionId]);
|
||||
});
|
||||
|
||||
it("skips refresh when access token expires far in the future", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const refreshFn = vi.fn();
|
||||
const svc = secretService(db, {
|
||||
registry: new ProviderRegistry({ env: {} }),
|
||||
refreshFn,
|
||||
});
|
||||
const accessSecret = await svc.upsertSecretByName(companyId, {
|
||||
name: `oauth:github:acct-42:access-${randomUUID()}`,
|
||||
value: "FRESH-ENOUGH-TOKEN",
|
||||
});
|
||||
const refreshSecret = await svc.upsertSecretByName(companyId, {
|
||||
name: `oauth:github:acct-42:refresh-${randomUUID()}`,
|
||||
value: "REFRESH-TOKEN",
|
||||
});
|
||||
const connectionId = await seedConnection({
|
||||
companyId,
|
||||
accessSecretId: accessSecret.id,
|
||||
refreshSecretId: refreshSecret.id,
|
||||
// Expires in 1h — well outside the lazy window.
|
||||
expiresAt: new Date(Date.now() + 3600_000),
|
||||
});
|
||||
|
||||
const result = await svc.resolveAdapterConfigForRuntime(companyId, {
|
||||
env: {
|
||||
GITHUB_TOKEN: { type: "oauth_token", connectionId, field: "access" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(refreshFn).not.toHaveBeenCalled();
|
||||
expect((result.config.env as Record<string, string>).GITHUB_TOKEN).toBe(
|
||||
"FRESH-ENOUGH-TOKEN",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the existing access secret on transient refresh failure when token is still valid", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const refreshFn = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ outcome: "transient", error: "network_blip" });
|
||||
const svc = secretService(db, {
|
||||
registry: new ProviderRegistry({ env: {} }),
|
||||
refreshFn,
|
||||
});
|
||||
const accessSecret = await svc.upsertSecretByName(companyId, {
|
||||
name: `oauth:github:acct-42:access-${randomUUID()}`,
|
||||
value: "STALE-TOKEN",
|
||||
});
|
||||
const refreshSecret = await svc.upsertSecretByName(companyId, {
|
||||
name: `oauth:github:acct-42:refresh-${randomUUID()}`,
|
||||
value: "REFRESH-TOKEN",
|
||||
});
|
||||
const connectionId = await seedConnection({
|
||||
companyId,
|
||||
accessSecretId: accessSecret.id,
|
||||
refreshSecretId: refreshSecret.id,
|
||||
// Expires in 30s — inside the lazy window, but still positive.
|
||||
expiresAt: new Date(Date.now() + 30_000),
|
||||
});
|
||||
|
||||
const result = await svc.resolveAdapterConfigForRuntime(companyId, {
|
||||
env: {
|
||||
GITHUB_TOKEN: { type: "oauth_token", connectionId, field: "access" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(refreshFn).toHaveBeenCalledTimes(1);
|
||||
expect((result.config.env as Record<string, string>).GITHUB_TOKEN).toBe(
|
||||
"STALE-TOKEN",
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates revoked outcome from refreshFn as oauth_connection_revoked", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const refreshFn = vi.fn().mockResolvedValue({ outcome: "revoked" });
|
||||
const svc = secretService(db, {
|
||||
registry: new ProviderRegistry({ env: {} }),
|
||||
refreshFn,
|
||||
});
|
||||
const accessSecret = await svc.upsertSecretByName(companyId, {
|
||||
name: `oauth:github:acct-42:access-${randomUUID()}`,
|
||||
value: "STALE-TOKEN",
|
||||
});
|
||||
const refreshSecret = await svc.upsertSecretByName(companyId, {
|
||||
name: `oauth:github:acct-42:refresh-${randomUUID()}`,
|
||||
value: "REFRESH-TOKEN",
|
||||
});
|
||||
const connectionId = await seedConnection({
|
||||
companyId,
|
||||
accessSecretId: accessSecret.id,
|
||||
refreshSecretId: refreshSecret.id,
|
||||
expiresAt: new Date(Date.now() + 30_000),
|
||||
});
|
||||
|
||||
await expect(
|
||||
svc.resolveAdapterConfigForRuntime(companyId, {
|
||||
env: {
|
||||
GITHUB_TOKEN: { type: "oauth_token", connectionId, field: "access" },
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ errorCode: "oauth_connection_revoked" });
|
||||
});
|
||||
|
||||
it("does not call refreshFn when refreshTokenSecretId is null", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const refreshFn = vi.fn();
|
||||
const svc = secretService(db, {
|
||||
registry: new ProviderRegistry({ env: {} }),
|
||||
refreshFn,
|
||||
});
|
||||
const accessSecret = await svc.upsertSecretByName(companyId, {
|
||||
name: `oauth:github:acct-42:access-${randomUUID()}`,
|
||||
value: "PLAIN-TOKEN",
|
||||
});
|
||||
const connectionId = await seedConnection({
|
||||
companyId,
|
||||
accessSecretId: accessSecret.id,
|
||||
refreshSecretId: null,
|
||||
expiresAt: new Date(Date.now() + 30_000), // would normally trigger refresh
|
||||
});
|
||||
|
||||
const result = await svc.resolveAdapterConfigForRuntime(companyId, {
|
||||
env: {
|
||||
GITHUB_TOKEN: { type: "oauth_token", connectionId, field: "access" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(refreshFn).not.toHaveBeenCalled();
|
||||
expect((result.config.env as Record<string, string>).GITHUB_TOKEN).toBe(
|
||||
"PLAIN-TOKEN",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
agents,
|
||||
companies,
|
||||
companySecretBindings,
|
||||
companySecretProviderConfigs,
|
||||
companySecretVersions,
|
||||
companySecrets,
|
||||
createDb,
|
||||
secretAccessEvents,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "../../__tests__/helpers/embedded-postgres.js";
|
||||
import { secretService } from "../secrets.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported
|
||||
? describe
|
||||
: describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping upsertSecretByName tests on this host: ${
|
||||
embeddedPostgresSupport.reason ?? "unsupported environment"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("secretService.upsertSecretByName (real db)", () => {
|
||||
let stopDb: (() => Promise<void>) | null = null;
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
const secretsTmpDir = path.join(
|
||||
os.tmpdir(),
|
||||
`paperclip-secrets-upsert-${randomUUID()}`,
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
mkdirSync(secretsTmpDir, { recursive: true });
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(
|
||||
secretsTmpDir,
|
||||
"master.key",
|
||||
);
|
||||
const started = await startEmbeddedPostgresTestDatabase("secrets-upsert");
|
||||
stopDb = started.cleanup;
|
||||
db = createDb(started.connectionString);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(secretAccessEvents);
|
||||
await db.delete(companySecretBindings);
|
||||
await db.delete(companySecretVersions);
|
||||
await db.delete(companySecrets);
|
||||
await db.delete(companySecretProviderConfigs);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await stopDb?.();
|
||||
if (previousKeyFile === undefined) {
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
} else {
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile;
|
||||
}
|
||||
rmSync(secretsTmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function seedCompany(name = "Acme") {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name,
|
||||
issuePrefix: `T${companyId.slice(0, 7)}`.toUpperCase(),
|
||||
status: "active",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
return companyId;
|
||||
}
|
||||
|
||||
it("creates a new secret when no existing secret with that name", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const name = `oauth:test:abc:access-${randomUUID()}`;
|
||||
const secret = await svc.upsertSecretByName(companyId, {
|
||||
name,
|
||||
value: "v1",
|
||||
});
|
||||
expect(secret.id).toBeTruthy();
|
||||
expect(secret.name).toBe(name);
|
||||
expect(secret.status).toBe("active");
|
||||
expect(secret.latestVersion).toBe(1);
|
||||
});
|
||||
|
||||
it("rotates an existing active secret in place (same id, bumped version)", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = secretService(db);
|
||||
const name = `oauth:test:def:access-${randomUUID()}`;
|
||||
const created = await svc.upsertSecretByName(companyId, {
|
||||
name,
|
||||
value: "v1",
|
||||
});
|
||||
const rotated = await svc.upsertSecretByName(companyId, {
|
||||
name,
|
||||
value: "v2",
|
||||
});
|
||||
expect(rotated.id).toBe(created.id);
|
||||
expect(rotated.latestVersion).toBe(2);
|
||||
expect(rotated.status).toBe("active");
|
||||
});
|
||||
});
|
||||
|
||||
describe("secretService.upsertSecretByName (routing-only)", () => {
|
||||
// Mock-driven test for the recovery-from-deleted branch. The real
|
||||
// getByName filters out status='deleted' rows at the SQL layer, so this
|
||||
// branch is reachable in production only via stale rows from a
|
||||
// partially-failed previous `remove()` (provider.deleteOrArchive threw
|
||||
// before the final hard-delete). The fix purges the stale row so the
|
||||
// create path can proceed instead of dead-lettering reconnects.
|
||||
it("purges a stale deleted row and falls through to create", async () => {
|
||||
const companyId = randomUUID();
|
||||
const deletedRow = {
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
name: "oauth:test:xyz:access",
|
||||
status: "deleted",
|
||||
};
|
||||
|
||||
// First call to getByName (inside upsertSecretByName) returns the stale
|
||||
// deleted row; the create path's own getByName must then return null so
|
||||
// the create step actually runs. We don't care about the create path
|
||||
// here — we just need to confirm the throw is gone and db.delete fires
|
||||
// against the stale row's id. Stub db.select so the first call returns
|
||||
// [deletedRow] and any subsequent call returns []. Stub db.delete to
|
||||
// record its where() argument.
|
||||
let selectCalls = 0;
|
||||
const fakeDb = {
|
||||
select: vi.fn().mockImplementation(() => {
|
||||
const rows = selectCalls === 0 ? [deletedRow] : [];
|
||||
selectCalls++;
|
||||
return {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
then: (resolve: (r: unknown[]) => unknown) => resolve(rows),
|
||||
};
|
||||
}),
|
||||
delete: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
} as unknown as ReturnType<typeof createDb> & {
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const svc = secretService(fakeDb);
|
||||
// The create path will fail downstream because we haven't stubbed the
|
||||
// full insert chain; we only need to confirm the early throw is gone
|
||||
// *and* that db.delete was invoked to purge the stale row before any
|
||||
// create attempt. Wrap in a try so a later create-path failure doesn't
|
||||
// mask the assertions we care about.
|
||||
try {
|
||||
await svc.upsertSecretByName(companyId, {
|
||||
name: "oauth:test:xyz:access",
|
||||
value: "v1",
|
||||
});
|
||||
} catch (err) {
|
||||
// Any error here must NOT match the previous "previously deleted"
|
||||
// wording — that branch is gone.
|
||||
expect(String((err as Error).message)).not.toMatch(/previously deleted/i);
|
||||
}
|
||||
expect(fakeDb.delete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -336,7 +336,12 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
projectEnv: unknown;
|
||||
secretsSvc: RuntimeConfigSecretResolver;
|
||||
}) {
|
||||
const { config: resolvedConfig, secretKeys, manifest } = await input.secretsSvc.resolveAdapterConfigForRuntime(
|
||||
const {
|
||||
config: resolvedConfig,
|
||||
secretKeys,
|
||||
manifest,
|
||||
oauthConnectionIds,
|
||||
} = await input.secretsSvc.resolveAdapterConfigForRuntime(
|
||||
input.companyId,
|
||||
input.executionRunConfig,
|
||||
input.agentId
|
||||
@@ -379,6 +384,7 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
resolvedConfig,
|
||||
secretKeys,
|
||||
secretManifest: [...(manifest ?? []), ...(projectEnvResolution.manifest ?? [])],
|
||||
oauthConnectionIds: oauthConnectionIds ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2308,6 +2314,10 @@ export type HeartbeatEnvironmentRuntime = ReturnType<typeof environmentRuntimeSe
|
||||
export interface HeartbeatServiceOptions {
|
||||
pluginWorkerManager?: PluginWorkerManager;
|
||||
environmentRuntime?: HeartbeatEnvironmentRuntime;
|
||||
// OAuth wiring: when present, runtime resolution will resolve oauth_token
|
||||
// bindings (lazy refresh + access-token decryption). Tests/embedded callers
|
||||
// can omit it; the resolver throws on oauth_token bindings without it.
|
||||
oauthDeps?: Parameters<typeof secretService>[1];
|
||||
}
|
||||
|
||||
export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) {
|
||||
@@ -2317,7 +2327,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
});
|
||||
|
||||
const runLogStore = getRunLogStore();
|
||||
const secretsSvc = secretService(db);
|
||||
const secretsSvc = secretService(db, options.oauthDeps);
|
||||
const companySkills = companySkillService(db);
|
||||
const issuesSvc = issueService(db);
|
||||
const treeControlSvc = issueTreeControlService(db);
|
||||
@@ -7062,7 +7072,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
});
|
||||
const configSnapshot = buildExecutionWorkspaceConfigSnapshot(mergedConfig, selectedEnvironmentId);
|
||||
const executionRunConfig = stripWorkspaceRuntimeFromExecutionRunConfig(mergedConfig);
|
||||
const { resolvedConfig, secretKeys, secretManifest } = await resolveExecutionRunAdapterConfig({
|
||||
const { resolvedConfig, secretKeys, secretManifest, oauthConnectionIds } = await resolveExecutionRunAdapterConfig({
|
||||
companyId: agent.companyId,
|
||||
agentId: agent.id,
|
||||
issueId,
|
||||
@@ -7646,7 +7656,15 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
|
||||
const adapter = getServerAdapter(agent.adapterType);
|
||||
const authToken = adapter.supportsLocalAgentJwt
|
||||
? createLocalAgentJwt(agent.id, agent.companyId, agent.adapterType, run.id)
|
||||
? createLocalAgentJwt(
|
||||
agent.id,
|
||||
agent.companyId,
|
||||
agent.adapterType,
|
||||
run.id,
|
||||
oauthConnectionIds.length > 0
|
||||
? { connectionIds: oauthConnectionIds }
|
||||
: undefined,
|
||||
)
|
||||
: null;
|
||||
if (adapter.supportsLocalAgentJwt && !authToken) {
|
||||
logger.warn(
|
||||
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
routines,
|
||||
secretAccessEvents,
|
||||
} from "@paperclipai/db";
|
||||
import { oauthConnections } from "@paperclipai/db/schema/oauth";
|
||||
import type { ProviderRegistry } from "../oauth/registry.js";
|
||||
import type { refreshConnection as refreshConnectionImpl, RefreshDeps } from "../oauth/refresh.js";
|
||||
import type {
|
||||
AgentEnvConfig,
|
||||
CompanySecretBindingTarget,
|
||||
@@ -166,7 +169,8 @@ async function cleanupPreparedProviderWrite(input: {
|
||||
|
||||
type CanonicalEnvBinding =
|
||||
| { type: "plain"; value: string }
|
||||
| { type: "secret_ref"; secretId: string; version: number | "latest" };
|
||||
| { type: "secret_ref"; secretId: string; version: number | "latest" }
|
||||
| { type: "oauth_token"; connectionId: string };
|
||||
|
||||
type SecretConsumerContext = {
|
||||
consumerType: SecretBindingTargetType;
|
||||
@@ -227,6 +231,9 @@ function canonicalizeBinding(binding: EnvBinding): CanonicalEnvBinding {
|
||||
if (binding.type === "plain") {
|
||||
return { type: "plain", value: String(binding.value) };
|
||||
}
|
||||
if (binding.type === "oauth_token") {
|
||||
return { type: "oauth_token", connectionId: binding.connectionId };
|
||||
}
|
||||
return {
|
||||
type: "secret_ref",
|
||||
secretId: binding.secretId,
|
||||
@@ -253,7 +260,16 @@ function assertSelectableProviderConfig(config: {
|
||||
}
|
||||
}
|
||||
|
||||
export function secretService(db: Db) {
|
||||
export interface SecretServiceOAuthDeps {
|
||||
registry: ProviderRegistry;
|
||||
// refreshFn is wired late to break the secrets <-> refresh circular import.
|
||||
// app.ts injects the production `refreshConnection` from `../oauth/refresh.js`
|
||||
// here; tests can supply a stub. When undefined, the resolver throws if it
|
||||
// hits a stale oauth_token branch that requires refresh.
|
||||
refreshFn?: (deps: RefreshDeps) => ReturnType<typeof refreshConnectionImpl>;
|
||||
}
|
||||
|
||||
export function secretService(db: Db, oauthDeps?: SecretServiceOAuthDeps) {
|
||||
type NormalizeEnvOptions = {
|
||||
strictMode?: boolean;
|
||||
fieldPath?: string;
|
||||
@@ -611,6 +627,19 @@ export function secretService(db: Db) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (binding.type === "oauth_token") {
|
||||
// Persistence path: oauth_token bindings reference an oauth_connections
|
||||
// row, not a companySecrets row. Existence is validated at runtime by
|
||||
// resolveAdapterConfigForRuntime; here we just preserve the binding
|
||||
// shape so it round-trips through persistence.
|
||||
normalized[key] = {
|
||||
type: "oauth_token",
|
||||
connectionId: binding.connectionId,
|
||||
field: "access",
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
await assertSecretInCompany(companyId, binding.secretId);
|
||||
normalized[key] = {
|
||||
type: "secret_ref",
|
||||
@@ -897,7 +926,7 @@ export function secretService(db: Db) {
|
||||
return { providerConfig, provider, runtimeConfig: toProviderVaultRuntimeConfig(providerConfig) };
|
||||
}
|
||||
|
||||
return {
|
||||
const api = {
|
||||
listProviders: () => listSecretProviders(),
|
||||
|
||||
checkProviders: () => checkSecretProviders(),
|
||||
@@ -2032,6 +2061,41 @@ export function secretService(db: Db) {
|
||||
return refs;
|
||||
},
|
||||
|
||||
upsertSecretByName: async (
|
||||
companyId: string,
|
||||
input: { name: string; value: string },
|
||||
actor?: { userId?: string | null; agentId?: string | null },
|
||||
) => {
|
||||
const existing = await getByName(companyId, input.name);
|
||||
if (existing) {
|
||||
if (existing.status === "active") {
|
||||
return await api.rotate(existing.id, { value: input.value }, actor);
|
||||
}
|
||||
if (existing.status === "deleted") {
|
||||
// Stale row from a partially-failed previous `remove()` (the
|
||||
// provider's deleteOrArchive threw before the final hard-delete,
|
||||
// leaving the row renamed and status=deleted). The normal
|
||||
// getByName filter excludes deleted rows so this branch is
|
||||
// unreachable in the happy path; if we ever land here, purge the
|
||||
// dead row and fall through to create. Reconnects after a
|
||||
// disconnect must always work — a permanent throw here would
|
||||
// dead-letter that path.
|
||||
await db
|
||||
.delete(companySecrets)
|
||||
.where(eq(companySecrets.id, existing.id));
|
||||
} else {
|
||||
throw unprocessable(
|
||||
`Secret exists in non-active state: ${input.name}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return await api.create(
|
||||
companyId,
|
||||
{ name: input.name, provider: "local_encrypted", value: input.value },
|
||||
actor,
|
||||
);
|
||||
},
|
||||
|
||||
remove: async (secretId: string) => {
|
||||
const secret = await getById(secretId);
|
||||
if (!secret) return null;
|
||||
@@ -2132,7 +2196,7 @@ export function secretService(db: Db) {
|
||||
const binding = canonicalizeBinding(parsed.data as EnvBinding);
|
||||
if (binding.type === "plain") {
|
||||
resolved[key] = binding.value;
|
||||
} else {
|
||||
} else if (binding.type === "secret_ref") {
|
||||
const secretResolution = await resolveSecretValueInternal(
|
||||
companyId,
|
||||
binding.secretId,
|
||||
@@ -2142,6 +2206,13 @@ export function secretService(db: Db) {
|
||||
resolved[key] = secretResolution.value;
|
||||
manifest.push(secretResolution.manifestEntry);
|
||||
secretKeys.add(key);
|
||||
} else {
|
||||
// oauth_token bindings are not expected on project env (which only
|
||||
// accepts secret_ref / plain via the project secrets API). Surface a
|
||||
// clear error rather than silently dropping or falling through.
|
||||
throw unprocessable(
|
||||
`oauth_token bindings are not supported in project env (key: ${key})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return { env: resolved, secretKeys, manifest };
|
||||
@@ -2151,17 +2222,33 @@ export function secretService(db: Db) {
|
||||
companyId: string,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
context?: Omit<SecretConsumerContext, "configPath">,
|
||||
): Promise<{ config: Record<string, unknown>; secretKeys: Set<string>; manifest: RuntimeSecretManifestEntry[] }> => {
|
||||
): Promise<{
|
||||
config: Record<string, unknown>;
|
||||
secretKeys: Set<string>;
|
||||
manifest: RuntimeSecretManifestEntry[];
|
||||
oauthConnectionIds: string[];
|
||||
}> => {
|
||||
const resolved = { ...adapterConfig };
|
||||
const secretKeys = new Set<string>();
|
||||
const manifest: RuntimeSecretManifestEntry[] = [];
|
||||
const oauthConnectionIds = new Set<string>();
|
||||
if (!Object.prototype.hasOwnProperty.call(adapterConfig, "env")) {
|
||||
return { config: resolved, secretKeys, manifest };
|
||||
return {
|
||||
config: resolved,
|
||||
secretKeys,
|
||||
manifest,
|
||||
oauthConnectionIds: Array.from(oauthConnectionIds),
|
||||
};
|
||||
}
|
||||
const record = asRecord(adapterConfig.env);
|
||||
if (!record) {
|
||||
resolved.env = {};
|
||||
return { config: resolved, secretKeys, manifest };
|
||||
return {
|
||||
config: resolved,
|
||||
secretKeys,
|
||||
manifest,
|
||||
oauthConnectionIds: Array.from(oauthConnectionIds),
|
||||
};
|
||||
}
|
||||
const env: Record<string, string> = {};
|
||||
for (const [key, rawBinding] of Object.entries(record)) {
|
||||
@@ -2175,7 +2262,7 @@ export function secretService(db: Db) {
|
||||
const binding = canonicalizeBinding(parsed.data as EnvBinding);
|
||||
if (binding.type === "plain") {
|
||||
env[key] = binding.value;
|
||||
} else {
|
||||
} else if (binding.type === "secret_ref") {
|
||||
const secretResolution = await resolveSecretValueInternal(
|
||||
companyId,
|
||||
binding.secretId,
|
||||
@@ -2185,10 +2272,146 @@ export function secretService(db: Db) {
|
||||
env[key] = secretResolution.value;
|
||||
manifest.push(secretResolution.manifestEntry);
|
||||
secretKeys.add(key);
|
||||
} else {
|
||||
// oauth_token binding
|
||||
if (!oauthDeps) {
|
||||
throw unprocessable(
|
||||
"oauth_token bindings require server OAuth wiring (registry not configured)",
|
||||
);
|
||||
}
|
||||
const conn = await db.query.oauthConnections.findFirst({
|
||||
where: and(
|
||||
eq(oauthConnections.id, binding.connectionId),
|
||||
eq(oauthConnections.companyId, companyId),
|
||||
),
|
||||
});
|
||||
if (!conn) {
|
||||
const e = new Error(
|
||||
`oauth_connection_missing: ${binding.connectionId}`,
|
||||
) as Error & { errorCode: string };
|
||||
e.errorCode = "oauth_connection_missing";
|
||||
throw e;
|
||||
}
|
||||
if (conn.status === "revoked") {
|
||||
const e = new Error(
|
||||
`oauth_connection_revoked: ${conn.providerId}`,
|
||||
) as Error & { errorCode: string };
|
||||
e.errorCode = "oauth_connection_revoked";
|
||||
throw e;
|
||||
}
|
||||
if (conn.status === "error") {
|
||||
const e = new Error(
|
||||
`oauth_provider_unavailable: ${conn.providerId}`,
|
||||
) as Error & { errorCode: string };
|
||||
e.errorCode = "oauth_provider_unavailable";
|
||||
throw e;
|
||||
}
|
||||
// Lazy refresh: if the access token will expire within the lazy
|
||||
// window AND we have a refresh token on file, attempt a refresh
|
||||
// BEFORE reading the (possibly expired) access secret. On success
|
||||
// we use the freshly-rotated plaintext returned by refreshFn; on
|
||||
// permanent revocation we propagate `oauth_connection_revoked`;
|
||||
// on transient failure we fall back to the existing access secret
|
||||
// unless it's already past expiry, in which case we surface
|
||||
// `oauth_refresh_failed`.
|
||||
const LAZY_WINDOW_MS = 60 * 1000;
|
||||
const expiresInMs = conn.accessTokenExpiresAt
|
||||
? conn.accessTokenExpiresAt.getTime() - Date.now()
|
||||
: Number.POSITIVE_INFINITY;
|
||||
const needsRefresh =
|
||||
!!conn.refreshTokenSecretId && expiresInMs < LAZY_WINDOW_MS;
|
||||
|
||||
// When the access token is already expired AND there is no refresh
|
||||
// token to recover with, fail loudly. The previous behaviour fell
|
||||
// through and returned the expired plaintext, so agents hit the
|
||||
// upstream provider with a guaranteed-401 token.
|
||||
if (
|
||||
!conn.refreshTokenSecretId &&
|
||||
conn.accessTokenExpiresAt &&
|
||||
expiresInMs <= 0
|
||||
) {
|
||||
const e = new Error(
|
||||
`oauth_access_token_expired: ${conn.providerId}`,
|
||||
) as Error & { errorCode: string };
|
||||
e.errorCode = "oauth_access_token_expired";
|
||||
throw e;
|
||||
}
|
||||
|
||||
let resolvedAccessToken: string | null = null;
|
||||
if (needsRefresh) {
|
||||
if (!oauthDeps.refreshFn) {
|
||||
const e = new Error(
|
||||
"oauth_refresh_unwired: refreshFn not configured",
|
||||
) as Error & { errorCode: string };
|
||||
e.errorCode = "oauth_refresh_unwired";
|
||||
throw e;
|
||||
}
|
||||
const refreshResult = await oauthDeps.refreshFn({
|
||||
connectionId: conn.id,
|
||||
db,
|
||||
registry: oauthDeps.registry,
|
||||
secretService: api,
|
||||
});
|
||||
if (refreshResult.outcome === "success") {
|
||||
resolvedAccessToken = refreshResult.accessToken;
|
||||
} else if (refreshResult.outcome === "revoked") {
|
||||
const e = new Error(
|
||||
`oauth_connection_revoked: ${conn.providerId}`,
|
||||
) as Error & { errorCode: string };
|
||||
e.errorCode = "oauth_connection_revoked";
|
||||
throw e;
|
||||
} else if (expiresInMs <= 0) {
|
||||
// transient or skipped, but the existing token is already
|
||||
// expired — there is nothing safe to fall back to.
|
||||
const e = new Error(
|
||||
`oauth_refresh_failed: ${refreshResult.outcome}`,
|
||||
) as Error & { errorCode: string };
|
||||
e.errorCode = "oauth_refresh_failed";
|
||||
throw e;
|
||||
}
|
||||
// transient/skipped with non-expired token: fall through and read
|
||||
// the existing secret below.
|
||||
}
|
||||
|
||||
let manifestEntry: RuntimeSecretManifestEntry;
|
||||
if (resolvedAccessToken !== null) {
|
||||
manifestEntry = {
|
||||
configPath: `env.${key}`,
|
||||
envKey: key,
|
||||
secretId: conn.accessTokenSecretId,
|
||||
secretKey: `oauth:${conn.providerId}:access`,
|
||||
version: 0,
|
||||
provider: "local_encrypted",
|
||||
outcome: "success",
|
||||
};
|
||||
} else {
|
||||
const secretResolution = await resolveSecretValueInternal(
|
||||
companyId,
|
||||
conn.accessTokenSecretId,
|
||||
"latest",
|
||||
undefined,
|
||||
);
|
||||
resolvedAccessToken = secretResolution.value;
|
||||
manifestEntry = {
|
||||
...secretResolution.manifestEntry,
|
||||
configPath: `env.${key}`,
|
||||
envKey: key,
|
||||
};
|
||||
}
|
||||
env[key] = resolvedAccessToken;
|
||||
manifest.push(manifestEntry);
|
||||
secretKeys.add(key);
|
||||
oauthConnectionIds.add(conn.id);
|
||||
}
|
||||
}
|
||||
resolved.env = env;
|
||||
return { config: resolved, secretKeys, manifest };
|
||||
return {
|
||||
config: resolved,
|
||||
secretKeys,
|
||||
manifest,
|
||||
oauthConnectionIds: Array.from(oauthConnectionIds),
|
||||
};
|
||||
},
|
||||
};
|
||||
return api;
|
||||
}
|
||||
Reference in new issue
Block a user