Skip to content
Draft
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
442 changes: 442 additions & 0 deletions e2e/selfhost/mcp-ema-work-identity.test.ts

Large diffs are not rendered by default.

83 changes: 82 additions & 1 deletion packages/core/api/src/handlers/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ import {
OAuthSessionNotFoundError,
OAuthStartError,
OAuthState,
WorkIdentityLinkError,
type Connection,
type ConnectResult,
type OAuthCallbackCompletion,
} from "@executor-js/sdk";

import { ExecutorApi } from "../api";
Expand All @@ -32,6 +34,7 @@ const decodeOAuthStartError = Schema.decodeUnknownOption(OAuthStartError);
const decodeOAuthCompleteError = Schema.decodeUnknownOption(OAuthCompleteError);
const decodeOAuthProbeError = Schema.decodeUnknownOption(OAuthProbeError);
const decodeOAuthSessionNotFoundError = Schema.decodeUnknownOption(OAuthSessionNotFoundError);
const decodeWorkIdentityLinkError = Schema.decodeUnknownOption(WorkIdentityLinkError);

const connectionToResponse = (c: Connection) => ({
owner: c.owner,
Expand All @@ -57,6 +60,19 @@ const startResultToResponse = (result: ConnectResult) =>
state: result.state,
};

/** What the popup posts back to the opener for each flow the shared callback can
* complete.
*
* A connection keeps the historical shape verbatim — spread flat into the
* message — because openers already read its fields. A work identity is spread
* under its own key instead of flat: the two objects share field names
* (`owner`, for one) and a console must be able to tell which arrived by
* looking, not by guessing from overlapping keys. */
const callbackToPopupPayload = (completion: OAuthCallbackCompletion) =>
completion.kind === "connection"
? connectionToResponse(completion.connection)
: { workIdentity: completion.workIdentity };

const toPopupErrorMessage = (error: unknown): PopupErrorMessage => {
const completeError = decodeOAuthCompleteError(error);
if (Option.isSome(completeError))
Expand All @@ -72,6 +88,13 @@ const toPopupErrorMessage = (error: unknown): PopupErrorMessage => {
details: startError.value.message,
};

const linkError = decodeWorkIdentityLinkError(error);
if (Option.isSome(linkError))
return {
short: "Could not link your work identity",
details: linkError.value.message,
};

const probeError = decodeOAuthProbeError(error);
if (Option.isSome(probeError))
return {
Expand Down Expand Up @@ -202,23 +225,81 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler
}),
),
)
.handle("startWorkIdentityLink", ({ payload }) =>
capture(
Effect.gen(function* () {
const executor = yield* ExecutorService;
return yield* executor.oauth.startWorkIdentityLink({
owner: payload.owner,
idpClient: payload.idpClient,
idpClientOwner: payload.idpClientOwner,
scopes: payload.scopes,
redirectUri: payload.redirectUri,
});
}),
),
)
.handle("completeWorkIdentityLink", ({ payload }) =>
capture(
Effect.gen(function* () {
const executor = yield* ExecutorService;
return yield* executor.oauth.completeWorkIdentityLink({
state: payload.state,
code: payload.code,
});
}),
),
)
.handle("workIdentityStatus", ({ query }) =>
capture(
Effect.gen(function* () {
const executor = yield* ExecutorService;
return yield* executor.oauth.workIdentityStatus({
owner: query.owner,
idpClient: query.idpClient,
idpClientOwner: query.idpClientOwner,
});
}),
),
)
.handle("unlinkWorkIdentity", ({ payload }) =>
capture(
Effect.gen(function* () {
const executor = yield* ExecutorService;
yield* executor.oauth.unlinkWorkIdentity({
owner: payload.owner,
idpClient: payload.idpClient,
idpClientOwner: payload.idpClientOwner,
});
return { unlinked: true };
}),
),
)
.handle("callback", ({ query: urlParams }) =>
// The callback always renders HTML, even on failure — the popup shows the
// error + messages it back to the opener.
//
// BOTH browser flows land here: connecting an integration, and linking a
// work identity. `completeCallback` reads the in-flight session to decide
// which, so the route infers nothing from the URL. The popup payload stays
// backward compatible — a connection is still spread flat, exactly as
// before — and a link is spread as `{ workIdentity }`, which is how an
// opener tells the two apart without the connection shape changing.
capture(
Effect.gen(function* () {
const executor = yield* ExecutorService;
const html = yield* runOAuthCallback({
complete: ({ state, code, callbackDomain }) =>
executor.oauth
.complete({
.completeCallback({
// `runOAuthCallback`'s `state` is a raw string from the URL;
// the SDK speaks the branded `OAuthState` (nominal brand).
state: OAuthState.make(state),
code: code ?? "",
callbackDomain,
})
.pipe(
Effect.map(callbackToPopupPayload),
Effect.tapError((cause: unknown) =>
Effect.logError("OAuth callback completion failed", cause),
),
Expand Down
89 changes: 89 additions & 0 deletions packages/core/api/src/oauth/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import {
OAuthState,
Owner,
ProviderKey,
WorkIdentityLinkError,
WorkIdentityStatusSchema,
} from "@executor-js/sdk/shared";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -256,6 +258,64 @@ const CallbackUrlParams = Schema.Struct({

const HtmlResponse = Schema.String.pipe(HttpApiSchema.asText());

// ---------------------------------------------------------------------------
// Work identity — acquiring the enterprise assertion an EMA connect presents.
//
// A user links their enterprise identity ONCE per (owner, IdP app); every
// enterprise-managed connect afterwards omits `enterprise.subjectToken` and the
// server resolves the held identity. The console's loop is:
//
// GET /oauth/work-identity/status → `unlinked` / `linked` / `needs_relink`
// POST /oauth/work-identity/start → open `authorizationUrl` in the popup
// (the popup lands on the SHARED /oauth/callback and posts back a
// `{ workIdentity: <status> }` payload; `complete` below is the direct entry
// point for callers that catch the code themselves)
// POST /oauth/work-identity/complete
// DELETE /oauth/work-identity → forget it
//
// The three routing fields (`owner`, `idpClient`, `idpClientOwner`) are the same
// on every one of them: the integration catalog projects `idpClient` /
// `idpClientOwner` on the server's oauth auth method, and `owner` is the owner
// the connection will be made under.
// ---------------------------------------------------------------------------

const WorkIdentityRefFields = {
/** The owner the identity is held under — the same owner the
* enterprise-managed CONNECTIONS backed by it are made under. */
owner: Owner,
/** The registered OAuth app standing for the enterprise identity provider, as
* the integration catalog's `enterpriseIdentityProvider` names it. */
idpClient: OAuthClientSlug,
idpClientOwner: Owner,
} as const;

const StartWorkIdentityLinkPayload = Schema.Struct({
...WorkIdentityRefFields,
/** Replace the default `openid offline_access` request outright. Send only for
* an identity provider that needs different scope names — the defaults are
* what make the link return account claims and a durable refresh token. */
scopes: Schema.optional(Schema.Array(Schema.String)),
redirectUri: Schema.optional(Schema.NullOr(Schema.String)),
});

const StartWorkIdentityLinkResponse = Schema.Struct({
authorizationUrl: Schema.String,
state: OAuthState,
});

const CompleteWorkIdentityLinkPayload = Schema.Struct({
state: OAuthState,
code: Schema.String,
});

const WorkIdentityStatusUrlParams = Schema.Struct(WorkIdentityRefFields);

const UnlinkWorkIdentityPayload = Schema.Struct(WorkIdentityRefFields);

const UnlinkWorkIdentityResponse = Schema.Struct({
unlinked: Schema.Boolean,
});

// ---------------------------------------------------------------------------
// Error schemas with HTTP status annotations
// ---------------------------------------------------------------------------
Expand All @@ -265,6 +325,7 @@ const OAuthComplete = OAuthCompleteError.annotate({ httpApiStatus: 400 });
const OAuthProbe = OAuthProbeError.annotate({ httpApiStatus: 400 });
const OAuthRegisterDynamic = OAuthRegisterDynamicError.annotate({ httpApiStatus: 400 });
const OAuthSessionNotFound = OAuthSessionNotFoundError.annotate({ httpApiStatus: 404 });
const WorkIdentityLink = WorkIdentityLinkError.annotate({ httpApiStatus: 400 });

// ---------------------------------------------------------------------------
// Group
Expand Down Expand Up @@ -327,6 +388,34 @@ export const OAuthApi = HttpApiGroup.make("oauth")
error: [InternalError, OAuthProbe],
}),
)
.add(
HttpApiEndpoint.post("startWorkIdentityLink", "/oauth/work-identity/start", {
payload: StartWorkIdentityLinkPayload,
success: StartWorkIdentityLinkResponse,
error: [InternalError, WorkIdentityLink],
}),
)
.add(
HttpApiEndpoint.post("completeWorkIdentityLink", "/oauth/work-identity/complete", {
payload: CompleteWorkIdentityLinkPayload,
success: WorkIdentityStatusSchema,
error: [InternalError, WorkIdentityLink, OAuthSessionNotFound],
}),
)
.add(
HttpApiEndpoint.get("workIdentityStatus", "/oauth/work-identity/status", {
query: WorkIdentityStatusUrlParams,
success: WorkIdentityStatusSchema,
error: InternalError,
}),
)
.add(
HttpApiEndpoint.delete("unlinkWorkIdentity", "/oauth/work-identity", {
payload: UnlinkWorkIdentityPayload,
success: UnlinkWorkIdentityResponse,
error: InternalError,
}),
)
.add(
HttpApiEndpoint.get("callback", "/oauth/callback", {
query: CallbackUrlParams,
Expand Down
Loading
Loading