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
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -393,6 +393,9 @@ export type {
|
||||
PluginDatabaseDeclaration,
|
||||
PluginApiRouteCompanyResolution,
|
||||
PluginApiRouteDeclaration,
|
||||
OAuthProviderContribution,
|
||||
OAuthProviderContributionConfig,
|
||||
OAuthProviderShape,
|
||||
PaperclipPluginManifestV1,
|
||||
PluginRecord,
|
||||
PluginDatabaseNamespaceRecord,
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -283,6 +283,7 @@ export {
|
||||
export {
|
||||
envBindingPlainSchema,
|
||||
envBindingSecretRefSchema,
|
||||
envBindingOAuthTokenSchema,
|
||||
envBindingSchema,
|
||||
envConfigSchema,
|
||||
createSecretSchema,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user