Skip to content

feat(pipes): authorize URLs for user- and organization-owned accounts - #179

Open
dkarten wants to merge 12 commits into
workos:mainfrom
dkarten:pipes-authorize
Open

dkarten wants to merge 12 commits into
workos:mainfrom
dkarten:pipes-authorize

Conversation

@dkarten

@dkarten dkarten commented Oct 29, 2026

Copy link
Copy Markdown

Stacks on #178

Summary

  • Adds POST /data-integrations/{slug}/authorize, which returns { url }.
  • With no provider to reach, url connects the account itself, as /user_management/authorize completes without an IdP.
  • Redirects to return_to, or shows a confirmation page without one. Under --interactive it shows an Allow page first.
  • A link works once and expires after 10 minutes. Issuing a new one sweeps expired ones.
  • Deleting the user or organization deletes links. Opening a link after the membership ended returns a 403 page.
  • Connecting sets established_by_user_id. A reconnect keeps the account's id and drops its stored tokens.
  • return_to is checked against the allowed redirect hosts, and re-serialized before the redirect.
  • GET /data-integrations/{slug}/authorize (the legacy redirect) and the new page are public.
  • Moves the user POST's exact account lookup into findExactConnectedAccount, shared with connecting.

Behaviour

Request Outcome
/authorize with a malformed body 400, "error": "Bad Request"
/authorize naming an organization the user is not an active member of 403
/authorize without an API key 401
Visiting the url Connects, then 302 to return_to or a confirmation page
Visiting the url under --interactive Allow page; submitting it connects
Visiting a used or expired url 400 page
Visiting the url after the user's membership of the organization ended 403 page, nothing connected

Verification

Full suite: 1937 pass, 0 fail. bun run typecheck, bun run lint, bun run fmt:check and
bun run gen:supported are clean. New tests in data-integrations.spec.ts cover the full flow for
both owners, reconnecting, the confirmation page, used, expired and swept links, both cascades, the
ended-membership page, the interactive page, return_to refusal and encoding, the API key, and
the user-owned flow through the Node SDK's authorizeDataIntegration.

I also built the image from the Dockerfile at this branch's head and called the routes from an
external Python client. It got the not-installed answer, the 403 for a non-member, a connect
through the URL, a token, a disconnect, and the empty state after it.

Closes #176.

dkarten and others added 8 commits October 29, 2026 09:51
Serve GET, POST, PUT and DELETE on /organizations/{organization_id}/connected_accounts/{slug}.
An organization holds one account per provider with user_id null, apart from any its members
hold. Import and update name the acting member in user_id, who must be an active member.

Accounts now report established_by_user_id, cleared when that user is deleted. Deleting an
organization deletes its accounts and its members' accounts scoped to it. The connectedAccounts
seed takes owner: organization.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Describe the organization-owned rules in prose, as the other behavior sections do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
An overlapping import otherwise misses the other's write and both answer 201. Cover the members'
scoped accounts in the organization-delete cascade test, and say plainly that ending a membership
does not clear credentials, unlike production.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
As CLAUDE.md asks, and cite production's invalidation when the establishing member leaves.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Serve POST /data-integrations/{slug}/credentials. It returns the account's token as a
credential, or active: false with not_installed or needs_reauthorization. With
connection_owner: organization it vends the organization's account. Naming an organization
the user is not an active member of returns 403 for either owner, and a malformed body
returns the spec's 400.

/credentials and /token share the token logic. /token is otherwise unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…olves

A request that leaves organization_id out still resolves a user's only account for the provider,
and that account may be scoped to an organization the user has left.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@dkarten
dkarten marked this pull request as ready for review October 29, 2026 18:36
@greptile-apps

greptile-apps Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5

[Medium impact] The changes reach the Pipes account connection and credential workflows.

The PR appears safe to merge, with two non-blocking fixes for the interactive page and malformed-body errors.

Findings

  1. P2 Former members still see Allow ▶
  2. P2 Invalid JSON gets another shape ▶
Fix with agent prompt
### Issue 1
src/workos/routes/data-integrations.ts:207-218
Under `--interactive`, opening a link after the user's organization membership ends still shows the Allow page with status 200. The membership check only runs in `connect()`, after the user submits the form. Check membership before rendering the page too, so the visit returns the documented 403 instead of offering a connection that will fail.

### Issue 2
src/workos/routes/data-integrations.ts:102
Invalid JSON sent to `/authorize` or `/credentials` bypasses `badRequest()`. `parseJsonBody()` throws a `WorkOSApiError`, so the response contains `code: "invalid_request_body"` but lacks the documented `error: "Bad Request"`. Clients checking that field get a different response than they do for invalid body fields. Use the promised error shape for parse failures too, and add an invalid-JSON test for both routes.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds authorize links and credential retrieval for user- and organization-owned connected accounts.

  • A one-use link connects the requested account and then returns the browser.
  • Organizations can keep and manage their own connected account for each provider.
  • Credential requests can return a user’s or organization’s connected account token.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[POST authorize with API key] --> B[Check user and membership]
  B --> C[Create ten-minute link]
  C --> D[Browser opens link]
  D --> E{Interactive?}
  E -->|Yes| F[Show Allow page]
  F --> G[POST Allow]
  E -->|No| H[Consume link and check membership]
  G --> H
  H --> I[Create or reconnect account]
  I --> J[Redirect or show confirmation]
Loading

Reviews (1) · Last reviewed commit: "fix(pipes): re-serialize return_to and s..." · Reviewed by Greptile

Comment on lines +207 to +218
if (store.getData<boolean>(STORE_KEYS.interactiveAuth)) {
const organization = authorization.organization_id
? ws.organizations.get(authorization.organization_id)
: undefined;
return c.html(
renderConnectPage({
slug: authorization.slug,
email: ws.users.get(authorization.user_id)!.email,
organizationName: organization?.name,
formAction: new URL(c.req.url).pathname,
}),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Former members still see Allow

Under --interactive, opening a link after the user's organization membership ends still shows the Allow page with status 200. The membership check only runs in connect(), after the user submits the form. Check membership before rendering the page too, so the visit returns the documented 403 instead of offering a connection that will fail.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/workos/routes/data-integrations.ts
Line: 207-218

Comment:
**Former members still see Allow**

Under `--interactive`, opening a link after the user's organization membership ends still shows the Allow page with status 200. The membership check only runs in `connect()`, after the user submits the form. Check membership before rendering the page too, so the visit returns the documented 403 instead of offering a connection that will fail.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

// The spec's authorize: returns a URL to send the browser to, which connects the account.
app.post('/data-integrations/:slug/authorize', async (c) => {
const slug = c.req.param('slug');
const body = await parseJsonBody(c);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Invalid JSON gets another shape

Invalid JSON sent to /authorize or /credentials bypasses badRequest(). parseJsonBody() throws a WorkOSApiError, so the response contains code: "invalid_request_body" but lacks the documented error: "Bad Request". Clients checking that field get a different response than they do for invalid body fields. Use the promised error shape for parse failures too, and add an invalid-JSON test for both routes.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/workos/routes/data-integrations.ts
Line: 102

Comment:
**Invalid JSON gets another shape**

Invalid JSON sent to `/authorize` or `/credentials` bypasses `badRequest()`. `parseJsonBody()` throws a `WorkOSApiError`, so the response contains `code: "invalid_request_body"` but lacks the documented `error: "Bad Request"`. Clients checking that field get a different response than they do for invalid body fields. Use the promised error shape for parse failures too, and add an invalid-JSON test for both routes.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the comment in #178, this is the convention of other endpoints

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. We settled the same point on PR #178: parseJsonBody errors keep the shared parser response, while field-validation errors use badRequest(). I’ll withdraw this finding rather than repeat it.

dkarten and others added 4 commits October 29, 2026 22:25
…0 row

The 400 with "error": "Bad Request" answers a missing or invalid field; a body that is not JSON
gets the emulator-wide invalid_request_body, as on every route.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Serve POST /data-integrations/{slug}/authorize. It returns a url whose page connects the
account and redirects to return_to, or shows a confirmation page without one. Under
--interactive the page asks first, standing in for the provider's consent screen. The link
works once, expires after 10 minutes, and is deleted with its user or organization. A
membership that has ended by the time the link is opened returns 403.

Only GET /data-integrations/{slug}/authorize and the new page are public. Before, any method
on a path ending in /authorize skipped the API key.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Redirect to return_to through URL, as the logout redirect does, so a space is encoded in Location.
Issuing a link deletes the user's expired ones, which were only dropped when visited. Cover the
10-minute expiry and a reconnect dropping the stored tokens.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Under --interactive the page rendered for a user whose membership had ended, and only submitting
it answered 403. Both authorize-redirect handlers now check first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Support organization-owned connected accounts in Pipes

1 participant