Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions packages/core/sdk/src/http-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ export const OAuth2Flow = Schema.Literals(["authorizationCode", "clientCredentia
export type OAuth2Flow = typeof OAuth2Flow.Type;
export type OAuth2FlowType = OAuth2Flow;

export const OAuth2IdentityScopes = Schema.Union([
Schema.Literal("auto"),
Schema.Literal(false),
Schema.Array(Schema.String),
]);
export type OAuth2IdentityScopes = typeof OAuth2IdentityScopes.Type;
export type OAuth2IdentityScopesType = OAuth2IdentityScopes;

export const OAuth2SourceConfig = Schema.Struct({
kind: Schema.Literal("oauth2"),
securitySchemeName: Schema.String,
Expand All @@ -60,6 +68,10 @@ export const OAuth2SourceConfig = Schema.Struct({
clientSecretSlot: Schema.NullOr(Schema.String),
connectionSlot: Schema.String,
scopes: Schema.Array(Schema.String),
identityScopes: OAuth2IdentityScopes.pipe(
Schema.optional,
Schema.withDecodingDefault(Effect.succeed("auto" as const)),
),
}).annotate({ identifier: "OAuth2SourceConfig" });
export type OAuth2SourceConfig = typeof OAuth2SourceConfig.Type;
export type OAuth2SourceConfigType = OAuth2SourceConfig;
Expand Down
82 changes: 76 additions & 6 deletions packages/core/sdk/src/oauth-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
OAuthSessionNotFoundError,
OAuthStartError,
type OAuthAuthorizationCodeStrategy,
type OAuthAuthorizationCodeExistingClientStrategy,
type OAuthClientCredentialsStrategy,
type OAuthCompleteInput,
type OAuthCompleteResult,
Expand Down Expand Up @@ -141,6 +142,8 @@ const AuthorizationCodeSessionPayload = Schema.Struct({
Schema.withDecodingDefaultType(Effect.succeed(null)),
),
scopes: Schema.Array(Schema.String),
authorizationScopes: Schema.optional(Schema.Array(Schema.String)),
storedScope: Schema.optional(Schema.String),
scopeSeparator: Schema.optional(Schema.String),
clientAuth: Schema.Literals(["body", "basic"]),
});
Expand Down Expand Up @@ -587,6 +590,10 @@ export const makeOAuth2Service = (
const startAuthorizationCode = (
input: OAuthStartInput,
strategy: OAuthAuthorizationCodeStrategy,
options?: {
readonly authorizationScopes?: readonly string[];
readonly storedScope?: string;
},
): Effect.Effect<OAuthStartResult, OAuthStartError | StorageFailure> =>
Effect.gen(function* () {
const clientIdRef = yield* secretsGetResolvedAtScope({
Expand All @@ -609,12 +616,19 @@ export const makeOAuth2Service = (
const sessionId = scopedSessionId(input.tokenScope, newSessionId());
const codeVerifier = createPkceCodeVerifier();
const codeChallenge = yield* Effect.promise(() => createPkceCodeChallenge(codeVerifier));
const authorizationScopes =
options?.authorizationScopes ?? strategy.authorizationScopes ?? strategy.scopes;
const storedScope =
options?.storedScope ??
(strategy.authorizationScopes
? strategy.scopes.join(strategy.scopeSeparator ?? " ")
: undefined);

const authorizationUrl = buildAuthorizationUrl({
authorizationUrl: strategy.authorizationEndpoint,
clientId: clientIdRef.value,
redirectUrl: input.redirectUrl,
scopes: strategy.scopes,
scopes: authorizationScopes,
state: sessionId,
codeChallenge,
scopeSeparator: strategy.scopeSeparator,
Expand All @@ -639,6 +653,8 @@ export const makeOAuth2Service = (
}))?.scopeId ?? null)
: null,
scopes: [...strategy.scopes],
authorizationScopes: [...authorizationScopes],
storedScope,
scopeSeparator: strategy.scopeSeparator,
clientAuth: strategy.clientAuth ?? "body",
};
Expand All @@ -657,6 +673,53 @@ export const makeOAuth2Service = (
};
});

const startAuthorizationCodeWithExistingClient = (
input: OAuthStartInput,
strategy: OAuthAuthorizationCodeExistingClientStrategy,
): Effect.Effect<OAuthStartResult, OAuthStartError | StorageFailure> =>
Effect.gen(function* () {
const existing = yield* connectionsGet(input.connectionId);
if (!existing || existing.scopeId !== input.tokenScope) {
return yield* new OAuthStartError({
message: "Existing OAuth connection was not found at the selected scope",
});
}
const state = existing.providerState
? Option.getOrNull(decodeProviderStateOption(coerceJson(existing.providerState)))
: null;
if (!state || state.kind !== "authorization-code") {
return yield* new OAuthStartError({
message: "Existing OAuth connection cannot be reused for authorization-code sign-in",
});
}

const scopeSeparator = strategy.scopeSeparator ?? state.scopeSeparator;

return yield* startAuthorizationCode(
input,
{
kind: "authorization-code",
authorizationEndpoint: strategy.authorizationEndpoint,
tokenEndpoint: strategy.tokenEndpoint ?? state.tokenEndpoint,
issuerUrl: strategy.issuerUrl ?? state.issuerUrl,
clientIdSecretId: state.clientIdSecretId,
clientIdSecretScopeId: state.clientIdSecretScopeId,
clientSecretSecretId: state.clientSecretSecretId,
clientSecretSecretScopeId: state.clientSecretSecretScopeId,
scopes: [...strategy.scopes],
scopeSeparator,
extraAuthorizationParams: strategy.extraAuthorizationParams,
clientAuth: state.clientAuth,
},
strategy.authorizationScopes
? {
authorizationScopes: strategy.authorizationScopes,
storedScope: strategy.scopes.join(scopeSeparator ?? " "),
}
: undefined,
);
});

const startClientCredentials = (
input: OAuthStartInput,
strategy: OAuthClientCredentialsStrategy,
Expand Down Expand Up @@ -765,6 +828,9 @@ export const makeOAuth2Service = (
Match.when({ kind: "authorization-code" }, (strategy) =>
startAuthorizationCode(input, strategy),
),
Match.when({ kind: "authorization-code-existing-client" }, (strategy) =>
startAuthorizationCodeWithExistingClient(input, strategy),
),
Match.when({ kind: "client-credentials" }, (strategy) =>
startClientCredentials(input, strategy),
),
Expand Down Expand Up @@ -875,6 +941,10 @@ export const makeOAuth2Service = (
typeof exchangeResult.tokens.expires_in === "number"
? now() + exchangeResult.tokens.expires_in * 1000
: null;
const effectiveOAuthScope =
payload.kind === "authorization-code" && payload.storedScope
? payload.storedScope
: (exchangeResult.tokens.scope ?? null);

const dynamicClientSecretSecretId = yield* (() => {
if (payload.kind !== "dynamic-dcr") return Effect.succeed(null);
Expand Down Expand Up @@ -938,7 +1008,7 @@ export const makeOAuth2Service = (
: "body",
clientSecretSecretScopeId: dynamicClientSecretSecretId ? tokenScope : null,
scopes: [...payload.scopes],
scope: exchangeResult.tokens.scope ?? null,
scope: effectiveOAuthScope,
resource: payload.resource,
}
: {
Expand All @@ -950,9 +1020,9 @@ export const makeOAuth2Service = (
clientSecretSecretId: payload.clientSecretSecretId,
clientSecretSecretScopeId: payload.clientSecretSecretScopeId,
clientAuth: payload.clientAuth,
scopes: [...payload.scopes],
scopes: [...(payload.authorizationScopes ?? payload.scopes)],
scopeSeparator: payload.scopeSeparator,
scope: exchangeResult.tokens.scope ?? null,
scope: effectiveOAuthScope,
};

yield* deps
Expand All @@ -977,7 +1047,7 @@ export const makeOAuth2Service = (
})
: null,
expiresAt: connectionExpiresAt,
oauthScope: exchangeResult.tokens.scope ?? null,
oauthScope: effectiveOAuthScope,
providerState: encodeProviderStateSync(providerState) as Record<string, unknown>,
}),
)
Expand Down Expand Up @@ -1009,7 +1079,7 @@ export const makeOAuth2Service = (
return {
connectionId,
expiresAt: connectionExpiresAt,
scope: exchangeResult.tokens.scope ?? null,
scope: effectiveOAuthScope,
};
});

Expand Down
24 changes: 24 additions & 0 deletions packages/core/sdk/src/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,11 @@ export const OAuthAuthorizationCodeStrategy = Schema.Struct({
* PKCE without a confidential secret. */
clientSecretSecretId: Schema.NullOr(Schema.String),
clientSecretSecretScopeId: Schema.optional(Schema.NullOr(Schema.String)),
/** Final scope set Executor should remember for this connection. */
scopes: Schema.Array(Schema.String),
/** Optional smaller scope set to send to the authorization server. This is
* useful when one provider scope covers many source-level operation scopes. */
authorizationScopes: Schema.optional(Schema.Array(Schema.String)),
/** Separator between scopes. RFC 6749 says space; some providers
* (GitHub classic) use comma. */
scopeSeparator: Schema.optional(Schema.String),
Expand All @@ -77,6 +81,25 @@ export const OAuthAuthorizationCodeStrategy = Schema.Struct({
});
export type OAuthAuthorizationCodeStrategy = typeof OAuthAuthorizationCodeStrategy.Type;

/** Authorization-code flow that reuses the client credentials recorded on
* an existing OAuth connection. Used for incremental authorization where
* the user is granting more scopes to the same account/provider. */
export const OAuthAuthorizationCodeExistingClientStrategy = Schema.Struct({
kind: Schema.Literal("authorization-code-existing-client"),
authorizationEndpoint: Schema.String,
tokenEndpoint: Schema.optional(Schema.String),
issuerUrl: Schema.optional(Schema.NullOr(Schema.String)),
/** Final scope set Executor should remember for this connection. */
scopes: Schema.Array(Schema.String),
/** Optional smaller scope set to send to the authorization server. This is
* useful when one provider scope covers many source-level operation scopes. */
authorizationScopes: Schema.optional(Schema.Array(Schema.String)),
scopeSeparator: Schema.optional(Schema.String),
extraAuthorizationParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
});
export type OAuthAuthorizationCodeExistingClientStrategy =
typeof OAuthAuthorizationCodeExistingClientStrategy.Type;

/** RFC 6749 §4.4 client credentials — no user redirect, no PKCE. Used
* for server-to-server integrations where the plugin has both
* `client_id` and `client_secret` and the server will mint tokens
Expand All @@ -100,6 +123,7 @@ export type OAuthClientCredentialsStrategy = typeof OAuthClientCredentialsStrate
export const OAuthStrategy = Schema.Union([
OAuthDynamicDcrStrategy,
OAuthAuthorizationCodeStrategy,
OAuthAuthorizationCodeExistingClientStrategy,
OAuthClientCredentialsStrategy,
]);
export type OAuthStrategy = typeof OAuthStrategy.Type;
Expand Down
91 changes: 90 additions & 1 deletion packages/core/sdk/src/testing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,14 @@ layer(TestLayer, { timeout: "15 seconds" })("testing fixtures", (it) => {
tokenEndpoint: oauth.tokenEndpoint,
clientIdSecretId: "oauth-client-id",
clientSecretSecretId: "oauth-client-secret",
scopes: ["read"],
scopes: ["read", "write"],
authorizationScopes: ["read"],
},
});

expect(started.authorizationUrl).not.toBeNull();
const authorizationUrl = started.authorizationUrl ?? "";
expect(new URL(authorizationUrl).searchParams.get("scope")).toBe("read");
const callback = yield* oauth.completeAuthorizationCodeFlow({ authorizationUrl });
const completed = yield* workspace.executor.oauth.complete({
state: callback.state,
Expand All @@ -75,6 +77,93 @@ layer(TestLayer, { timeout: "15 seconds" })("testing fixtures", (it) => {
});

expect(completed.connectionId).toBe("test-oauth-authorization-code");
expect(completed.scope).toBe("read write");
const accessToken = yield* workspace.executor.connections.accessToken(completed.connectionId);
expect(yield* oauth.acceptsAccessToken(accessToken)).toBe(true);
}),
);

it.effect("authorization-code OAuth can reuse an existing connection client", () =>
Effect.gen(function* () {
const workspace = yield* TestWorkspace.current<typeof plugins>();
const oauth = yield* OAuthTestServer;
const scope = workspace.scopes[0]!;

yield* workspace.executor.secrets.set(
SetSecretInput.make({
id: SecretId.make("oauth-client-id"),
scope: scope.id,
name: "OAuth Client ID",
value: "test-client",
}),
);
yield* workspace.executor.secrets.set(
SetSecretInput.make({
id: SecretId.make("oauth-client-secret"),
scope: scope.id,
name: "OAuth Client Secret",
value: "test-secret",
}),
);

const started = yield* workspace.executor.oauth.start({
endpoint: oauth.resourceUrl,
connectionId: "test-oauth-existing-client",
tokenScope: String(scope.id),
redirectUrl: "http://127.0.0.1/callback",
pluginId: "test",
identityLabel: "OAuth Test",
strategy: {
kind: "authorization-code",
authorizationEndpoint: oauth.authorizationEndpoint,
tokenEndpoint: oauth.tokenEndpoint,
clientIdSecretId: "oauth-client-id",
clientSecretSecretId: "oauth-client-secret",
scopes: ["gmail.read"],
},
});
const callback = yield* oauth.completeAuthorizationCodeFlow({
authorizationUrl: started.authorizationUrl ?? "",
});
yield* workspace.executor.oauth.complete({
state: callback.state,
code: callback.code,
tokenScope: String(scope.id),
});

const incremental = yield* workspace.executor.oauth.start({
endpoint: oauth.resourceUrl,
connectionId: "test-oauth-existing-client",
tokenScope: String(scope.id),
redirectUrl: "http://127.0.0.1/callback",
pluginId: "test",
identityLabel: "OAuth Test",
strategy: {
kind: "authorization-code-existing-client",
authorizationEndpoint: oauth.authorizationEndpoint,
tokenEndpoint: oauth.tokenEndpoint,
scopes: ["gmail.read", "calendar.read"],
authorizationScopes: ["calendar.read"],
extraAuthorizationParams: { include_granted_scopes: "true" },
},
});

const authorizationUrl = new URL(incremental.authorizationUrl ?? "");
expect(authorizationUrl.searchParams.get("client_id")).toBe("test-client");
expect(authorizationUrl.searchParams.get("scope")).toBe("calendar.read");
expect(authorizationUrl.searchParams.get("include_granted_scopes")).toBe("true");

const incrementalCallback = yield* oauth.completeAuthorizationCodeFlow({
authorizationUrl: incremental.authorizationUrl ?? "",
});
const completed = yield* workspace.executor.oauth.complete({
state: incrementalCallback.state,
code: incrementalCallback.code,
tokenScope: String(scope.id),
});

expect(completed.connectionId).toBe("test-oauth-existing-client");
expect(completed.scope).toBe("gmail.read calendar.read");
const accessToken = yield* workspace.executor.connections.accessToken(completed.connectionId);
expect(yield* oauth.acceptsAccessToken(accessToken)).toBe(true);
}),
Expand Down
2 changes: 2 additions & 0 deletions packages/core/sdk/src/testing/oauth-test-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export interface OAuthTestServerRequest {
readonly path: string;
readonly headers: Readonly<Record<string, string>>;
readonly body: string;
readonly query: Readonly<Record<string, string>>;
}

export interface OAuthTestServerOptions {
Expand Down Expand Up @@ -435,6 +436,7 @@ export const serveOAuthTestServer = (
path: requestUrl.pathname,
headers,
body,
query: Object.fromEntries(requestUrl.searchParams.entries()),
},
]);

Expand Down
1 change: 1 addition & 0 deletions packages/plugins/openapi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"@executor-js/config": "workspace:*",
"@executor-js/sdk": "workspace:*",
"effect": "catalog:",
"lucide-react": "^1.7.0",
"openapi-types": "^12.1.3",
"yaml": "^2.7.1"
},
Expand Down
Loading
Loading