mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Browser authorization binds a consenting identity to a company connection. > - Direct OAuth, managed-cloud claims and Vercel authorization have different state and persistence rules. > - Keeping all of them in the general tool service obscures those rules and their cleanup owners. > - This pull request separates state, initiation, provider completion and explicit identity promotion. > - The benefit is reviewable authorization flows with their existing authority checks and ordering preserved. ## Linked Issues or Issue Description Depends on #17161. Earlier stack: #17144 → #17146 → #17147 → #17155 → #17157 → #17159 → #17161 → this PR. **What existing behavior does this improve?** Maintenance of browser authorization and credential-sharing lifecycle. **Current behavior** The 12,019-line service combines provider callbacks, actor-bound states, authorization URLs and personal-to-company credential promotion. **Proposed behavior** Separate owners handle authorization state, start, direct/cloud/Vercel callbacks, catalog completion and explicit access finalization. Shared setup policy and identity checks have typed APIs used by the coordinator and authorization owners. **Reason and benefit** The entry point falls to 8,997 lines, a reduction of 3,022. Provider-specific state consumption and durable-write rules are visible in their own modules. ## What Changed - Extract authorization flows and their constraints; the largest new module is 700 lines. - Preserve direct/Vercel terminal state consumption and managed-cloud retryability until claim persistence commits. - Keep membership and grant locks, secret transaction clients, interaction resolution and catalog ordering unchanged. - Keep partial secret acquisition and cleanup together in access finalization. - Give shared organization-grant persistence, personal identity checks, setup policy and response projections named owners. ## Verification - All 470 public service and real local HTTP/PostgreSQL OAuth cases pass before and after extraction. - Coverage includes same-actor state binding, replay/denial, scope selection, membership revocation, provider failures, refresh concurrency and persisted usable credentials. - Server typecheck, module-boundary gate and whitespace checks pass. - All 182 pre-existing function/method bodies match by normalized syntax comparison. - The completed stack passes 525 targeted cases, full repository typecheck and build. The earlier interrupted local full suite remains unverified; all current-head CI gates pass; Greptile is 5/5 with no open review threads. - Original CI failure retained: [server shard 1/12](https://github.com/paperclipai/paperclip/actions/runs/39710536789/job/119600621627), `workspace-runtime.test.ts` case “replaces a reused Paperclip dev runtime whose 2xx health payload is unhealthy”. The fixture failed its initial health readiness with `ECONNREFUSED` after 97 probes / 10 seconds. The fixture and runtime implementation are unchanged and do not import this service. The isolated case passes without changing its timeout or assertions. One retry of only that shard passed at the same commit. All CI gates are now green; the original failure remains recorded here. ## Risks - Provider flows intentionally consume authorization state at different points. This extraction preserves each flow rather than sharing its completion algorithm. - Promotion must remove partially created secrets on failure and retire source secrets only after persistence. Both phases remain in the same owner with unchanged ordering. - Local HTTP fixtures prove assembled application behavior, not live third-party qualification. - No schema, public API or authorization policy change is intended. ## Model Used OpenAI Codex, GPT-6 family, with repository tools and code execution. The exact serving model ID and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
701 lines
29 KiB
TypeScript
701 lines
29 KiB
TypeScript
import { credentialScope } from "./client-settings.js";
|
|
import { withoutOAuthRefreshLease } from "./refresh-leases.js";
|
|
import type { ActorInfo } from "../types.js";
|
|
import { createOAuthDiscovery } from "./discovery.js";
|
|
import { createOAuthClientProtocol, storedOAuthTokenEndpointAuthMethod } from "./client.js";
|
|
import { normalizeOAuthProviderError, oauthProviderErrorMessage } from "./provider-errors.js";
|
|
import { oauthConfig, normalizeOauthScopes, resolveGrantedOauthScopes } from "./metadata.js";
|
|
import { connectionMethodForConnection } from "../connection-methods.js";
|
|
import { googleWorkspaceEffectiveScopes, isGoogleWorkspaceScopeGrant } from "@paperclipai/shared";
|
|
import { emitConnectionUpdated } from "../../connector-telemetry.js";
|
|
import { and, eq, ne } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { connectionGrants, companyMemberships, principalPermissionGrants, issueThreadInteractions, toolApplications, toolConnections } from "@paperclipai/db";
|
|
import type { ConnectToolAppResult } from "@paperclipai/shared";
|
|
import { getConnectableAppDefinition, recommendedDefaultsForApp } from "@paperclipai/shared";
|
|
import { badRequest, forbidden, HttpError, unprocessable } from "../../../errors.js";
|
|
import { secretService } from "../../secrets.js";
|
|
|
|
import type { OAuthStateStore } from "./state.js";
|
|
import type { OAuthCredentialStore } from "./credential-store.js";
|
|
import type { OAuthCatalogCompletion } from "./catalog-completion.js";
|
|
import type { RefreshConnectionCatalog, CheckConnectionHealth } from "../catalog/contracts.js";
|
|
import type { ConnectionSetupPolicy } from "../connections/setup-policy.js";
|
|
|
|
import { toApplication } from "../applications/projection.js";
|
|
import { groupedActions } from "../catalog/action-summary.js";
|
|
|
|
import { assertOAuthRedirectConstraints, assertOAuthCallbackIssuer } from "./authorization-constraints.js";
|
|
import type { createOrganizationGrantStore } from "../connections/organization-grant.js";
|
|
function asRecord(value: unknown): Record<string, unknown> {
|
|
if (value && typeof value === "object" && !Array.isArray(value))
|
|
return value as Record<string, unknown>;
|
|
return {};
|
|
}
|
|
|
|
type Dependencies = {
|
|
now: () => Date;
|
|
getConnectionRow(idOrUid: string, companyId?: string): Promise<typeof toolConnections.$inferSelect>;
|
|
state: OAuthStateStore;
|
|
refreshCatalog: RefreshConnectionCatalog;
|
|
finishOAuthCatalogWithRecommendedDefaults: OAuthCatalogCompletion;
|
|
store: Pick<OAuthCredentialStore, "createOrRotateOAuthSecret" | "oauthClientForConnection" | "syncCredentialBindings">;
|
|
setupPolicy: ConnectionSetupPolicy;
|
|
checkConnectionHealth: CheckConnectionHealth;
|
|
ensureDefaultOrganizationGrant: ReturnType<typeof createOrganizationGrantStore>;
|
|
oauthEndpointsForConnection: ReturnType<typeof createOAuthDiscovery>["oauthEndpointsForConnection"];
|
|
exchangeOAuthToken: ReturnType<typeof createOAuthClientProtocol>["exchangeOAuthToken"];
|
|
};
|
|
|
|
export function createDirectOAuthCallback(db: Db, dependencies: Dependencies) {
|
|
const { now, getConnectionRow, state, store, setupPolicy, refreshCatalog, finishOAuthCatalogWithRecommendedDefaults, checkConnectionHealth, ensureDefaultOrganizationGrant, oauthEndpointsForConnection, exchangeOAuthToken } = dependencies;
|
|
const { consumeOAuthState, rejectPendingOAuthInteraction } = state;
|
|
const { createOrRotateOAuthSecret, oauthClientForConnection, syncCredentialBindings } = store;
|
|
const { assertConnectorConnectionSetupEnabled } = setupPolicy;
|
|
async function completeOAuthCallback(input: {
|
|
state: string;
|
|
code?: string | null;
|
|
/**
|
|
* RFC 6749 `error`. Untrusted, and deliberately the *only* thing read from a
|
|
* failed callback — `error_description` and `error_uri` are not accepted as
|
|
* input at all, so there is nothing for a hostile provider to reflect
|
|
* through (PAP-17108).
|
|
*/
|
|
error?: string | null;
|
|
redirectUri: string;
|
|
/** RFC 9207 `iss`, when the authorization server returns it. */
|
|
iss?: string | null;
|
|
actor?: ActorInfo;
|
|
}): Promise<ConnectToolAppResult> {
|
|
// Binding first, outcome second: the provider's report of a failure is only
|
|
// acted on once the callback is bound to a state Paperclip issued and to the
|
|
// actor that started the flow, so an unsolicited callback cannot drive any
|
|
// path here. Consuming the state up front is what makes a denial terminal —
|
|
// a refused request must not stay completable by a later code (PAP-17109).
|
|
const stateRow = await consumeOAuthState(input.state, input.actor);
|
|
if (input.error) {
|
|
await rejectPendingOAuthInteraction(stateRow, input.actor);
|
|
const providerError = normalizeOAuthProviderError(input.error);
|
|
throw new HttpError(
|
|
400,
|
|
oauthProviderErrorMessage(
|
|
providerError,
|
|
"The authorization server denied the request.",
|
|
),
|
|
{
|
|
code: "oauth_authorization_denied",
|
|
providerError,
|
|
},
|
|
);
|
|
}
|
|
// Neither a code nor an error is not a usable answer either. It still spends
|
|
// the request: the recovery is a fresh authorization, not a state left live
|
|
// waiting for a better callback.
|
|
if (!input.code) throw badRequest("OAuth callback is missing a code");
|
|
|
|
let connection = await getConnectionRow(
|
|
stateRow.connectionId,
|
|
stateRow.companyId,
|
|
);
|
|
// Reauthorization refreshes credentials and catalog without rebuilding the
|
|
// operator's action profile, policy rules, or access bindings.
|
|
const shouldFinalizeDefaults = connection.status === "draft";
|
|
const preCallbackLifecycle = {
|
|
status: connection.status,
|
|
enabled: connection.enabled,
|
|
};
|
|
await assertConnectorConnectionSetupEnabled(connection);
|
|
const sourceTemplateKey =
|
|
typeof connection.config.sourceTemplateKey === "string"
|
|
? connection.config.sourceTemplateKey
|
|
: null;
|
|
const galleryEntry = sourceTemplateKey
|
|
? getConnectableAppDefinition(sourceTemplateKey)
|
|
: null;
|
|
assertOAuthRedirectConstraints(galleryEntry, input.redirectUri);
|
|
const endpoints = await oauthEndpointsForConnection(
|
|
connection,
|
|
null,
|
|
input.redirectUri,
|
|
);
|
|
assertOAuthCallbackIssuer(connection, endpoints, input.iss);
|
|
// Generic states created before requested scopes were persisted may still
|
|
// complete after an upgrade. Keep their original cached/discovered scope set.
|
|
const authorizedScopes = normalizeOauthScopes(
|
|
stateRow.requestedScopes ?? (galleryEntry ? [] : endpoints.scopes),
|
|
);
|
|
const client = await oauthClientForConnection(
|
|
connection,
|
|
endpoints.provider,
|
|
input.actor,
|
|
);
|
|
if (!client.clientId)
|
|
throw unprocessable(
|
|
`OAuth client id is not configured for ${endpoints.provider}`,
|
|
);
|
|
|
|
const token = await exchangeOAuthToken({
|
|
tokenUrl: endpoints.tokenUrl,
|
|
clientId: client.clientId,
|
|
clientSecret: client.clientSecret,
|
|
tokenEndpointAuthMethod: storedOAuthTokenEndpointAuthMethod(
|
|
oauthConfig(connection),
|
|
client.clientSecret,
|
|
),
|
|
redirectUri: input.redirectUri,
|
|
codeVerifier: stateRow.codeVerifier,
|
|
code: input.code,
|
|
resource: endpoints.resource,
|
|
});
|
|
if (sourceTemplateKey === "google-workspace" && !isGoogleWorkspaceScopeGrant(normalizeOauthScopes(token.scope))) {
|
|
throw unprocessable("Google did not return a supported permission grant. Reconnect and choose the Google services you want to use.", { code: "oauth_scope_grant_invalid" });
|
|
}
|
|
if (sourceTemplateKey === "google-workspace") {
|
|
token.scope = googleWorkspaceEffectiveScopes(authorizedScopes, normalizeOauthScopes(token.scope)).join(" ");
|
|
if (!token.scope) throw unprocessable("Google did not grant any selected service permissions", { code: "oauth_scope_grant_invalid" });
|
|
}
|
|
const connectedAt = now();
|
|
const expiresAt = token.expiresIn
|
|
? new Date(connectedAt.getTime() + token.expiresIn * 1000).toISOString()
|
|
: null;
|
|
if (stateRow.subjectUserId) {
|
|
let personalCredentialSecretRefs: typeof connectionGrants.$inferSelect.credentialSecretRefs =
|
|
[];
|
|
await db.transaction(async (tx) => {
|
|
// Serialize callback persistence with suspension/removal. Those paths
|
|
// lock this same membership row before sweeping personal credentials.
|
|
const [membership] = await tx
|
|
.select({ id: companyMemberships.id })
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, connection.companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, stateRow.subjectUserId!),
|
|
eq(companyMemberships.status, "active"),
|
|
ne(companyMemberships.membershipRole, "viewer"),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.for("update");
|
|
if (!membership) {
|
|
throw forbidden(
|
|
"Your company membership no longer permits connection changes. Ask a company owner to restore non-viewer access before you authorize this connection again.",
|
|
);
|
|
}
|
|
const txSecrets = secretService(tx);
|
|
const txSecretContext = { dbClient: tx, secretClient: txSecrets };
|
|
|
|
const [existingUserGrant] = await tx
|
|
.select()
|
|
.from(connectionGrants)
|
|
.where(
|
|
and(
|
|
eq(connectionGrants.companyId, connection.companyId),
|
|
eq(connectionGrants.connectionId, connection.id),
|
|
eq(connectionGrants.kind, "user"),
|
|
eq(connectionGrants.subjectUserId, stateRow.subjectUserId!),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.for("update");
|
|
await tx.select({ id: toolApplications.id }).from(toolApplications)
|
|
.where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, connection.companyId)))
|
|
.for("update");
|
|
await tx.select({ id: toolConnections.id }).from(toolConnections)
|
|
.where(and(eq(toolConnections.id, connection.id), eq(toolConnections.companyId, connection.companyId)))
|
|
.for("update");
|
|
const subjectCredentialSecretRefs =
|
|
existingUserGrant?.credentialSecretRefs ?? [];
|
|
const accessRef = await createOrRotateOAuthSecret(
|
|
{
|
|
companyId: connection.companyId,
|
|
connection,
|
|
configPath: "oauth.access_token",
|
|
label: "OAuth access token",
|
|
value: token.accessToken,
|
|
actor: input.actor,
|
|
existingRefs: subjectCredentialSecretRefs,
|
|
ownerUserId: stateRow.subjectUserId!,
|
|
},
|
|
txSecretContext,
|
|
);
|
|
const nextCredentialSecretRefs = [
|
|
...subjectCredentialSecretRefs.filter(
|
|
(ref) =>
|
|
ref.configPath !== "oauth.access_token" &&
|
|
ref.configPath !== "oauth.refresh_token",
|
|
),
|
|
accessRef,
|
|
];
|
|
if (token.refreshToken) {
|
|
nextCredentialSecretRefs.push(
|
|
await createOrRotateOAuthSecret(
|
|
{
|
|
companyId: connection.companyId,
|
|
connection,
|
|
configPath: "oauth.refresh_token",
|
|
label: "OAuth refresh token",
|
|
value: token.refreshToken,
|
|
actor: input.actor,
|
|
existingRefs: subjectCredentialSecretRefs,
|
|
ownerUserId: stateRow.subjectUserId!,
|
|
},
|
|
txSecretContext,
|
|
),
|
|
);
|
|
} else {
|
|
const existingRefreshRef = subjectCredentialSecretRefs.find(
|
|
(ref) => ref.configPath === "oauth.refresh_token",
|
|
);
|
|
if (existingRefreshRef)
|
|
nextCredentialSecretRefs.push(existingRefreshRef);
|
|
}
|
|
|
|
const grantedScopes = resolveGrantedOauthScopes({
|
|
tokenScope: token.scope,
|
|
requestedScopes: authorizedScopes,
|
|
});
|
|
const grantValues = {
|
|
providerTenant: {
|
|
...(existingUserGrant?.providerTenant ?? {}),
|
|
oauth: {
|
|
...asRecord(asRecord(existingUserGrant?.providerTenant).oauth),
|
|
strategy: "direct_oauth",
|
|
accessTokenExpiresAt: expiresAt ?? undefined,
|
|
scopes: grantedScopes.scopes,
|
|
scopeSource: grantedScopes.scopeSource,
|
|
unrequestedScopes: grantedScopes.unrequestedScopes,
|
|
// Per-grant, because two users can authorize the same connection with
|
|
// different scopes. The connection-level list is whichever callback ran last,
|
|
// so it is the wrong baseline for anyone else's refresh.
|
|
requestedScopes: authorizedScopes,
|
|
tokenType: token.tokenType,
|
|
refreshedAt: connectedAt.toISOString(),
|
|
},
|
|
},
|
|
credentialSecretRefs: nextCredentialSecretRefs,
|
|
status: "active" as const,
|
|
revokedAt: null,
|
|
revokedByAgentId: null,
|
|
revokedByUserId: null,
|
|
updatedAt: new Date(),
|
|
};
|
|
if (existingUserGrant) {
|
|
await tx
|
|
.update(connectionGrants)
|
|
.set(grantValues)
|
|
.where(eq(connectionGrants.id, existingUserGrant.id));
|
|
} else {
|
|
await tx.insert(connectionGrants).values({
|
|
companyId: connection.companyId,
|
|
connectionId: connection.id,
|
|
kind: "user",
|
|
subjectUserId: stateRow.subjectUserId!,
|
|
...grantValues,
|
|
isDefault: false,
|
|
createdByUserId: stateRow.subjectUserId!,
|
|
});
|
|
}
|
|
personalCredentialSecretRefs = nextCredentialSecretRefs;
|
|
const nextConfig = {
|
|
...connection.config,
|
|
oauth: {
|
|
...withoutOAuthRefreshLease(oauthConfig(connection)),
|
|
provider: endpoints.provider,
|
|
authorizationUrl: endpoints.authorizationUrl,
|
|
tokenUrl: endpoints.tokenUrl,
|
|
metadataUrl: endpoints.metadataUrl ?? null,
|
|
scopes: authorizedScopes,
|
|
clientIdEnv: client.clientIdEnv,
|
|
clientSecretEnv: client.clientSecret
|
|
? client.clientSecretEnv
|
|
: null,
|
|
credentialScope: credentialScope(connection, input.actor),
|
|
issuer: endpoints.issuer ?? oauthConfig(connection).issuer ?? null,
|
|
resource:
|
|
endpoints.resource ?? oauthConfig(connection).resource ?? null,
|
|
expiresAt,
|
|
scope: token.scope,
|
|
tokenType: token.tokenType,
|
|
connectedAt: connectedAt.toISOString(),
|
|
},
|
|
providerMetadata: {
|
|
...asRecord(connection.config.providerMetadata),
|
|
oauth: {
|
|
expiresAt,
|
|
scope: token.scope,
|
|
scopeSource: grantedScopes.scopeSource,
|
|
unrequestedScopes: grantedScopes.unrequestedScopes,
|
|
tokenType: token.tokenType,
|
|
},
|
|
},
|
|
};
|
|
const [updatedConnection] = await tx
|
|
.update(toolConnections)
|
|
.set({
|
|
status: "active",
|
|
enabled: true,
|
|
authKind: "oauth",
|
|
credentialPolicy: connection.credentialPolicy,
|
|
config: nextConfig,
|
|
transportConfig: nextConfig,
|
|
// A personal-only connection keeps tokens exclusively on its user
|
|
// grant. Adding a personal identity to an existing shared/fallback
|
|
// connection must not erase that connection's organization token.
|
|
credentialRefs:
|
|
connection.credentialPolicy === "per_user"
|
|
? connection.credentialRefs.filter(
|
|
(ref) => ref.name !== "oauth.access_token",
|
|
)
|
|
: connection.credentialRefs,
|
|
credentialSecretRefs:
|
|
connection.credentialPolicy === "per_user"
|
|
? connection.credentialSecretRefs.filter(
|
|
(ref) =>
|
|
ref.configPath !== "oauth.access_token" &&
|
|
ref.configPath !== "oauth.refresh_token",
|
|
)
|
|
: connection.credentialSecretRefs,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolConnections.id, connection.id))
|
|
.returning();
|
|
if (!updatedConnection)
|
|
throw new Error("OAuth connection was not found");
|
|
connection = updatedConnection;
|
|
await tx
|
|
.update(toolApplications)
|
|
.set({
|
|
status: "active",
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolApplications.id, connection.applicationId));
|
|
const linkedInteractionKind = stateRow.interactionId
|
|
? await tx
|
|
.select({ kind: issueThreadInteractions.kind })
|
|
.from(issueThreadInteractions)
|
|
.where(eq(issueThreadInteractions.id, stateRow.interactionId))
|
|
.limit(1)
|
|
.then((rows) => rows[0]?.kind ?? null)
|
|
: null;
|
|
if (
|
|
stateRow.interactionId &&
|
|
linkedInteractionKind === "request_confirmation"
|
|
) {
|
|
await tx
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
status: "accepted",
|
|
result: { version: 1, outcome: "accepted" },
|
|
resolvedByUserId: stateRow.subjectUserId!,
|
|
resolvedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(
|
|
and(
|
|
eq(issueThreadInteractions.id, stateRow.interactionId),
|
|
eq(issueThreadInteractions.companyId, connection.companyId),
|
|
),
|
|
);
|
|
}
|
|
await syncCredentialBindings(
|
|
connection,
|
|
connection.credentialPolicy === "per_user"
|
|
? personalCredentialSecretRefs
|
|
: [],
|
|
tx,
|
|
);
|
|
});
|
|
emitConnectionUpdated(
|
|
connection,
|
|
preCallbackLifecycle,
|
|
"oauth_callback",
|
|
);
|
|
|
|
// Personal OAuth used to return immediately after saving the grant. That
|
|
// left the connection draft/paused and its catalog empty, so the person
|
|
// who had just consented landed on a false "Nothing to test" state.
|
|
// Activate and discover with the just-issued token before returning.
|
|
const refresh = await refreshCatalog(connection.id, input.actor, {
|
|
enableAllByDefault: shouldFinalizeDefaults,
|
|
skipDefaultProfileSync: shouldFinalizeDefaults,
|
|
credentialHeaders: { Authorization: `Bearer ${token.accessToken}` },
|
|
});
|
|
const [application] = await db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.id, connection.applicationId));
|
|
if (!application)
|
|
throw new Error("OAuth connection application was not found");
|
|
const suggestedDefaults = galleryEntry
|
|
? recommendedDefaultsForApp(
|
|
galleryEntry,
|
|
connectionMethodForConnection(galleryEntry, connection).key,
|
|
)
|
|
: { access: "all_agents" as const, askFirstRiskLevels: [] };
|
|
const finished = shouldFinalizeDefaults
|
|
? await finishOAuthCatalogWithRecommendedDefaults({
|
|
interactionId: stateRow.interactionId,
|
|
connection,
|
|
catalog: refresh.catalog,
|
|
suggestedDefaults,
|
|
actor: input.actor,
|
|
})
|
|
: null;
|
|
return {
|
|
connectionId: refresh.connection.id,
|
|
application: toApplication(application),
|
|
connection: finished?.connection ?? refresh.connection,
|
|
catalog: refresh.catalog,
|
|
actions: groupedActions(refresh.catalog),
|
|
suggestedDefaults,
|
|
auth: null,
|
|
};
|
|
}
|
|
|
|
const organizationActorUserId =
|
|
stateRow.createdByActorType === "user" ? stateRow.createdByActorId : null;
|
|
if (!organizationActorUserId) {
|
|
throw forbidden(
|
|
"Organization OAuth completion requires the user who started sign-in",
|
|
);
|
|
}
|
|
await db.transaction(async (tx) => {
|
|
// Keep callback persistence serialized with membership suspension, role
|
|
// downgrade, and removal. Once this row is locked, authority cannot be
|
|
// revoked between the live check and the shared credential/grant writes.
|
|
const [membership] = await tx
|
|
.select({
|
|
id: companyMemberships.id,
|
|
membershipRole: companyMemberships.membershipRole,
|
|
})
|
|
.from(companyMemberships)
|
|
.where(
|
|
and(
|
|
eq(companyMemberships.companyId, connection.companyId),
|
|
eq(companyMemberships.principalType, "user"),
|
|
eq(companyMemberships.principalId, organizationActorUserId),
|
|
eq(companyMemberships.status, "active"),
|
|
ne(companyMemberships.membershipRole, "viewer"),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.for("update");
|
|
if (!membership) {
|
|
throw forbidden(
|
|
"Your company membership no longer permits connection changes. Ask a company owner to restore non-viewer access before you authorize this connection again.",
|
|
);
|
|
}
|
|
const roleCanManage =
|
|
membership.membershipRole === "owner" ||
|
|
membership.membershipRole === "admin";
|
|
const [explicitManagerGrant] = roleCanManage
|
|
? []
|
|
: await tx
|
|
.select({
|
|
id: principalPermissionGrants.id,
|
|
})
|
|
.from(principalPermissionGrants)
|
|
.where(
|
|
and(
|
|
eq(principalPermissionGrants.companyId, connection.companyId),
|
|
eq(principalPermissionGrants.principalType, "user"),
|
|
eq(
|
|
principalPermissionGrants.principalId,
|
|
organizationActorUserId,
|
|
),
|
|
eq(
|
|
principalPermissionGrants.permissionKey,
|
|
"tools:manage_connections",
|
|
),
|
|
),
|
|
)
|
|
.limit(1)
|
|
.for("update");
|
|
if (!roleCanManage && !explicitManagerGrant) {
|
|
throw forbidden(
|
|
"Only a company owner, admin, or member with connection-manager permission can share credentials with the organization.",
|
|
);
|
|
}
|
|
await tx.select({ id: connectionGrants.id }).from(connectionGrants)
|
|
.where(and(eq(connectionGrants.companyId, connection.companyId), eq(connectionGrants.connectionId, connection.id),
|
|
eq(connectionGrants.kind, "organization"), eq(connectionGrants.isDefault, true)))
|
|
.for("update");
|
|
await tx.select({ id: toolApplications.id }).from(toolApplications)
|
|
.where(and(eq(toolApplications.id, connection.applicationId), eq(toolApplications.companyId, connection.companyId)))
|
|
.for("update");
|
|
await tx.select({ id: toolConnections.id }).from(toolConnections)
|
|
.where(and(eq(toolConnections.id, connection.id), eq(toolConnections.companyId, connection.companyId)))
|
|
.for("update");
|
|
const txSecrets = secretService(tx);
|
|
const txSecretContext = { dbClient: tx, secretClient: txSecrets };
|
|
|
|
const subjectCredentialSecretRefs = connection.credentialSecretRefs;
|
|
const accessRef = await createOrRotateOAuthSecret(
|
|
{
|
|
companyId: connection.companyId,
|
|
connection,
|
|
configPath: "oauth.access_token",
|
|
label: "OAuth access token",
|
|
value: token.accessToken,
|
|
actor: input.actor,
|
|
},
|
|
txSecretContext,
|
|
);
|
|
const nextCredentialSecretRefs = [
|
|
...subjectCredentialSecretRefs.filter(
|
|
(ref) =>
|
|
ref.configPath !== "oauth.access_token" &&
|
|
ref.configPath !== "oauth.refresh_token",
|
|
),
|
|
accessRef,
|
|
];
|
|
if (token.refreshToken) {
|
|
nextCredentialSecretRefs.push(
|
|
await createOrRotateOAuthSecret(
|
|
{
|
|
companyId: connection.companyId,
|
|
connection,
|
|
configPath: "oauth.refresh_token",
|
|
label: "OAuth refresh token",
|
|
value: token.refreshToken,
|
|
actor: input.actor,
|
|
},
|
|
txSecretContext,
|
|
),
|
|
);
|
|
} else {
|
|
const existingRefreshRef = subjectCredentialSecretRefs.find(
|
|
(ref) => ref.configPath === "oauth.refresh_token",
|
|
);
|
|
if (existingRefreshRef)
|
|
nextCredentialSecretRefs.push(existingRefreshRef);
|
|
}
|
|
const organizationGrantedScopes = resolveGrantedOauthScopes({
|
|
tokenScope: token.scope,
|
|
requestedScopes: authorizedScopes,
|
|
});
|
|
const nextConfig = {
|
|
...connection.config,
|
|
oauth: {
|
|
...withoutOAuthRefreshLease(oauthConfig(connection)),
|
|
provider: endpoints.provider,
|
|
authorizationUrl: endpoints.authorizationUrl,
|
|
tokenUrl: endpoints.tokenUrl,
|
|
metadataUrl: endpoints.metadataUrl ?? null,
|
|
scopes: authorizedScopes,
|
|
clientIdEnv: client.clientIdEnv,
|
|
clientSecretEnv: client.clientSecret ? client.clientSecretEnv : null,
|
|
credentialScope: credentialScope(connection, input.actor),
|
|
// Keep the issuer and resource this grant was minted against so refresh,
|
|
// reconnect, revoke and diagnostics resolve the same authorization server
|
|
// instead of re-discovering one from a possibly-changed endpoint.
|
|
issuer: endpoints.issuer ?? oauthConfig(connection).issuer ?? null,
|
|
resource:
|
|
endpoints.resource ?? oauthConfig(connection).resource ?? null,
|
|
expiresAt,
|
|
scope: token.scope,
|
|
tokenType: token.tokenType,
|
|
connectedAt: connectedAt.toISOString(),
|
|
},
|
|
providerMetadata: {
|
|
...asRecord(connection.config.providerMetadata),
|
|
oauth: {
|
|
expiresAt,
|
|
scope: token.scope,
|
|
scopeSource: organizationGrantedScopes.scopeSource,
|
|
unrequestedScopes: organizationGrantedScopes.unrequestedScopes,
|
|
tokenType: token.tokenType,
|
|
},
|
|
},
|
|
};
|
|
const [updatedConnection] = await tx
|
|
.update(toolConnections)
|
|
.set({
|
|
status: "active",
|
|
enabled: true,
|
|
authKind: "oauth",
|
|
config: nextConfig,
|
|
transportConfig: nextConfig,
|
|
credentialSecretRefs: nextCredentialSecretRefs,
|
|
credentialRefs: [
|
|
...connection.credentialRefs.filter(
|
|
(ref) => ref.name !== "oauth.access_token",
|
|
),
|
|
{
|
|
name: "oauth.access_token",
|
|
secretId: accessRef.secretId,
|
|
version: "latest" as const,
|
|
placement: "header" as const,
|
|
key: "Authorization",
|
|
prefix: "Bearer ",
|
|
},
|
|
],
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(toolConnections.id, connection.id))
|
|
.returning();
|
|
connection = updatedConnection;
|
|
await tx
|
|
.update(toolApplications)
|
|
.set({ status: "active", updatedAt: new Date() })
|
|
.where(eq(toolApplications.id, connection.applicationId));
|
|
// The organization grant is created before OAuth has any secrets to attach.
|
|
// Synchronize it after every successful callback/rotation so all real tool
|
|
// execution paths receive the credentials that setup and catalog discovery
|
|
// just proved.
|
|
await ensureDefaultOrganizationGrant(connection, tx, undefined, {
|
|
scopes: organizationGrantedScopes.scopes,
|
|
scopeSource: organizationGrantedScopes.scopeSource,
|
|
unrequestedScopes: organizationGrantedScopes.unrequestedScopes,
|
|
requestedScopes: authorizedScopes,
|
|
});
|
|
await syncCredentialBindings(connection, [], tx);
|
|
});
|
|
emitConnectionUpdated(
|
|
connection,
|
|
preCallbackLifecycle,
|
|
"oauth_callback",
|
|
);
|
|
|
|
await checkConnectionHealth(connection.id, input.actor);
|
|
const refresh = await refreshCatalog(connection.id, input.actor, {
|
|
enableAllByDefault: shouldFinalizeDefaults,
|
|
skipDefaultProfileSync: shouldFinalizeDefaults,
|
|
});
|
|
const [application] = await db
|
|
.select()
|
|
.from(toolApplications)
|
|
.where(eq(toolApplications.id, connection.applicationId));
|
|
const suggestedDefaults = galleryEntry
|
|
? recommendedDefaultsForApp(
|
|
galleryEntry,
|
|
connectionMethodForConnection(galleryEntry, connection).key,
|
|
)
|
|
: {
|
|
access: "all_agents" as const,
|
|
askFirstRiskLevels: [],
|
|
};
|
|
const finished = shouldFinalizeDefaults
|
|
? await finishOAuthCatalogWithRecommendedDefaults({
|
|
interactionId: stateRow.interactionId,
|
|
connection,
|
|
catalog: refresh.catalog,
|
|
suggestedDefaults,
|
|
actor: input.actor,
|
|
})
|
|
: null;
|
|
return {
|
|
connectionId: refresh.connection.id,
|
|
application: toApplication(application),
|
|
connection: finished?.connection ?? refresh.connection,
|
|
catalog: refresh.catalog,
|
|
actions: groupedActions(refresh.catalog),
|
|
suggestedDefaults,
|
|
auth: null,
|
|
};
|
|
}
|
|
return completeOAuthCallback;
|
|
}
|