mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
Links a self-hosted instance to a SaaS team over an ordinary redirect, and leaves the admin's browser holding a Stirling session at the same time. ## The problem A self-hosted server needs a device credential bound to a SaaS team, and the admin's Supabase JWT must never reach the instance backend. Three things ruled out the obvious approaches: - **A customer hostname can never be in Supabase's redirect allow-list**, so the sign-in cannot happen on the instance's own origin. That is why SSO and sign-up did not work for linking at all. - **A device credential identifies a server, not a person.** Every attended portal read (Usage, Billing, Documents, Infrastructure) goes through `getPortalSaasToken()` and needs a *user* session, so a credential-only link left all of them asking for a second sign-in. - **The previous design relayed a JWT** from the browser into the instance, which is the thing we wanted to avoid. That path is deleted here. ## The solution Redirect and nonce, modelled on desktop's `authService.loginWithSelfHostedOAuth`: mint a nonce, hand the browser off, accept only a callback carrying that nonce back. Desktop has the OS route the reply; self-hosted has no OS hop, so our own approval page performs it. That is the point — the human half happens on an origin we control. ``` instance SaaS admin's browser | POST connect/request | | | (name, callback, nonce, | | | claim-secret hash) | | |-------------------------->| | | <- requestId + authorizeUrl | | | GET /link?request=... | | |<-------------------------------| | | sign in (SSO works here), | | | see ACCOUNT + ORIGIN, approve | | |------------------------------->| | | 302 callback#nonce+session | | POST connect/claim | | | (requestId, claim secret)| | |-------------------------->| | | <- device credential | | ``` Four properties carry the safety, and each is stated in the code because each is easy to lose in a refactor: - **The redirect target is never caller-supplied.** Validated once at creation, then read back from the stored row, so nothing in the approval page's URL can steer the token elsewhere. - **Approval and minting are separate.** Approval records the team and hands out nothing usable; the credential is minted only on claim, authenticated by a secret that never entered a browser. - **A re-authentication cannot move a server between teams.** The team is pinned at creation from the credential only that instance holds, so an approver from another team gets `WRONG_TEAM` instead of a rebind. - **The approver has to confirm what they are binding.** The page shows the address and the signed-in account, with a way to switch, and a checkbox naming the address gates the approve button. The name the server reports is deliberately not shown: the requester picks it on an unauthenticated endpoint, and its honest value is the hostname already in the address. The session rides the URL fragment, so it stays out of access logs and `Referer`, and is stripped before anything awaits. The claim is row-locked, so one approval mints once. A request lives 30 minutes; a settled one is not offered again, since approving it fails server-side. Signing in mid-flow no longer loses the request. The id is kept on the SaaS origin and resumed after any sign-in, which is what makes creating an account work: the confirmation email opens a new tab, where the `next` parameter is gone. Reading it does not consume it — the request may be open in two tabs — and only a recorded decision retires it. The result lands as a modal over the portal the admin started from, and the portal re-reads its link status so the page behind agrees with the modal. Plaintext `http://` callbacks are accepted rather than refused, because many self-hosted instances legitimately run plain HTTP on a private network; the address carries a warning icon explaining the risk, derived server-side so a requester cannot suppress it. Hard-refusing `http://` to a public IP literal is a reasonable follow-up; a bare hostname can't be classified without a DNS lookup, so the warning stays the general mechanism. ## Configuration Four surfaces. Placeholders below, not values. **SaaS backend** | Setting | Needed | Why | |---|---|---| | `stirling.billing.account-link.enabled` | Yes, `true` | The connect controller and service are `@ConditionalOnProperty` with no default, so without it the endpoints do not exist. | | `system.frontendUrl` | Only when the approval page is not on the API's own origin | Where the approver is sent. Must include the app's base path if it is served under one, or the redirect misses `/link`. | **SaaS frontend** | Setting | Needed | Why | |---|---|---| | `VITE_SUPABASE_URL`, `VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY` | Yes | Its own sign-in. Must be the project the SaaS backend validates tokens against. | | `RUN_SUBPATH` | Only if served under a subpath | Moves the approval page to `<base>/<subpath>/link`, so `system.frontendUrl` has to agree. | **Self-hosted backend** | Setting | Needed | Why | |---|---|---| | `stirling.billing.account-link.enabled` | Yes, `true` | Defaults to `false`. | | `stirling.billing.account-link.saas-base-url` | Yes | Origin of the SaaS API it links to. Not the SaaS frontend. | | `system.frontendUrl` | Optional | Externally reachable base URL for the callback. Otherwise derived from the request's `Origin`, which is right for ordinary deployments and wrong behind a rewriting proxy. | **Self-hosted frontend** | Setting | Needed | Why | |---|---|---| | `VITE_SUPABASE_URL`, `VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY` | Yes | Accepts the session handed over in the callback fragment. | | `VITE_SAAS_API_URL` | For Usage and Billing | Attended reads go to the SaaS API with the admin's token. Absent, those surfaces stay on the mock. | | `VITE_INCLUDE_PORTAL` | Production builds | Dev builds include the portal automatically; without it there is no link UI and no callback route. | Two things worth stating because neither fails loudly: - **Both frontends must use the URL *and* key of the same Supabase project**, and the same one the SaaS backend validates against. A key from one project with a URL from another is accepted by the browser and rejected by Supabase, which surfaces much later as "session expired" on Usage rather than as an error at hand-over. - **The Supabase redirect allow-list must contain the SaaS app's `/auth/callback`**, since a confirmation email returns through it. Entries are matched exactly. - **`system.frontendUrl` is the existing setting for this**, not a new one, so each side reads its own value and there is nothing extra to configure. It also gates share links, so on a stack with storage and sharing already on, setting it here turns those on too. The self-hosted side deliberately does **not** configure where the approval page lives — SaaS answers that in the connect-request reply, being the only party that knows. Also here, because testing this needs two stacks side by side: `linked:staging` / `linked:dev` (which derive `system.frontendUrl` and `RUN_SUBPATH` themselves), the missing `frontend:staging:saas`, and a per-mode vite `cacheDir` — two dev servers in different modes otherwise re-optimise over one shared dep cache. ## How to test Automated and green: `task frontend:check:all` plus both backend modules. `ConnectRequestServiceTest` covers callback validation, the per-IP cap, single-use approval, claim outcomes, expiry, `WRONG_TEAM` and reauth confirming without minting; `ConnectServiceTest` covers callback-resolution precedence including a foreign-origin callback being discarded; `ConnectControllerTest` covers the authorize URL, including the forwarded-header path and only the first hop being trusted; `ConnectCallback.test.tsx` covers the fragment being stripped synchronously and malformed fragments refused; `LinkAccountModal.test.tsx` covers link and reauth hitting different endpoints. Manual walkthrough: 1. `task linked:staging` — added here; brings up a SaaS stack and a self-hosted instance pointed at it, on discovered ports, and prints the four addresses. 2. Open the link-account modal in the self-hosted portal and continue. Expect the SaaS approval page at `/link?request=<id>`. 3. Sign in as a team leader, or create an account and confirm the email. Either way you should come back to the approval page. 4. Tick the acknowledgement and approve. Expect the fragment gone from the address bar immediately, a result modal over the portal, the portal showing linked without a reload, and attended reads (Usage, Billing) working without a second sign-in. 5. Repeat, approving as a member of a different team. Expect a refusal, not a rebind. ## Outstanding - #7415 to be reworked against this design once this lands. - **No SaaS-side UI to disconnect a server.** `GET /account-link/instances` and `POST /account-link/instances/{id}/revoke` are already team-scoped and leader-gated, and the portal has a panel that uses them, but `portal-saas/components/settings/accountLinkSettings.tsx` exports `null` on the reasoning that "SaaS has no account-link concept". That held when linking was a self-hosted admin managing their own instance; here a leader approves a server they may not administer, and has no way to withdraw it. The seam to fill is that one file. Expected to land with the CTA work in #7415. --------- Co-authored-by: James Brunton <jbrunton96@gmail.com>
101 lines
3.3 KiB
TypeScript
101 lines
3.3 KiB
TypeScript
import { http, HttpResponse, delay } from "msw";
|
|
import {
|
|
getLocalStatus,
|
|
getLocalUsage,
|
|
linkLocal,
|
|
listInstances,
|
|
revokeInstance,
|
|
unlinkLocal,
|
|
} from "@portal/mocks/link";
|
|
|
|
/**
|
|
* Account-link MSW handlers. Two surfaces:
|
|
*
|
|
* - LOCAL backend (this instance): the connect handshake, status and unlink.
|
|
* `connect/complete` mutates the in-memory store and flips local status so the
|
|
* surface behaves like a real backend within a session. The device secret stays
|
|
* server-side — never returned over the wire, matching the real contract.
|
|
* - SaaS backend (team-wide): instances / revoke.
|
|
*
|
|
* Mirrors the real controller paths so MSW can be dropped with no code change.
|
|
*/
|
|
export const linkHandlers = [
|
|
http.get("/api/v1/account-link/status", async () => {
|
|
await delay(120);
|
|
return HttpResponse.json(getLocalStatus());
|
|
}),
|
|
|
|
// Opening a handshake hands back where to send the admin. The real backend gets
|
|
// that URL from SaaS rather than composing it, so the mock returns one too.
|
|
http.post("*/api/v1/account-link/connect/start", async () => {
|
|
await delay(120);
|
|
return HttpResponse.json({
|
|
phase: "PENDING",
|
|
authorizeUrl: "https://app.stirling.test/link?request=mock-request",
|
|
secondsRemaining: 900,
|
|
teamId: null,
|
|
});
|
|
}),
|
|
|
|
http.post("*/api/v1/account-link/connect/reauth", async () => {
|
|
await delay(120);
|
|
return HttpResponse.json({
|
|
phase: "PENDING",
|
|
authorizeUrl: "https://app.stirling.test/link?request=mock-reauth",
|
|
secondsRemaining: 900,
|
|
teamId: null,
|
|
});
|
|
}),
|
|
|
|
// The callback's completion step. Flips the store to linked, as a real claim would.
|
|
http.post("*/api/v1/account-link/connect/complete", async () => {
|
|
await delay(120);
|
|
linkLocal("mock-server");
|
|
return HttpResponse.json({
|
|
phase: "LINKED",
|
|
authorizeUrl: null,
|
|
secondsRemaining: null,
|
|
teamId: 7,
|
|
});
|
|
}),
|
|
|
|
http.get("/api/v1/account-link/usage", async () => {
|
|
await delay(120);
|
|
return HttpResponse.json(getLocalUsage());
|
|
}),
|
|
|
|
http.post("/api/v1/account-link/unlink", async () => {
|
|
await delay(120);
|
|
// Clear local link state, then 204 (no body) to match the real backend.
|
|
unlinkLocal();
|
|
return new HttpResponse(null, { status: 204 });
|
|
}),
|
|
|
|
// Manual sync trigger — the real backend runs a sync + entitlement refresh and
|
|
// returns 204 (or 409 when metering is off). The portal fires it best-effort
|
|
// after a checkout completes; the mock just acknowledges.
|
|
http.post("/api/v1/account-link/sync-now", async () => {
|
|
await delay(120);
|
|
return new HttpResponse(null, { status: 204 });
|
|
}),
|
|
|
|
// Team-wide list/revoke are SaaS-direct now (apiClient.saas calls the
|
|
// absolute VITE_SAAS_API_URL). Wildcard so the same handlers intercept both
|
|
// the relative pattern (legacy / direct-MSW usage) and any absolute SaaS
|
|
// base URL configured in dev/test.
|
|
http.get("*/api/v1/account-link/instances", async () => {
|
|
await delay(120);
|
|
return HttpResponse.json(listInstances());
|
|
}),
|
|
|
|
http.post(
|
|
"*/api/v1/account-link/instances/:instanceId/revoke",
|
|
async ({ params }) => {
|
|
await delay(120);
|
|
const ok = revokeInstance(Number(params.instanceId));
|
|
if (!ok) return new HttpResponse(null, { status: 404 });
|
|
return new HttpResponse(null, { status: 204 });
|
|
},
|
|
),
|
|
];
|