Repository navigation
feat(plugins): dynamic OAuth clients, plus Notion, Slack and Gmail #7317
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
32 commits
Select commit
Hold shift + click to select a range
71644dd
feat(plugins): connect a hosted MCP server with no OAuth app to register
harshithmullapudi 098ca85
feat(plugins): add Slack and Gmail, move Linear onto a dynamic client
harshithmullapudi 8ef480a
chore(db): renumber the plugin_oauth_clients migration after rebase
harshithmullapudi 79f58c5
fix(plugins): stop the OAuth module from evaluating the database client
harshithmullapudi 9bf5469
fix(plugins): correct four faults the review found in the OAuth path
harshithmullapudi 73e2148
feat(plugins): add the Google plugins to the desktop install catalog
harshithmullapudi 8eb02b8
feat(plugins): give Slack and Gmail their own MCP servers
harshithmullapudi 91e19e0
fix(cli): make a connection id the only way to name an mcp target
harshithmullapudi 73b035e
fix(plugins): correct nine faults the review found in the OAuth path
harshithmullapudi c64597c
fix(plugins): converge concurrent dynamic client registrations
harshithmullapudi 2d481c7
fix(plugins): address the PR review on the OAuth and Gmail paths
harshithmullapudi d5cb17a
test(plugins): drop the dynamic client suite for ORM-free discovery t…
harshithmullapudi 72cd566
fix(plugins): correct the faults cubic found in the review commit
harshithmullapudi a236abe
fix(plugins): render the RFC Message-ID and fail an unrefreshable con…
harshithmullapudi a528541
docs(plugins): say what PLUGIN_CLIENT_METADATA_BASE_URL cannot do
harshithmullapudi 64a8276
Merge remote-tracking branch 'origin/main' into integration-plugins
harshithmullapudi 3278e2a
feat(connectors): move integrations onto the connections table
harshithmullapudi 315cf7e
Merge remote-tracking branch 'origin/main' into integration-plugins
harshithmullapudi 4184952
fix(db): drop the automation_events FK that would abort migration 0115
harshithmullapudi 0cff967
fix(connectors): resolve user-scoped connectors per member
harshithmullapudi 21b0a4e
fix(plugins): make the dynamic OAuth client path usable
harshithmullapudi 326fc33
refactor(cli): fold plugins connections into plugins list
harshithmullapudi eec2ba7
style(plugins): format the manifests biome rejects
harshithmullapudi 1537119
fix(connectors): green CI, RFC 9207 iss check, dedicated secrets key
harshithmullapudi c571b3b
refactor(plugins): manifests name connectors, and the schema we serve…
harshithmullapudi 43b8153
style(plugins): silence the placeholder-syntax lint in the manifest s…
harshithmullapudi 0c8b7d1
refactor(plugins): serve plugin tools from an MCP endpoint, and close…
harshithmullapudi 47059ff
Merge remote-tracking branch 'origin/main' into integration-plugins
harshithmullapudi 5045973
Merge remote-tracking branch 'origin/main' into integration-plugins
harshithmullapudi ccea4a5
style(slack): format the plugin-server test stub biome rejects
harshithmullapudi 3799409
fix(connectors): close the review's remaining checklist
harshithmullapudi b03b7ac
test(connectors): cover the ambiguity, needs-reauth and bad-gateway r…
harshithmullapudi File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
31 changes: 31 additions & 0 deletions
31
apps/api/src/app/api/connectors/[connector]/client-metadata/route.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| import { getConnector } from "@superset/shared/connectors"; | ||
| import { clientMetadataUrl, redirectUriFor } from "@superset/trpc/connectors"; | ||
|
|
||
| export async function GET( | ||
| _request: Request, | ||
| { params }: { params: Promise<{ connector: string }> }, | ||
| ) { | ||
| const { connector: slug } = await params; | ||
| if (!getConnector(slug)) { | ||
| return Response.json({ error: "Unknown connector" }, { status: 404 }); | ||
| } | ||
|
|
||
| return Response.json( | ||
| { | ||
| client_id: clientMetadataUrl(slug), | ||
| client_name: "Superset", | ||
| client_uri: "https://superset.sh", | ||
| redirect_uris: [redirectUriFor(slug)], | ||
| grant_types: ["authorization_code", "refresh_token"], | ||
| response_types: ["code"], | ||
| token_endpoint_auth_method: "none", | ||
| application_type: "web", | ||
| }, | ||
| { | ||
| headers: { | ||
| "cache-control": "public, max-age=3600", | ||
| "content-type": "application/json", | ||
| }, | ||
| }, | ||
| ); | ||
| } |
91 changes: 91 additions & 0 deletions
91
apps/api/src/app/api/connectors/[connector]/connect/route.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| import type { ConnectorMethod } from "@superset/shared/connectors"; | ||
| import { | ||
| authorizeUrl, | ||
| connectorMethod, | ||
| createCodeVerifier, | ||
| MissingConnectorEnvError, | ||
| redirectUriFor, | ||
| requireConnector, | ||
| resolveEndpoints, | ||
| UnknownConnectorError, | ||
| } from "@superset/trpc/connectors"; | ||
| import { encryptSecret } from "@superset/trpc/integrations/plugins"; | ||
|
|
||
| import { env } from "@/env"; | ||
| import { requireOrgMember } from "@/lib/integrations/requireOrgMember"; | ||
| import { createSignedState } from "@/lib/oauth-state"; | ||
|
|
||
| export const STATE_COOKIE = "connector_oauth_state"; | ||
|
|
||
| export async function GET( | ||
| request: Request, | ||
| { params }: { params: Promise<{ connector: string }> }, | ||
| ) { | ||
| const { connector: slug } = await params; | ||
| const member = await requireOrgMember(request); | ||
| if (member instanceof Response) return member; | ||
|
|
||
| // Same reason as the callback: the generic connect page covers every | ||
| // connector, `/integrations/<slug>` only the seven older ones. | ||
| const connectUrl = `${env.NEXT_PUBLIC_WEB_URL}/connect/${slug}`; | ||
| const requested = new URL(request.url).searchParams.get("method"); | ||
|
|
||
| let method: ConnectorMethod; | ||
| try { | ||
| const connector = requireConnector(slug); | ||
| method = connectorMethod( | ||
| connector, | ||
| (requested as ConnectorMethod["type"] | null) ?? undefined, | ||
| ); | ||
| } catch (error) { | ||
| if (error instanceof UnknownConnectorError) | ||
| return Response.json({ error: "Unknown connector" }, { status: 404 }); | ||
| throw error; | ||
| } | ||
|
|
||
| if (method.type === "api_key") | ||
| return Response.json( | ||
| { error: "This method takes inputs, not a redirect" }, | ||
| { status: 400 }, | ||
| ); | ||
|
|
||
| let target: string; | ||
| let state: string; | ||
| try { | ||
| const redirect = redirectUriFor(slug); | ||
| const wantsPkce = | ||
| method.type === "oauth2" && | ||
| (await resolveEndpoints(slug, method, redirect)).pkce; | ||
| const codeVerifier = wantsPkce ? createCodeVerifier() : null; | ||
| state = codeVerifier | ||
| ? createSignedState({ | ||
| organizationId: member.organizationId, | ||
| userId: member.userId, | ||
| codeVerifier: await encryptSecret(codeVerifier), | ||
| }) | ||
| : member.state; | ||
|
|
||
| target = ( | ||
| await authorizeUrl(slug, method, { | ||
| redirectUri: redirect, | ||
| state, | ||
| codeVerifier, | ||
| }) | ||
| ).url; | ||
| } catch (error) { | ||
| if (error instanceof MissingConnectorEnvError) { | ||
| console.error(`[connectors/${slug}] ${error.message}`); | ||
| return Response.redirect(`${connectUrl}?error=not_configured`); | ||
| } | ||
| throw error; | ||
| } | ||
|
|
||
| const secure = env.NEXT_PUBLIC_API_URL.startsWith("https") ? " Secure;" : ""; | ||
| return new Response(null, { | ||
| status: 302, | ||
| headers: { | ||
| Location: target, | ||
| "Set-Cookie": `${STATE_COOKIE}=${state}; HttpOnly;${secure} SameSite=Lax; Path=/api/connectors; Max-Age=600`, | ||
| }, | ||
| }); | ||
| } |
117 changes: 117 additions & 0 deletions
117
apps/api/src/app/api/connectors/callback/[connector]/route.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| import type { ConnectorMethod } from "@superset/shared/connectors"; | ||
| import { | ||
| connectorMethod, | ||
| exchangeCode, | ||
| probeIdentity, | ||
| redirectUriFor, | ||
| requireConnector, | ||
| UnknownConnectorError, | ||
| upsertConnection, | ||
| } from "@superset/trpc/connectors"; | ||
| import { decryptSecret } from "@superset/trpc/integrations/plugins"; | ||
| import { STATE_COOKIE } from "@/app/api/connectors/[connector]/connect/route"; | ||
| import { env } from "@/env"; | ||
| import { resolveCallback } from "@/lib/integrations/resolveCallback"; | ||
| import { connectorStateSchema, verifySignedState } from "@/lib/oauth-state"; | ||
|
|
||
| function callbackParams(method: ConnectorMethod): string[] { | ||
| if (method.type === "oauth2") return ["code"]; | ||
| if (method.type === "app_install" || method.type === "admin_consent") | ||
| return [...method.callback_params]; | ||
| return []; | ||
| } | ||
|
|
||
| function stateCookie(request: Request): string | null { | ||
| const cookie = request.headers.get("cookie") ?? ""; | ||
| const match = cookie.match(new RegExp(`(?:^|;\\s*)${STATE_COOKIE}=([^;]+)`)); | ||
| return match?.[1] ?? null; | ||
| } | ||
|
|
||
| export async function GET( | ||
| request: Request, | ||
| { params }: { params: Promise<{ connector: string }> }, | ||
| ) { | ||
| const { connector: slug } = await params; | ||
| // The generic connect page exists for every connector; `/integrations/<slug>` | ||
| // only exists for the seven that predate the registry, so a new connector | ||
| // would 404 the moment someone finished authorizing it. | ||
| const connectUrl = `${env.NEXT_PUBLIC_WEB_URL}/connect/${slug}`; | ||
| const web = (query: string) => Response.redirect(`${connectUrl}${query}`); | ||
|
|
||
| let connector: ReturnType<typeof requireConnector>; | ||
| let method: ConnectorMethod; | ||
| try { | ||
| connector = requireConnector(slug); | ||
| method = connectorMethod(connector); | ||
| } catch (error) { | ||
| if (error instanceof UnknownConnectorError) | ||
| return Response.json({ error: "Unknown connector" }, { status: 404 }); | ||
| throw error; | ||
| } | ||
|
|
||
| const callback = await resolveCallback(request, { | ||
| params: callbackParams(method), | ||
| redirect: (error) => web(`?error=${error}`), | ||
| stateFrom: method.type === "app_install" ? stateCookie : undefined, | ||
| }); | ||
| if (callback instanceof Response) return callback; | ||
|
|
||
| // The signed state proves who asked, not who is answering. Without this the | ||
| // authorization code a victim approves can be paired with a state minted by | ||
| // an attacker, binding the victim's account to the attacker's connection. | ||
| if (method.type !== "app_install") { | ||
| const query = new URL(request.url).searchParams.get("state"); | ||
| if (!query || query !== stateCookie(request)) | ||
| return web("?error=invalid_state"); | ||
| } | ||
|
|
||
| try { | ||
| const rawState = | ||
| method.type === "app_install" | ||
| ? stateCookie(request) | ||
| : new URL(request.url).searchParams.get("state"); | ||
| const carried = rawState | ||
| ? verifySignedState(rawState, connectorStateSchema) | ||
| : null; | ||
| const codeVerifier = carried?.codeVerifier | ||
| ? await decryptSecret(carried.codeVerifier) | ||
| : null; | ||
|
|
||
| const tokens = await exchangeCode(slug, method, { | ||
| code: callback.params.code ?? "", | ||
| redirectUri: redirectUriFor(slug), | ||
| codeVerifier, | ||
| params: callback.params, | ||
| issuer: new URL(request.url).searchParams.get("iss"), | ||
| }); | ||
|
|
||
| const identity = await probeIdentity( | ||
| slug, | ||
| method, | ||
| tokens.accessToken, | ||
| callback.params, | ||
| ); | ||
|
|
||
| const result = await upsertConnection({ | ||
| connector, | ||
| slug, | ||
| authMethod: method.type, | ||
| organizationId: callback.organizationId, | ||
| userId: callback.userId, | ||
| tokens, | ||
| identity, | ||
| }); | ||
|
|
||
| if (result.conflict) { | ||
| const owner = result.conflict.ownerEmail | ||
| ? `&owner=${encodeURIComponent(result.conflict.ownerEmail)}` | ||
| : ""; | ||
| return web(`?error=account_already_linked${owner}`); | ||
| } | ||
| } catch (error) { | ||
| console.error(`[connectors/${slug}] callback failed:`, error); | ||
| return web("?error=token_exchange_failed"); | ||
| } | ||
|
|
||
| return web("?connected=1"); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.