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:
DottaandPaperclip committed 2026-05-12 09:47:09 -05:00
1 parent 0808b388ee
commit 382cb3bede
94 files changed
+14984 -16

No files matched your search

+4
View File
@@ -609,6 +609,9 @@ export type {
PluginDatabaseDeclaration,
PluginApiRouteCompanyResolution,
PluginApiRouteDeclaration,
OAuthProviderContribution,
OAuthProviderContributionConfig,
OAuthProviderShape,
PaperclipPluginManifestV1,
PluginRecord,
PluginDatabaseNamespaceRecord,
@@ -863,6 +866,7 @@ export {
type AddApprovalComment,
envBindingPlainSchema,
envBindingSecretRefSchema,
envBindingOAuthTokenSchema,
envBindingSchema,
envConfigSchema,
createSecretSchema,
@@ -0,0 +1,74 @@
import { describe, it, expect } from "vitest";
import type {
OAuthProviderContribution,
OAuthProviderContributionConfig,
OAuthProviderShape,
PaperclipPluginManifestV1,
} from "../plugin.js";
describe("plugin manifest oauth extension", () => {
it("OAuthProviderContribution holds a config and an optional shape", () => {
const cfg: OAuthProviderContributionConfig = {
id: "x",
displayName: "X",
clientCredentials: { clientIdEnv: "X_ID", clientSecretEnv: "X_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: "name",
refresh: { supported: false },
};
const shape: OAuthProviderShape = {
parseTokenResponse: () => ({ accessToken: "a" }),
parseAccountInfo: () => ({ accountId: "a" }),
};
const c: OAuthProviderContribution = { config: cfg, shape };
expect(c.config.id).toBe("x");
expect(c.shape).toBeDefined();
const cWithoutShape: OAuthProviderContribution = { config: cfg };
expect(cWithoutShape.shape).toBeUndefined();
});
it("manifest.kind allows oauth_provider and composite alongside sandbox_provider", () => {
type Kind = NonNullable<PaperclipPluginManifestV1["kind"]>;
const a: Kind = "oauth_provider";
const b: Kind = "composite";
const c: Kind = "sandbox_provider";
expect([a, b, c]).toEqual(["oauth_provider", "composite", "sandbox_provider"]);
});
it("manifest accepts an optional oauthProviders array (compile check)", () => {
type WithOAuth = Pick<PaperclipPluginManifestV1, "oauthProviders">;
const m: WithOAuth = {
oauthProviders: [
{
config: {
id: "x",
displayName: "X",
clientCredentials: { clientIdEnv: "X_ID", clientSecretEnv: "X_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: "name",
refresh: { supported: false },
},
},
],
};
expect(m.oauthProviders?.length).toBe(1);
});
});
@@ -0,0 +1,16 @@
import { describe, it, expect } from "vitest";
import type { EnvBinding, EnvOAuthTokenBinding } from "../secrets.js";
describe("EnvBinding union", () => {
it("accepts oauth_token binding shape", () => {
const binding: EnvOAuthTokenBinding = {
type: "oauth_token",
connectionId: "11111111-1111-1111-1111-111111111111",
field: "access",
};
const asUnion: EnvBinding = binding;
expect(typeof asUnion === "string" ? asUnion : asUnion.type).toBe(
"oauth_token",
);
});
});
+3
View File
@@ -393,6 +393,9 @@ export type {
PluginDatabaseDeclaration,
PluginApiRouteCompanyResolution,
PluginApiRouteDeclaration,
OAuthProviderContribution,
OAuthProviderContributionConfig,
OAuthProviderShape,
PaperclipPluginManifestV1,
PluginRecord,
PluginDatabaseNamespaceRecord,
+70
View File
@@ -483,6 +483,58 @@ export interface PluginApiRouteDeclaration {
// Plugin Manifest V1
// ---------------------------------------------------------------------------
/**
* OAuth provider contribution from a plugin manifest.
*
* Mirrors the server-side `OAuthProviderConfig` Zod schema. Duplicated here
* (rather than imported from the server package) so the shared package stays
* free of server dependencies. The server validates each contribution via the
* Zod schema at registration time, so this interface is the structural
* contract plugin authors program against.
*/
export interface OAuthProviderContributionConfig {
id: string;
displayName: string;
iconUrl?: string;
docUrl?: string;
clientCredentials: { clientIdEnv: string; clientSecretEnv: string };
endpoints: { authorize: string; token: string; revoke?: string; accountInfo: string };
scopes: { default: string[]; offered: string[] };
pkce: "required" | "optional" | "unsupported";
authMethod: "post" | "basic";
responseFormat: "json" | "form";
accountIdField: string;
accountLabelField: string;
refresh:
| { supported: false }
| { supported: true; rotatesRefreshToken: boolean; expirySeconds?: number };
shape?: string;
}
/**
* Optional response-shape overrides a plugin can supply alongside an OAuth
* provider config. When omitted, the server falls back to the default
* RFC-6749 parser configured by `accountIdField` / `accountLabelField`.
*/
export interface OAuthProviderShape {
parseTokenResponse?: (raw: unknown) => {
accessToken: string;
refreshToken?: string;
expiresInSeconds?: number;
scope?: string[];
};
parseAccountInfo?: (raw: unknown) => { accountId: string; accountLabel?: string };
}
/**
* Wrapper a plugin emits per OAuth provider it contributes. Plugins may
* publish multiple contributions in a single manifest.
*/
export interface OAuthProviderContribution {
config: OAuthProviderContributionConfig;
shape?: OAuthProviderShape;
}
/**
* The manifest shape every plugin package must export.
* See PLUGIN_SPEC.md §10.1 for the normative definition.
@@ -552,6 +604,24 @@ export interface PaperclipPluginManifestV1 {
launchers?: PluginLauncherDeclaration[];
/** UI bundle declarations. Requires `entrypoints.ui` when populated. */
ui?: PluginUiDeclaration;
/**
* Optional plugin classification used by host-side dispatch when the
* manifest contributes to one of the typed contribution surfaces. The
* absence of this field denotes a generic plugin (the historical default).
*
* - `sandbox_provider` — plugin contributes a core-driver sandbox provider
* keyed by `environmentDrivers[].driverKey`.
* - `oauth_provider` — plugin contributes one or more OAuth providers via
* {@link oauthProviders}.
* - `composite` — plugin contributes a mix of contribution kinds.
*/
kind?: "sandbox_provider" | "oauth_provider" | "composite";
/**
* OAuth provider contributions. Each entry pairs a provider config with an
* optional response-shape override. The host validates each entry via the
* server-side Zod schema before registering it into the provider registry.
*/
oauthProviders?: OAuthProviderContribution[];
}
// ---------------------------------------------------------------------------
+11 -1
View File
@@ -33,8 +33,18 @@ export interface EnvSecretRefBinding {
version?: SecretVersionSelector;
}
export interface EnvOAuthTokenBinding {
type: "oauth_token";
connectionId: string;
field: "access"; // future: "refresh" | "account_id"
}
// Backward-compatible: legacy plaintext string values are still accepted.
export type EnvBinding = string | EnvPlainBinding | EnvSecretRefBinding;
export type EnvBinding =
| string
| EnvPlainBinding
| EnvSecretRefBinding
| EnvOAuthTokenBinding;
export type AgentEnvConfig = Record<string, EnvBinding>;
+1
View File
@@ -283,6 +283,7 @@ export {
export {
envBindingPlainSchema,
envBindingSecretRefSchema,
envBindingOAuthTokenSchema,
envBindingSchema,
envConfigSchema,
createSecretSchema,
+7
View File
@@ -18,11 +18,18 @@ export const envBindingSecretRefSchema = z.object({
version: z.union([z.literal("latest"), z.number().int().positive()]).optional(),
});
export const envBindingOAuthTokenSchema = z.object({
type: z.literal("oauth_token"),
connectionId: z.string().uuid(),
field: z.literal("access").default("access"),
});
// Backward-compatible union that accepts legacy inline values.
export const envBindingSchema = z.union([
z.string(),
envBindingPlainSchema,
envBindingSecretRefSchema,
envBindingOAuthTokenSchema,
]);
export const envConfigSchema = z.record(envBindingSchema);