Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/atlassian-hosted-client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@executor-js/hosted-server": patch
---

Offer an optional hosted Atlassian OAuth 2.0 (3LO) client for personal, read-only Jira and Confluence access. It uses JSON token requests, offline access, and credential placement restricted to api.atlassian.com.
22 changes: 22 additions & 0 deletions apps/hosted/server/src/implementation/first-party-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,14 @@ const slackMcpUserScopes = [
"files:write",
];

/** Read-only Jira and Confluence access, renewed through Atlassian's rotating refresh tokens. */
const atlassianScopes = [
"offline_access",
"read:jira-work",
"read:confluence-content.all",
"search:confluence",
];

/**
* The clients an operator can turn on by setting their credentials.
*
Expand Down Expand Up @@ -158,6 +166,20 @@ export const firstPartyOAuthClientDefinitions: readonly FirstPartyOAuthClientDef
allowedScopes: googleAllowedScopes,
placement: { hosts: ["*.googleapis.com"] },
},
{
id: "atlassian",
label: "Atlassian",
server: {
authorizationUrl: "https://auth.atlassian.com/authorize",
tokenUrl: "https://auth.atlassian.com/oauth/token",
authorizationParams: { audience: "api.atlassian.com", prompt: "consent" },
tokenRequestFormat: "json",
},
tokenEndpointAuthMethod: "client_secret_post",
defaultScopes: atlassianScopes,
allowedScopes: atlassianScopes,
placement: { hosts: ["api.atlassian.com"] },
},
{
id: "slack",
label: "Executor for Slack",
Expand Down
2 changes: 2 additions & 0 deletions e2e/support/managed-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ export const OperatorSettings = Schema.Struct({
EXECUTOR_OAUTH_SYNTHETIC_MAIL_CLIENT_SECRET: Schema.optionalKey(Schema.String),
EXECUTOR_OAUTH_GOOGLE_CLIENT_ID: Schema.optionalKey(Schema.String),
EXECUTOR_OAUTH_GOOGLE_CLIENT_SECRET: Schema.optionalKey(Schema.String),
EXECUTOR_OAUTH_ATLASSIAN_CLIENT_ID: Schema.optionalKey(Schema.String),
EXECUTOR_OAUTH_ATLASSIAN_CLIENT_SECRET: Schema.optionalKey(Schema.String),
EXECUTOR_OAUTH_SLACK_CLIENT_ID: Schema.optionalKey(Schema.String),
EXECUTOR_OAUTH_SLACK_CLIENT_SECRET: Schema.optionalKey(Schema.String),
});
Expand Down
13 changes: 13 additions & 0 deletions e2e/test-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2959,6 +2959,19 @@ export const scenarios = plan({
local: na("Local products have no operator OAuth clients."),
},
},
credentialManagedAtlassian: {
fixtures: "actors",
file: "credential-placement.spec.ts",
title:
"the hosted Atlassian client offers personal read scopes only after both credentials are configured",
targets: {
"self-host": scheduled,
cloud: na(
"Restarts a runner-owned product; Cloud composes the same reviewed client definitions.",
),
local: na("Local products have no operator OAuth clients."),
},
},
credentialManagedPlaced: {
fixtures: "actors",
file: "credential-placement.spec.ts",
Expand Down
79 changes: 79 additions & 0 deletions e2e/tests/credential-placement.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1086,6 +1086,85 @@ layer(HostedLive, { excludeTestServices: true })("Credential placement", (it) =>
),
);

it.effect(scenarios.credentialManagedAtlassian.title, (context) =>
withHostedCase(
context,
Effect.gen(function* () {
const { api, actors, prefix, deploy, connection } = yield* harness;
const endpoints = {
authorizationUrl: "https://auth.atlassian.com/authorize",
tokenUrl: "https://auth.atlassian.com/oauth/token",
authorizationParams: { audience: "api.atlassian.com", prompt: "consent" },
tokenRequestFormat: "json",
};
const scopes = [
"offline_access",
"read:jira-work",
"read:confluence-content.all",
"search:confluence",
];
const signIn = (key: string, requested = scopes, server = endpoints) =>
Effect.gen(function* () {
const name = `Atlassian ${key} ${randomUUID().slice(0, 8)}`;
const { path, app } = yield* deploy(
name,
endpointsApp(name, ["api.atlassian.com"], server, requested),
);
const id = yield* connection(path, "mail");
const started = yield* api.request(
actors.owner,
"POST",
`${prefix}/connections/${id}/oauth/start`,
{
method: "oauth",
label: "Atlassian",
},
);
const provider = app.requirements.accounts.mail!.provider;
const setup = yield* body(
Setup,
yield* api.request(
actors.owner,
"GET",
`${prefix}/providers/${provider}/oauth/oauth/setup`,
),
);
return { started, setup };
});
const cleared = {
EXECUTOR_OAUTH_ATLASSIAN_CLIENT_ID: "",
EXECUTOR_OAUTH_ATLASSIAN_CLIENT_SECRET: "",
};
yield* Effect.addFinalizer(() => restartWith(cleared).pipe(Effect.orDie));
yield* restartWith(cleared);
expect((yield* signIn("unset")).setup.firstParty).toBeUndefined();
yield* restartWith({
EXECUTOR_OAUTH_ATLASSIAN_CLIENT_ID: "synthetic-atlassian-client",
EXECUTOR_OAUTH_ATLASSIAN_CLIENT_SECRET: "synthetic-atlassian-secret",
});
const allowed = yield* signIn("read");
expect(allowed.setup).toMatchObject({ firstParty: { label: "Atlassian" }, scopes });
expect(allowed.started.status, JSON.stringify(allowed.started.body)).toBe(200);
const sent = new URL((yield* body(Redirect, allowed.started)).authorizationUrl);
expect(`${sent.origin}${sent.pathname}`).toBe(endpoints.authorizationUrl);
expect(sent.searchParams.get("client_id")).toBe("synthetic-atlassian-client");
expect(new Set(sent.searchParams.get("scope")?.split(" "))).toEqual(new Set(scopes));
expect(sent.searchParams.get("audience")).toBe("api.atlassian.com");
expect(sent.searchParams.get("prompt")).toBe("consent");
const wider = yield* signIn("write", [...scopes, "write:jira-work"]);
expect(wider.setup.firstParty).toBeUndefined();
expect(wider.started.body).toMatchObject({ _tag: "OAuthClientUnavailable" });
const otherServer = yield* signIn("other-server", scopes, {
...endpoints,
authorizationUrl: "https://example.invalid/authorize",
tokenUrl: "https://example.invalid/oauth/token",
});
expect(otherServer.setup.firstParty).toBeUndefined();
expect(otherServer.started.body).toMatchObject({ _tag: "OAuthClientUnavailable" });
}),
),
);

/** An app on the operator client's issuer, and an account connected through its client. */
const managedAccount = (rawToken: boolean, configured = operator) =>
Effect.gen(function* () {
Expand Down