import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";import { describe, expect, it } from "@effect/vitest";import { Context, Effect, Layer, Predicate, type Scope } from "effect";import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import { AuthTemplateSlug, ConnectionName, IntegrationSlug, OAuthClientSlug, ToolName,} from "./ids";import type { AuthMethodDescriptor } from "./integration";import { definePlugin, type IntegrationRecord } from "./plugin";import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";import { serveOAuthTestServer } from "./testing/oauth-test-server"; // Integration-driven scopes: at connect, `oauth.start` requests EXACTLY the// integration's DECLARED oauth scopes. The OAuth app carries no scope set of its// own, so there is no union and no over-request — the integration is the sole// source of what to request. (Replaces the earlier declared∪client union model.) const INTEG = IntegrationSlug.make("acme");const TEMPLATE = AuthTemplateSlug.make("oauth");const CLIENT = OAuthClientSlug.make("acme-app"); // The integration's DECLARED oauth scopes — the sole source of the request.const DECLARED_SCOPES = ["calendar", "gmail", "drive", "sheets"] as const; /** A plugin whose integration config carries declared oauth scopes, projected * into an oauth `AuthMethodDescriptor` via `describeAuthMethods` — exactly the * shape `resolveDeclaredOAuthScopes` reads. `scopes: null` ⇒ no declared oauth * scopes (the MCP/no-template-scopes case). */const makeScopePlugin = (config: { readonly scopes: readonly string[] | null }) => definePlugin(() => ({ id: "acme" as const, storage: () => ({}), resolveTools: () => Effect.succeed({ tools: [{ name: ToolName.make("whoami"), description: "whoami" }], }), invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }), describeAuthMethods: (record: IntegrationRecord): readonly AuthMethodDescriptor[] => { const cfg = record.config as { readonly scopes?: readonly string[] | null } | null; const scopes = cfg?.scopes; if (scopes == null) { // No declared oauth scopes — the integration declares an oauth method // with no template scopes (MCP/DCR-style). Declared scopes resolve to []. return [{ id: "oauth", label: "OAuth2", kind: "oauth", template: String(TEMPLATE) }]; } return [ { id: "oauth", label: "OAuth2", kind: "oauth", template: String(TEMPLATE), oauth: { scopes }, }, ]; }, extension: (ctx) => ({ seed: () => ctx.core.integrations.register({ slug: INTEG, description: "Acme", config: { scopes: config.scopes }, }), }), }))(); /** Parse the `scope` query param from an authorize URL into an ordered list. */const scopesFromAuthorizeUrl = (authorizationUrl: string): readonly string[] => { const raw = new URL(authorizationUrl).searchParams.get("scope"); return raw == null || raw.length === 0 ? [] : raw.split(" ");}; describe("oauth.start integration-driven scopes", () => { it.effect( "(a) requests exactly the integration's declared scopes (the app contributes none)", () => Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: [...DECLARED_SCOPES] }); const plugins = [ memoryCredentialsPlugin(), makeScopePlugin({ scopes: DECLARED_SCOPES }), ] as const; const { executor } = yield* makeTestWorkspaceHarness({ plugins }); yield* executor.acme.seed(); // The app is pure identity — no scope set. yield* executor.oauth.createClient({ owner: "org", slug: CLIENT, authorizationUrl: server.authorizationEndpoint, tokenUrl: server.tokenEndpoint, grant: "authorization_code", clientId: "test-client", clientSecret: "test-secret", }); const started = yield* executor.oauth.start({ owner: "org", client: CLIENT, clientOwner: "org", name: ConnectionName.make("main"), integration: INTEG, template: TEMPLATE, }); expect(started.status).toBe("redirect"); if (started.status !== "redirect") return; // The authorize URL requests exactly the integration's declared scopes. expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual([...DECLARED_SCOPES]); }), ), ); it.effect("(b) when the integration declares no oauth scopes, start requests none", () => Effect.scoped( Effect.gen(function* () { const server = yield* serveOAuthTestServer({ scopes: [] }); // `scopes: null` ⇒ the integration declares an oauth method with no // template scopes ⇒ declared scopes resolve to [] ⇒ no scope is requested. const plugins = [memoryCredentialsPlugin(), makeScopePlugin({ scopes: null })] as const; const { executor } = yield* makeTestWorkspaceHarness({ plugins }); yield* executor.acme.seed(); yield* executor.oauth.createClient({ owner: "org", slug: CLIENT, authorizationUrl: server.authorizationEndpoint, tokenUrl: server.tokenEndpoint, grant: "authorization_code", clientId: "test-client", clientSecret: "test-secret", }); const started = yield* executor.oauth.start({ owner: "org", client: CLIENT, clientOwner: "org", name: ConnectionName.make("main"), integration: INTEG, template: TEMPLATE, }); expect(started.status).toBe("redirect"); if (started.status !== "redirect") return; expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual([]); }), ), );}); // -----------------------------------------------------------------------------// (c) The recorded `oauth_scope` reflects the requested (declared) scopes when// the AS omits `scope`. A minimal inline token endpoint handles the// client_credentials grant and deliberately OMITS `scope` from its response,// forcing the recorded-scope fallback (`token.scope ?? requested.join(" ")`).// ----------------------------------------------------------------------------- /** A minimal token endpoint serving the client_credentials grant and OMITTING * `scope` from its response, forcing the recorded-scope fallback. Returns a * scoped effect (mirrors `serveOAuthTestServer`); `yield*` it inside an already * `Effect.scoped` test. */const serveScopelessTokenServer = (): Effect.Effect< { readonly tokenEndpoint: string }, unknown, Scope.Scope> => Effect.gen(function* () { const context = yield* Layer.build( Layer.fresh( HttpServer.serve( HttpServerRequest.HttpServerRequest.asEffect().pipe( Effect.map((request: HttpServerRequest.HttpServerRequest) => { if (request.url.startsWith("/token") && request.method === "POST") { // A Bearer access token WITHOUT a `scope` field — the AS omits // it, so the recorded scope falls back to the requested set. return HttpServerResponse.jsonUnsafe( { access_token: `at_${Math.random().toString(36).slice(2)}`, token_type: "Bearer", expires_in: 3600, }, { status: 200, headers: { "cache-control": "no-store" } }, ); } return HttpServerResponse.jsonUnsafe({ error: "not_found" }, { status: 404 }); }), ), ).pipe(Layer.provideMerge(NodeHttpServer.layerTest)), ), ); const server = Context.get(context, HttpServer.HttpServer); const address = server.address; if (!Predicate.isTagged(address, "TcpAddress")) { return yield* Effect.die(`Expected a TcpAddress, got ${JSON.stringify(address)}`); } return { tokenEndpoint: `http://127.0.0.1:${address.port}/token` }; }); describe("oauth.start recorded scope fallback", () => { it.effect( "(c) records the requested (declared) scopes when the authorization server omits scope", () => Effect.scoped( Effect.gen(function* () { const tokenServer = yield* serveScopelessTokenServer(); const plugins = [ memoryCredentialsPlugin(), makeScopePlugin({ scopes: DECLARED_SCOPES }), ] as const; const { executor, config } = yield* makeTestWorkspaceHarness({ plugins }); yield* executor.acme.seed(); // A client_credentials client so `start` mints inline (no redirect), // exchanging against the scopeless token endpoint. yield* executor.oauth.createClient({ owner: "org", slug: CLIENT, authorizationUrl: "http://127.0.0.1/authorize", tokenUrl: tokenServer.tokenEndpoint, grant: "client_credentials", clientId: "test-client", clientSecret: "test-secret", }); const started = yield* executor.oauth.start({ owner: "org", client: CLIENT, clientOwner: "org", name: ConnectionName.make("cc"), integration: INTEG, template: TEMPLATE, }); expect(started.status).toBe("connected"); // The connection's recorded `oauth_scope` is the requested (declared) set // since the AS omitted `scope`. const row = yield* Effect.promise(() => config.db.findFirst("connection", { where: (b) => b("name", "=", "cc"), }), ); expect(row?.oauth_scope).toBe("calendar gmail drive sheets"); }), ), );});
import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
import { describe, expect, it } from "@effect/vitest";
import { Context, Effect, Layer, Predicate, type Scope } from "effect";
import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
import {
AuthTemplateSlug,
ConnectionName,
IntegrationSlug,
OAuthClientSlug,
ToolName,
} from "./ids";
import type { AuthMethodDescriptor } from "./integration";
import { definePlugin, type IntegrationRecord } from "./plugin";
import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
import { serveOAuthTestServer } from "./testing/oauth-test-server";
// Integration-driven scopes: at connect, `oauth.start` requests EXACTLY the
// integration's DECLARED oauth scopes. The OAuth app carries no scope set of its
// own, so there is no union and no over-request — the integration is the sole
// source of what to request. (Replaces the earlier declared∪client union model.)
const INTEG = IntegrationSlug.make("acme");
const TEMPLATE = AuthTemplateSlug.make("oauth");
const CLIENT = OAuthClientSlug.make("acme-app");
// The integration's DECLARED oauth scopes — the sole source of the request.
const DECLARED_SCOPES = ["calendar", "gmail", "drive", "sheets"] as const;
/** A plugin whose integration config carries declared oauth scopes, projected
* into an oauth `AuthMethodDescriptor` via `describeAuthMethods` — exactly the
* shape `resolveDeclaredOAuthScopes` reads. `scopes: null` ⇒ no declared oauth
* scopes (the MCP/no-template-scopes case). */
const makeScopePlugin = (config: { readonly scopes: readonly string[] | null }) =>
definePlugin(() => ({
id: "acme" as const,
storage: () => ({}),
resolveTools: () =>
Effect.succeed({
tools: [{ name: ToolName.make("whoami"), description: "whoami" }],
}),
invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }),
describeAuthMethods: (record: IntegrationRecord): readonly AuthMethodDescriptor[] => {
const cfg = record.config as { readonly scopes?: readonly string[] | null } | null;
const scopes = cfg?.scopes;
if (scopes == null) {
// No declared oauth scopes — the integration declares an oauth method
// with no template scopes (MCP/DCR-style). Declared scopes resolve to [].
return [{ id: "oauth", label: "OAuth2", kind: "oauth", template: String(TEMPLATE) }];
}
return [
{
id: "oauth",
label: "OAuth2",
kind: "oauth",
template: String(TEMPLATE),
oauth: { scopes },
},
];
},
extension: (ctx) => ({
seed: () =>
ctx.core.integrations.register({
slug: INTEG,
description: "Acme",
config: { scopes: config.scopes },
}),
}),
}))();
/** Parse the `scope` query param from an authorize URL into an ordered list. */
const scopesFromAuthorizeUrl = (authorizationUrl: string): readonly string[] => {
const raw = new URL(authorizationUrl).searchParams.get("scope");
return raw == null || raw.length === 0 ? [] : raw.split(" ");
};
describe("oauth.start integration-driven scopes", () => {
it.effect(
"(a) requests exactly the integration's declared scopes (the app contributes none)",
() =>
Effect.scoped(
Effect.gen(function* () {
const server = yield* serveOAuthTestServer({ scopes: [...DECLARED_SCOPES] });
const plugins = [
memoryCredentialsPlugin(),
makeScopePlugin({ scopes: DECLARED_SCOPES }),
] as const;
const { executor } = yield* makeTestWorkspaceHarness({ plugins });
yield* executor.acme.seed();
// The app is pure identity — no scope set.
yield* executor.oauth.createClient({
owner: "org",
slug: CLIENT,
authorizationUrl: server.authorizationEndpoint,
tokenUrl: server.tokenEndpoint,
grant: "authorization_code",
clientId: "test-client",
clientSecret: "test-secret",
});
const started = yield* executor.oauth.start({
owner: "org",
client: CLIENT,
clientOwner: "org",
name: ConnectionName.make("main"),
integration: INTEG,
template: TEMPLATE,
});
expect(started.status).toBe("redirect");
if (started.status !== "redirect") return;
// The authorize URL requests exactly the integration's declared scopes.
expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual([...DECLARED_SCOPES]);
}),
),
);
it.effect("(b) when the integration declares no oauth scopes, start requests none", () =>
Effect.scoped(
Effect.gen(function* () {
const server = yield* serveOAuthTestServer({ scopes: [] });
// `scopes: null` ⇒ the integration declares an oauth method with no
// template scopes ⇒ declared scopes resolve to [] ⇒ no scope is requested.
const plugins = [memoryCredentialsPlugin(), makeScopePlugin({ scopes: null })] as const;
const { executor } = yield* makeTestWorkspaceHarness({ plugins });
yield* executor.acme.seed();
yield* executor.oauth.createClient({
owner: "org",
slug: CLIENT,
authorizationUrl: server.authorizationEndpoint,
tokenUrl: server.tokenEndpoint,
grant: "authorization_code",
clientId: "test-client",
clientSecret: "test-secret",
});
const started = yield* executor.oauth.start({
owner: "org",
client: CLIENT,
clientOwner: "org",
name: ConnectionName.make("main"),
integration: INTEG,
template: TEMPLATE,
});
expect(started.status).toBe("redirect");
if (started.status !== "redirect") return;
expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual([]);
}),
),
);
});
// -----------------------------------------------------------------------------
// (c) The recorded `oauth_scope` reflects the requested (declared) scopes when
// the AS omits `scope`. A minimal inline token endpoint handles the
// client_credentials grant and deliberately OMITS `scope` from its response,
// forcing the recorded-scope fallback (`token.scope ?? requested.join(" ")`).
// -----------------------------------------------------------------------------
/** A minimal token endpoint serving the client_credentials grant and OMITTING
* `scope` from its response, forcing the recorded-scope fallback. Returns a
* scoped effect (mirrors `serveOAuthTestServer`); `yield*` it inside an already
* `Effect.scoped` test. */
const serveScopelessTokenServer = (): Effect.Effect<
{ readonly tokenEndpoint: string },
unknown,
Scope.Scope
> =>
Effect.gen(function* () {
const context = yield* Layer.build(
Layer.fresh(
HttpServer.serve(
HttpServerRequest.HttpServerRequest.asEffect().pipe(
Effect.map((request: HttpServerRequest.HttpServerRequest) => {
if (request.url.startsWith("/token") && request.method === "POST") {
// A Bearer access token WITHOUT a `scope` field — the AS omits
// it, so the recorded scope falls back to the requested set.
return HttpServerResponse.jsonUnsafe(
{
access_token: `at_${Math.random().toString(36).slice(2)}`,
token_type: "Bearer",
expires_in: 3600,
},
{ status: 200, headers: { "cache-control": "no-store" } },
);
}
return HttpServerResponse.jsonUnsafe({ error: "not_found" }, { status: 404 });
}),
),
).pipe(Layer.provideMerge(NodeHttpServer.layerTest)),
),
);
const server = Context.get(context, HttpServer.HttpServer);
const address = server.address;
if (!Predicate.isTagged(address, "TcpAddress")) {
return yield* Effect.die(`Expected a TcpAddress, got ${JSON.stringify(address)}`);
}
return { tokenEndpoint: `http://127.0.0.1:${address.port}/token` };
});
describe("oauth.start recorded scope fallback", () => {
it.effect(
"(c) records the requested (declared) scopes when the authorization server omits scope",
() =>
Effect.scoped(
Effect.gen(function* () {
const tokenServer = yield* serveScopelessTokenServer();
const plugins = [
memoryCredentialsPlugin(),
makeScopePlugin({ scopes: DECLARED_SCOPES }),
] as const;
const { executor, config } = yield* makeTestWorkspaceHarness({ plugins });
yield* executor.acme.seed();
// A client_credentials client so `start` mints inline (no redirect),
// exchanging against the scopeless token endpoint.
yield* executor.oauth.createClient({
owner: "org",
slug: CLIENT,
authorizationUrl: "http://127.0.0.1/authorize",
tokenUrl: tokenServer.tokenEndpoint,
grant: "client_credentials",
clientId: "test-client",
clientSecret: "test-secret",
});
const started = yield* executor.oauth.start({
owner: "org",
client: CLIENT,
clientOwner: "org",
name: ConnectionName.make("cc"),
integration: INTEG,
template: TEMPLATE,
});
expect(started.status).toBe("connected");
// The connection's recorded `oauth_scope` is the requested (declared) set
// since the AS omitted `scope`.
const row = yield* Effect.promise(() =>
config.db.findFirst("connection", {
where: (b) => b("name", "=", "cc"),
}),
);
expect(row?.oauth_scope).toBe("calendar gmail drive sheets");
}),
),
);
});