@synap-core/sdk 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +133 -0
- package/README.md +212 -12
- package/dist/{chunk-J7XHAAFJ.js → chunk-UK2U6SMA.js} +14 -2
- package/dist/chunk-UK2U6SMA.js.map +1 -0
- package/dist/{client-CVUT1Mcf.d.cts → client-B50KC2aJ.d.cts} +14 -9
- package/dist/{client-CVUT1Mcf.d.ts → client-B50KC2aJ.d.ts} +14 -9
- package/dist/connect.cjs +790 -161
- package/dist/connect.cjs.map +1 -1
- package/dist/connect.d.cts +610 -37
- package/dist/connect.d.ts +610 -37
- package/dist/connect.js +777 -162
- package/dist/connect.js.map +1 -1
- package/dist/public.cjs +9 -2
- package/dist/public.cjs.map +1 -1
- package/dist/public.d.cts +19 -4
- package/dist/public.d.ts +19 -4
- package/dist/public.js +8 -2
- package/dist/public.js.map +1 -1
- package/dist/testing.cjs +366 -17
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +190 -7
- package/dist/testing.d.ts +190 -7
- package/dist/testing.js +358 -17
- package/dist/testing.js.map +1 -1
- package/dist/{types-DpqPNaaa.d.cts → types-DZYNBCq7.d.cts} +47 -5
- package/dist/{types-DpqPNaaa.d.ts → types-DZYNBCq7.d.ts} +47 -5
- package/package.json +36 -12
- package/src/connect/auth.ts +314 -55
- package/src/connect/client.ts +472 -26
- package/src/connect/cloud.ts +210 -0
- package/src/connect/discovery.ts +79 -44
- package/src/connect/encoding.ts +1 -1
- package/src/connect/errors.ts +8 -1
- package/src/connect/flow.ts +80 -12
- package/src/connect/http.ts +42 -0
- package/src/connect/hub-types.ts +254 -0
- package/src/connect/index.ts +69 -23
- package/src/connect/rules.ts +18 -0
- package/src/connect/seal.ts +1 -1
- package/src/connect/store.ts +50 -8
- package/src/connect/types.ts +48 -4
- package/src/public/client.ts +14 -2
- package/src/public/safe-markdown.ts +13 -0
- package/src/public/share-model.ts +6 -2
- package/src/public/site-model.ts +5 -3
- package/src/testing/auth-server.ts +377 -20
- package/src/testing/cloud.ts +83 -0
- package/src/testing/fixtures.ts +26 -0
- package/src/testing/index.ts +10 -6
- package/src/testing/seed.ts +120 -0
- package/dist/chunk-J7XHAAFJ.js.map +0 -1
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connecting through Synap Cloud: the person picks their pod on the Control
|
|
3
|
+
* Plane instead of typing its address.
|
|
4
|
+
*
|
|
5
|
+
* app POST <cloud handler> → 303 <cp>/connect/pod?client_id=<CIMD url>&state=<nonce>
|
|
6
|
+
* CP the person signs in, picks a pod → 302 <initiate_login_uri>?iss=<pod issuer>&state=<nonce>
|
|
7
|
+
* app GET <cloud handler>?iss&state → state checked against this browser's sealed
|
|
8
|
+
* cookie → the SAME sign-in as a typed address
|
|
9
|
+
* (discovery, fresh PKCE + state, `iss` on the answer)
|
|
10
|
+
*
|
|
11
|
+
* The CP never sees a code or a token, and takes the return URL from the
|
|
12
|
+
* app's client metadata, never from the query. `state` is what makes the
|
|
13
|
+
* return trustworthy: it is drawn here, kept sealed in an HttpOnly cookie of
|
|
14
|
+
* this browser, and compared in constant time, so an `iss` link that another
|
|
15
|
+
* site sends a visitor to (login CSRF into an attacker's pod) is refused. A
|
|
16
|
+
* return without a matching `state` never starts a sign-in.
|
|
17
|
+
*
|
|
18
|
+
* The CP's answer only names a pod. The pod itself is then validated exactly
|
|
19
|
+
* like a typed address (`discoverPod`), and its answer like any other.
|
|
20
|
+
*/
|
|
21
|
+
import { canonicalPodUrl } from "./discovery";
|
|
22
|
+
import { constantTimeEqual, randomToken } from "./encoding";
|
|
23
|
+
import { ConnectError, fromOAuthError } from "./errors";
|
|
24
|
+
import { CLOUD_STATE_RE, LOOPBACK_HOSTS } from "./rules";
|
|
25
|
+
import { seal, unseal, type AppSecret } from "./seal";
|
|
26
|
+
|
|
27
|
+
/** How long a Synap Cloud sign-in may take, from the click to the pod chooser's return. */
|
|
28
|
+
export const CLOUD_TRANSACTION_TTL_MS = 10 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
/** A sealed `CloudTransaction`: opaque, safe to put in an HttpOnly cookie. */
|
|
32
|
+
export type SealedCloudTransaction = string & { readonly __sealedCloudTransaction: true };
|
|
33
|
+
|
|
34
|
+
/** What a started Synap Cloud sign-in remembers (sealed; never sent to the CP). */
|
|
35
|
+
export interface CloudTransaction {
|
|
36
|
+
v: 1;
|
|
37
|
+
state: string;
|
|
38
|
+
clientId: string;
|
|
39
|
+
/** The CP base it was sent to. */
|
|
40
|
+
cloudUrl: string;
|
|
41
|
+
next: string | null;
|
|
42
|
+
spaceHint: string | null;
|
|
43
|
+
prompt: "consent" | null;
|
|
44
|
+
createdAt: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface CloudConnectConfig {
|
|
48
|
+
/** The Control Plane's base URL, e.g. https://api.synap.live. Unset = no Synap Cloud option. */
|
|
49
|
+
cloudUrl?: string | null;
|
|
50
|
+
/** Accept a CP on http://localhost (development). */
|
|
51
|
+
allowInsecureLoopback?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The CP base (no trailing slash), or a ConnectError (`misconfigured`). */
|
|
55
|
+
export function cloudBaseUrl(cloudUrl: string, opts: { allowInsecureLoopback?: boolean } = {}): string {
|
|
56
|
+
let url: URL;
|
|
57
|
+
try {
|
|
58
|
+
url = new URL(cloudUrl.trim());
|
|
59
|
+
} catch {
|
|
60
|
+
throw new ConnectError("misconfigured", "cloudUrl is not a URL");
|
|
61
|
+
}
|
|
62
|
+
const loopback = LOOPBACK_HOSTS.has(url.hostname);
|
|
63
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback && opts.allowInsecureLoopback)) {
|
|
64
|
+
throw new ConnectError("misconfigured", "cloudUrl must be https (http only on localhost in development)");
|
|
65
|
+
}
|
|
66
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
67
|
+
throw new ConnectError("misconfigured", "cloudUrl has no credentials, query or fragment");
|
|
68
|
+
}
|
|
69
|
+
return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Whether "Continue with Synap Cloud" can be offered: a valid Control Plane URL is configured. */
|
|
73
|
+
export function cloudConnectAvailable(config?: CloudConnectConfig | null): boolean {
|
|
74
|
+
if (!config?.cloudUrl) return false;
|
|
75
|
+
try {
|
|
76
|
+
cloudBaseUrl(config.cloudUrl, config);
|
|
77
|
+
return true;
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface StartCloudConnectOptions extends CloudConnectConfig {
|
|
84
|
+
/** The app's client_id (its CIMD URL): the CP reads `initiate_login_uri` from it. */
|
|
85
|
+
clientId: string;
|
|
86
|
+
/**
|
|
87
|
+
* SERVER-ONLY app secret that seals the transaction. Required: without it the call throws `misconfigured`.
|
|
88
|
+
* Optional in the type only for 1.x semver; required in 2.0.
|
|
89
|
+
*/
|
|
90
|
+
secret?: AppSecret;
|
|
91
|
+
/** A same-origin path to land on afterwards (validate it first: `safeNextPath`). */
|
|
92
|
+
next?: string | null;
|
|
93
|
+
/** A space hint for the pod's consent (`synap_space_hint`); a pod may ignore it. */
|
|
94
|
+
spaceHint?: string | null;
|
|
95
|
+
/** Show the pod's consent again even if this browser is already connected to the pod the person picks. */
|
|
96
|
+
prompt?: "consent" | null;
|
|
97
|
+
/** @deprecated Ignored: the SDK always draws its own 32-byte state. Removed in 2.0. */
|
|
98
|
+
state?: string;
|
|
99
|
+
now?: () => number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface StartedCloudConnect {
|
|
103
|
+
/** Redirect the browser here (303): the CP's pod chooser. */
|
|
104
|
+
url: string;
|
|
105
|
+
/** Store in an HttpOnly cookie until the CP returns. */
|
|
106
|
+
transaction: SealedCloudTransaction;
|
|
107
|
+
/** ms since epoch. */
|
|
108
|
+
expiresAt: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Start a Synap Cloud sign-in. Throws `cp_unavailable` at once when no CP URL
|
|
113
|
+
* is configured, `misconfigured` without a secret: both SYNCHRONOUSLY, before
|
|
114
|
+
* a Promise exists, so wrap the call in try/catch rather than relying on
|
|
115
|
+
* `.catch` (kept from 1.3 for compatibility; 2.0 makes it a plain async
|
|
116
|
+
* function with a required secret).
|
|
117
|
+
*/
|
|
118
|
+
export function startCloudConnect(opts: StartCloudConnectOptions): Promise<StartedCloudConnect> {
|
|
119
|
+
if (!cloudConnectAvailable(opts)) {
|
|
120
|
+
throw new ConnectError("cp_unavailable", "cp: not configured — pass cloudUrl (the Control Plane URL) to offer Synap Cloud");
|
|
121
|
+
}
|
|
122
|
+
if (!opts.secret) throw new ConnectError("misconfigured", "startCloudConnect needs the app secret to seal its state");
|
|
123
|
+
const base = cloudBaseUrl(opts.cloudUrl!, opts);
|
|
124
|
+
const secret = opts.secret;
|
|
125
|
+
return (async () => {
|
|
126
|
+
const now = (opts.now ?? Date.now)();
|
|
127
|
+
const state = randomToken();
|
|
128
|
+
const url = new URL(`${base}/connect/pod`);
|
|
129
|
+
url.searchParams.set("client_id", opts.clientId);
|
|
130
|
+
url.searchParams.set("state", state);
|
|
131
|
+
const tx: CloudTransaction = {
|
|
132
|
+
v: 1,
|
|
133
|
+
state,
|
|
134
|
+
clientId: opts.clientId,
|
|
135
|
+
cloudUrl: base,
|
|
136
|
+
next: opts.next ?? null,
|
|
137
|
+
spaceHint: opts.spaceHint ?? null,
|
|
138
|
+
prompt: opts.prompt === "consent" ? "consent" : null,
|
|
139
|
+
createdAt: now,
|
|
140
|
+
};
|
|
141
|
+
const expiresAt = now + CLOUD_TRANSACTION_TTL_MS;
|
|
142
|
+
const transaction = (await seal(tx, { secret, purpose: "synap-connect/cloud", expiresAt })) as SealedCloudTransaction;
|
|
143
|
+
return { url: url.href, transaction, expiresAt };
|
|
144
|
+
})();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface AcceptCloudReturnOptions {
|
|
148
|
+
/** The URL the CP sent the browser back to (`?iss=…&state=…`). */
|
|
149
|
+
url: string | URL;
|
|
150
|
+
transaction: string | null | undefined;
|
|
151
|
+
clientId: string;
|
|
152
|
+
secret: AppSecret;
|
|
153
|
+
allowInsecureLoopback?: boolean;
|
|
154
|
+
now?: () => number;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface CloudReturn {
|
|
158
|
+
/** The pod the person picked (canonical issuer), still to be discovered and validated. */
|
|
159
|
+
pod: string;
|
|
160
|
+
next: string | null;
|
|
161
|
+
spaceHint: string | null;
|
|
162
|
+
prompt: "consent" | null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Check the CP's return against the sealed transaction of THIS browser.
|
|
167
|
+
* Throws `expired_transaction` (none, foreign, tampered or older than 10
|
|
168
|
+
* minutes), `mismatch` (the state is not this browser's, or no pod is named),
|
|
169
|
+
* `denied` (the person cancelled on the chooser: `error=access_denied`, no
|
|
170
|
+
* `iss`; nothing is started), `invalid_pod_address`.
|
|
171
|
+
* `state` is compared BEFORE anything else in the query is used.
|
|
172
|
+
*/
|
|
173
|
+
export async function acceptCloudReturn(opts: AcceptCloudReturnOptions): Promise<CloudReturn> {
|
|
174
|
+
if (!opts.transaction) throw new ConnectError("expired_transaction", "No Synap Cloud sign-in in progress in this browser");
|
|
175
|
+
const opened = await unseal<CloudTransaction>(opts.transaction, {
|
|
176
|
+
secret: opts.secret,
|
|
177
|
+
purpose: "synap-connect/cloud",
|
|
178
|
+
now: (opts.now ?? Date.now)(),
|
|
179
|
+
});
|
|
180
|
+
if (!opened.ok) {
|
|
181
|
+
throw new ConnectError(
|
|
182
|
+
"expired_transaction",
|
|
183
|
+
opened.reason === "expired" ? "The Synap Cloud sign-in is older than 10 minutes" : "The Synap Cloud cookie is not one this app sealed",
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
const tx = opened.value;
|
|
187
|
+
if (!tx || tx.v !== 1 || typeof tx.state !== "string") {
|
|
188
|
+
throw new ConnectError("expired_transaction", "The Synap Cloud cookie is not one this app sealed");
|
|
189
|
+
}
|
|
190
|
+
if (tx.clientId !== opts.clientId) {
|
|
191
|
+
throw new ConnectError("misconfigured", "The Synap Cloud sign-in was started for another client_id");
|
|
192
|
+
}
|
|
193
|
+
const params = new URL(opts.url).searchParams;
|
|
194
|
+
const state = params.get("state");
|
|
195
|
+
if (state === null || !CLOUD_STATE_RE.test(state) || !constantTimeEqual(state, tx.state)) {
|
|
196
|
+
throw new ConnectError("mismatch", "The Synap Cloud answer does not belong to the sign-in this browser started");
|
|
197
|
+
}
|
|
198
|
+
const error = params.get("error");
|
|
199
|
+
if (error !== null) {
|
|
200
|
+
throw new ConnectError(fromOAuthError(error, "authorize"), `Synap Cloud answered ${error}`, { oauthError: error });
|
|
201
|
+
}
|
|
202
|
+
const iss = params.get("iss");
|
|
203
|
+
if (!iss) throw new ConnectError("mismatch", "The Synap Cloud answer names no pod");
|
|
204
|
+
return {
|
|
205
|
+
pod: canonicalPodUrl(iss, opts),
|
|
206
|
+
next: tx.next,
|
|
207
|
+
spaceHint: tx.spaceHint,
|
|
208
|
+
prompt: tx.prompt === "consent" ? "consent" : null,
|
|
209
|
+
};
|
|
210
|
+
}
|
package/src/connect/discovery.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pod discovery
|
|
3
|
-
*
|
|
2
|
+
* Pod discovery: a typed address or a launch `iss` → the pod's authorization
|
|
3
|
+
* server metadata (RFC 8414), validated before any redirect.
|
|
4
4
|
*
|
|
5
5
|
* - the metadata's `issuer` must equal the address it was fetched for, byte
|
|
6
6
|
* for byte (RFC 8414 §3.3): a document naming another issuer is not used;
|
|
@@ -11,15 +11,17 @@
|
|
|
11
11
|
* The fetch runs on the app's server with an address a visitor typed, so it is
|
|
12
12
|
* fenced: https only (http only on loopback when `allowInsecureLoopback`), no
|
|
13
13
|
* IP literal outside public space, no single-label or `.local`/`.internal`
|
|
14
|
-
* host, no redirect followed, 5 s, 64 KB
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* host, no redirect followed, 5 s, 64 KB counted on the stream. At most three
|
|
15
|
+
* addresses are tried per call (`discoverPod`). Residual: a public DNS name
|
|
16
|
+
* that resolves to a private address cannot be refused without a resolver; a
|
|
17
|
+
* deployment that cares adds egress rules, and an app rate-limits the route
|
|
18
|
+
* that calls this (it fetches an address a visitor chose).
|
|
17
19
|
*
|
|
18
|
-
* Connecting through Synap Cloud (the
|
|
19
|
-
* available yet: `cloudConnectAvailable()` is false and `startCloudConnect`
|
|
20
|
-
* throws `cp_unavailable`.
|
|
20
|
+
* Connecting through Synap Cloud (the Control Plane's pod chooser) is ./cloud.ts.
|
|
21
21
|
*/
|
|
22
22
|
import { ConnectError, retryAfterSeconds } from "./errors";
|
|
23
|
+
import { readCapped, timeoutSignal } from "./http";
|
|
24
|
+
import { CLOUD_STATE_RE, LOOPBACK_HOSTS, SPACE_HINT_RE } from "./rules";
|
|
23
25
|
import type { PodIssuer } from "./types";
|
|
24
26
|
|
|
25
27
|
const METADATA_PATH = "/.well-known/oauth-authorization-server";
|
|
@@ -33,8 +35,6 @@ export interface DiscoverOptions {
|
|
|
33
35
|
now?: () => number;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
37
|
-
|
|
38
38
|
function isPrivateIpv4(host: string): boolean {
|
|
39
39
|
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
40
40
|
if (!m) return false;
|
|
@@ -104,10 +104,14 @@ function sameOriginEndpoint(value: unknown, issuer: string): string | null {
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
async function readJsonCapped(res: Response): Promise<unknown> {
|
|
107
|
-
const
|
|
108
|
-
if (
|
|
107
|
+
const read = await readCapped(res, MAX_METADATA_BYTES);
|
|
108
|
+
if (!read.ok) {
|
|
109
|
+
throw read.reason === "too_large"
|
|
110
|
+
? new ConnectError("not_a_pod", "Metadata document too large")
|
|
111
|
+
: new ConnectError("pod_unreachable", "The metadata answer broke off");
|
|
112
|
+
}
|
|
109
113
|
try {
|
|
110
|
-
return JSON.parse(text);
|
|
114
|
+
return JSON.parse(read.text);
|
|
111
115
|
} catch {
|
|
112
116
|
throw new ConnectError("not_a_pod", "Metadata is not JSON");
|
|
113
117
|
}
|
|
@@ -122,18 +126,25 @@ async function discoverIssuer(issuer: string, opts: DiscoverOptions): Promise<Po
|
|
|
122
126
|
method: "GET",
|
|
123
127
|
headers: { Accept: "application/json" },
|
|
124
128
|
redirect: "manual",
|
|
125
|
-
signal:
|
|
129
|
+
signal: timeoutSignal(TIMEOUT_MS),
|
|
126
130
|
});
|
|
127
131
|
} catch {
|
|
128
132
|
throw new ConnectError("pod_unreachable", "The pod did not answer");
|
|
129
133
|
}
|
|
134
|
+
if (res.status >= 300 && res.status < 400) {
|
|
135
|
+
await res.body?.cancel().catch(() => {});
|
|
136
|
+
throw new ConnectError("pod_moved", `The address answered ${res.status}: the pod moved (its new address is not followed)`);
|
|
137
|
+
}
|
|
130
138
|
if (res.status === 429) {
|
|
131
139
|
throw new ConnectError("rate_limited", "The pod asked to slow down", {
|
|
132
140
|
retryAfterSec: retryAfterSeconds(res.headers.get("retry-after"), (opts.now ?? Date.now)()),
|
|
133
141
|
});
|
|
134
142
|
}
|
|
135
143
|
if (res.status >= 500) throw new ConnectError("pod_unreachable", `The pod answered ${res.status}`);
|
|
136
|
-
if (res.status !== 200)
|
|
144
|
+
if (res.status !== 200) {
|
|
145
|
+
await res.body?.cancel().catch(() => {});
|
|
146
|
+
throw new ConnectError("not_a_pod", `No authorization server metadata (${res.status})`);
|
|
147
|
+
}
|
|
137
148
|
const doc = await readJsonCapped(res);
|
|
138
149
|
if (!doc || typeof doc !== "object" || Array.isArray(doc)) throw new ConnectError("not_a_pod", "Metadata is not an object");
|
|
139
150
|
const m = doc as Record<string, unknown>;
|
|
@@ -167,42 +178,53 @@ async function discoverIssuer(issuer: string, opts: DiscoverOptions): Promise<Po
|
|
|
167
178
|
};
|
|
168
179
|
}
|
|
169
180
|
|
|
181
|
+
/** Synap Cloud's pod domain: pods are `<name>.pod.synap.live`; `<name>.synap.live` and `pod.<name>.synap.live` are older names. */
|
|
182
|
+
const CLOUD_DOMAIN = "synap.live";
|
|
183
|
+
const CLOUD_POD_ZONE = `pod.${CLOUD_DOMAIN}`;
|
|
184
|
+
const POD_NAME_RE = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/;
|
|
185
|
+
|
|
186
|
+
/** The addresses to try after the typed one fails, in order (at most two). */
|
|
187
|
+
function fallbackIssuers(first: string): string[] {
|
|
188
|
+
const url = new URL(first);
|
|
189
|
+
const host = url.hostname;
|
|
190
|
+
if (url.pathname !== "/" || !host.includes(".") || host.endsWith(`.${CLOUD_POD_ZONE}`)) return [];
|
|
191
|
+
const label = host.endsWith(`.${CLOUD_DOMAIN}`) ? host.slice(0, -CLOUD_DOMAIN.length - 1) : null;
|
|
192
|
+
if (label !== null && POD_NAME_RE.test(label)) {
|
|
193
|
+
// A Synap Cloud name typed the old way: the pod's current name, then the old nested one.
|
|
194
|
+
return [`${url.protocol}//${label}.${CLOUD_POD_ZONE}`, `${url.protocol}//pod.${url.host}`];
|
|
195
|
+
}
|
|
196
|
+
return host.startsWith("pod.") ? [] : [`${url.protocol}//pod.${url.host}`];
|
|
197
|
+
}
|
|
198
|
+
|
|
170
199
|
/**
|
|
171
200
|
* A typed pod address (or a launch `iss`) → its validated authorization server.
|
|
172
|
-
* Tries the address, then
|
|
173
|
-
* "pod.
|
|
201
|
+
* Tries the address, then the pod names it may stand for: on Synap Cloud,
|
|
202
|
+
* "you.synap.live" finds "you.pod.synap.live" (and the older
|
|
203
|
+
* "pod.you.synap.live"); elsewhere, "example.com" finds "pod.example.com".
|
|
204
|
+
* A redirect is never followed: an old name that answers 308 counts as a
|
|
205
|
+
* miss (`pod_moved` when nothing else answers).
|
|
174
206
|
*/
|
|
175
207
|
export async function discoverPod(input: string, opts: DiscoverOptions = {}): Promise<PodIssuer> {
|
|
176
208
|
const first = canonicalPodUrl(input, opts);
|
|
209
|
+
let firstError: unknown;
|
|
177
210
|
try {
|
|
178
211
|
return await discoverIssuer(first, opts);
|
|
179
212
|
} catch (err) {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
if (!retry) throw err;
|
|
188
|
-
const second = `${url.protocol}//pod.${url.host}`;
|
|
213
|
+
firstError = err;
|
|
214
|
+
}
|
|
215
|
+
const retryable = (err: unknown) =>
|
|
216
|
+
err instanceof ConnectError && (err.code === "not_a_pod" || err.code === "pod_unreachable" || err.code === "pod_moved");
|
|
217
|
+
if (!retryable(firstError)) throw firstError;
|
|
218
|
+
let tooOld: ConnectError | null = null;
|
|
219
|
+
for (const candidate of fallbackIssuers(first)) {
|
|
189
220
|
try {
|
|
190
|
-
return await discoverIssuer(
|
|
191
|
-
} catch (
|
|
221
|
+
return await discoverIssuer(candidate, opts);
|
|
222
|
+
} catch (err) {
|
|
192
223
|
// A pod that is there but too old says more than "nothing answered".
|
|
193
|
-
|
|
224
|
+
if (err instanceof ConnectError && err.code === "pod_unsupported") tooOld ??= err;
|
|
194
225
|
}
|
|
195
226
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
/** Whether "Continue with Synap Cloud" can be offered. False until the CP pod chooser (C1) ships. */
|
|
199
|
-
export function cloudConnectAvailable(): boolean {
|
|
200
|
-
return false;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** The Synap Cloud pod chooser (C1). Not available yet: always throws `cp_unavailable`. */
|
|
204
|
-
export function startCloudConnect(_opts: { clientId: string; state?: string }): never {
|
|
205
|
-
throw new ConnectError("cp_unavailable", "cp: not available — connecting through Synap Cloud is not built yet; use a pod address");
|
|
227
|
+
throw tooOld ?? firstError;
|
|
206
228
|
}
|
|
207
229
|
|
|
208
230
|
/**
|
|
@@ -229,18 +251,27 @@ export function safeNextPath(value: string | null | undefined, appOrigin: string
|
|
|
229
251
|
}
|
|
230
252
|
|
|
231
253
|
export interface LaunchHint {
|
|
232
|
-
/** The pod issuer the launcher named (`iss`), to pre-fill and CONFIRM
|
|
254
|
+
/** The pod issuer the launcher named (`iss`), to pre-fill and CONFIRM, never to auto-connect. */
|
|
233
255
|
pod: string | null;
|
|
234
256
|
/** Where to land after connecting (`target_link_uri`), same origin only. */
|
|
235
257
|
next: string | null;
|
|
236
|
-
/** A space
|
|
258
|
+
/** A space hint for the pod's consent (`synap_space_hint`); a pod may ignore it. */
|
|
237
259
|
spaceHint: string | null;
|
|
260
|
+
/**
|
|
261
|
+
* The `state` of a Synap Cloud return (the CP pod chooser answers
|
|
262
|
+
* `initiate_login_uri?iss=…&state=…`, or `?state=…&error=access_denied` when
|
|
263
|
+
* the person cancelled), shape-checked only. NOT verified here:
|
|
264
|
+
* send the request's query to the cloud handler (GET `paths.cloud`), which
|
|
265
|
+
* checks it against this browser's sealed cookie. Never act on `pod` because
|
|
266
|
+
* a state is present.
|
|
267
|
+
*/
|
|
268
|
+
cloudState: string | null;
|
|
238
269
|
}
|
|
239
270
|
|
|
240
271
|
/**
|
|
241
|
-
* Read a third-party-initiated login (OIDC Core §4
|
|
272
|
+
* Read a third-party-initiated login (OIDC Core §4): the browser
|
|
242
273
|
* opens the app's `initiate_login_uri?iss=…&target_link_uri=…`. One parser for
|
|
243
|
-
* the browser launch and
|
|
274
|
+
* the browser launch and the Synap Cloud chooser's return (`cloudState`).
|
|
244
275
|
*/
|
|
245
276
|
export function acceptLaunch(url: string | URL, appOrigin: string, opts: { allowInsecureLoopback?: boolean } = {}): LaunchHint {
|
|
246
277
|
const params = new URL(url).searchParams;
|
|
@@ -254,9 +285,13 @@ export function acceptLaunch(url: string | URL, appOrigin: string, opts: { allow
|
|
|
254
285
|
}
|
|
255
286
|
}
|
|
256
287
|
const hint = params.get("synap_space_hint");
|
|
288
|
+
const state = params.get("state");
|
|
257
289
|
return {
|
|
258
290
|
pod,
|
|
259
291
|
next: safeNextPath(params.get("target_link_uri"), appOrigin),
|
|
260
|
-
spaceHint: hint &&
|
|
292
|
+
spaceHint: hint && SPACE_HINT_RE.test(hint) ? hint : null,
|
|
293
|
+
// A browser launch never carries a state: any well-formed one is a Synap Cloud return
|
|
294
|
+
// (a pick: iss + state; a cancel: state + error=access_denied, no iss).
|
|
295
|
+
cloudState: state && CLOUD_STATE_RE.test(state) ? state : null,
|
|
261
296
|
};
|
|
262
297
|
}
|
package/src/connect/encoding.ts
CHANGED
package/src/connect/errors.ts
CHANGED
|
@@ -13,7 +13,7 @@ export type ConnectErrorCode =
|
|
|
13
13
|
| "pod_unreachable"
|
|
14
14
|
/** A Synap pod, but too old for app sign-in (no CIMD, no `iss`, no S256). */
|
|
15
15
|
| "pod_unsupported"
|
|
16
|
-
/** Connecting through Synap Cloud is not
|
|
16
|
+
/** Connecting through Synap Cloud is not configured for this app (no Control Plane URL). */
|
|
17
17
|
| "cp_unavailable"
|
|
18
18
|
/** The person said no on the consent screen. */
|
|
19
19
|
| "denied"
|
|
@@ -29,6 +29,12 @@ export type ConnectErrorCode =
|
|
|
29
29
|
| "expired"
|
|
30
30
|
/** The pod refused the connection's refresh token while it should still live. */
|
|
31
31
|
| "revoked"
|
|
32
|
+
/**
|
|
33
|
+
* The pod answered a redirect where it must answer itself (a moved pod
|
|
34
|
+
* answers 308 from its old name for a while): connect again with its new
|
|
35
|
+
* address. The new address is never taken from the redirect.
|
|
36
|
+
*/
|
|
37
|
+
| "pod_moved"
|
|
32
38
|
/** The pod answered something the SDK cannot use. */
|
|
33
39
|
| "server_error";
|
|
34
40
|
|
|
@@ -69,6 +75,7 @@ export const CONNECT_ERROR_CODES: readonly ConnectErrorCode[] = [
|
|
|
69
75
|
"rate_limited",
|
|
70
76
|
"expired",
|
|
71
77
|
"revoked",
|
|
78
|
+
"pod_moved",
|
|
72
79
|
"server_error",
|
|
73
80
|
];
|
|
74
81
|
|
package/src/connect/flow.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The OAuth 2.1 authorization-code flow of a pod app
|
|
2
|
+
* The OAuth 2.1 authorization-code flow of a pod app, as
|
|
3
3
|
* plain functions over a SEALED transaction string. `createPodAppAuth` wires
|
|
4
4
|
* them to cookies and route handlers; use these directly for another store.
|
|
5
5
|
*
|
|
@@ -18,15 +18,19 @@
|
|
|
18
18
|
import { ConnectError, fromOAuthError, retryAfterSeconds } from "./errors";
|
|
19
19
|
import { constantTimeEqual, pkceChallenge, randomToken } from "./encoding";
|
|
20
20
|
import { discoverPod, type DiscoverOptions } from "./discovery";
|
|
21
|
+
import { readCapped, timeoutSignal } from "./http";
|
|
22
|
+
import { LOOPBACK_HOSTS } from "./rules";
|
|
21
23
|
import { seal, unseal, type AppSecret } from "./seal";
|
|
22
|
-
import type { PodConnection, PodGrant, PodIssuer, PodUser } from "./types";
|
|
24
|
+
import type { PodActsAs, PodConnection, PodGrant, PodIssuer, PodUser } from "./types";
|
|
23
25
|
|
|
24
26
|
/** How long a started sign-in stays valid. */
|
|
25
27
|
export const TRANSACTION_TTL_MS = 10 * 60 * 1000;
|
|
26
|
-
/** The refresh token's idle life when the pod does not say (
|
|
28
|
+
/** The refresh token's idle life when the pod does not say (the pod's: 30 days). */
|
|
27
29
|
export const DEFAULT_REFRESH_IDLE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
28
30
|
const DEFAULT_ACCESS_TTL_SEC = 3600;
|
|
29
31
|
const TIMEOUT_MS = 10_000;
|
|
32
|
+
/** A token response is a few KB; more is not a token response. */
|
|
33
|
+
const MAX_TOKEN_RESPONSE_BYTES = 64 * 1024;
|
|
30
34
|
|
|
31
35
|
/** A sealed `ConnectTransaction`: opaque, safe to put in an HttpOnly cookie. */
|
|
32
36
|
export type SealedTransaction = string & { readonly __sealedTransaction: true };
|
|
@@ -66,6 +70,12 @@ export interface StartConnectOptions extends FlowOptions {
|
|
|
66
70
|
spaceHint?: string | null;
|
|
67
71
|
/** A same-origin path to land on afterwards (validate it first: `safeNextPath`). */
|
|
68
72
|
next?: string | null;
|
|
73
|
+
/**
|
|
74
|
+
* `"consent"`: the person asked to see the consent screen again (to widen or
|
|
75
|
+
* narrow what the app may touch). Sent as OIDC `prompt=consent`; the sign-in
|
|
76
|
+
* is otherwise the same (fresh PKCE verifier and state).
|
|
77
|
+
*/
|
|
78
|
+
prompt?: "consent" | null;
|
|
69
79
|
allowInsecureLoopback?: boolean;
|
|
70
80
|
}
|
|
71
81
|
|
|
@@ -114,6 +124,7 @@ export async function startConnect(opts: StartConnectOptions): Promise<StartedCo
|
|
|
114
124
|
url.searchParams.set("code_challenge_method", "S256");
|
|
115
125
|
if (resource) url.searchParams.set("resource", resource);
|
|
116
126
|
if (opts.spaceHint) url.searchParams.set("synap_space_hint", opts.spaceHint);
|
|
127
|
+
if (opts.prompt === "consent") url.searchParams.set("prompt", "consent");
|
|
117
128
|
|
|
118
129
|
const tx: ConnectTransaction = {
|
|
119
130
|
v: 1,
|
|
@@ -233,20 +244,30 @@ async function tokenRequest(
|
|
|
233
244
|
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
|
234
245
|
body: body.toString(),
|
|
235
246
|
redirect: "manual",
|
|
236
|
-
signal:
|
|
247
|
+
signal: timeoutSignal(TIMEOUT_MS),
|
|
237
248
|
});
|
|
238
249
|
} catch {
|
|
239
250
|
throw new ConnectError("pod_unreachable", "The pod's token endpoint did not answer");
|
|
240
251
|
}
|
|
252
|
+
if (res.status >= 300 && res.status < 400) {
|
|
253
|
+
await res.body?.cancel().catch(() => {});
|
|
254
|
+
throw new ConnectError("pod_moved", `The token endpoint answered ${res.status}: the pod moved; connect again with its new address`);
|
|
255
|
+
}
|
|
241
256
|
if (res.status === 429) {
|
|
242
257
|
throw new ConnectError("rate_limited", "The pod asked to slow down", {
|
|
243
258
|
retryAfterSec: retryAfterSeconds(res.headers.get("retry-after"), (opts.now ?? Date.now)()),
|
|
244
259
|
});
|
|
245
260
|
}
|
|
246
261
|
if (res.status >= 500) throw new ConnectError("pod_unreachable", `The token endpoint answered ${res.status}`);
|
|
262
|
+
const read = await readCapped(res, MAX_TOKEN_RESPONSE_BYTES);
|
|
263
|
+
if (!read.ok) {
|
|
264
|
+
throw read.reason === "too_large"
|
|
265
|
+
? new ConnectError("server_error", "The token endpoint's answer is too large")
|
|
266
|
+
: new ConnectError("pod_unreachable", "The token endpoint's answer broke off");
|
|
267
|
+
}
|
|
247
268
|
let json: unknown;
|
|
248
269
|
try {
|
|
249
|
-
json =
|
|
270
|
+
json = JSON.parse(read.text);
|
|
250
271
|
} catch {
|
|
251
272
|
throw new ConnectError("server_error", `The token endpoint answered ${res.status} without JSON`);
|
|
252
273
|
}
|
|
@@ -263,9 +284,46 @@ function parseGrant(value: unknown): PodGrant[] | null {
|
|
|
263
284
|
const out: PodGrant[] = [];
|
|
264
285
|
for (const g of value) {
|
|
265
286
|
if (!g || typeof g !== "object") return null;
|
|
266
|
-
const { permission, workspaceId, workspaceName } = g as Record<string, unknown>;
|
|
287
|
+
const { permission, workspaceId, workspaceName, templateSlug } = g as Record<string, unknown>;
|
|
267
288
|
if (typeof permission !== "string" || typeof workspaceId !== "string") return null;
|
|
268
|
-
|
|
289
|
+
const row: PodGrant = { permission, workspaceId, workspaceName: typeof workspaceName === "string" ? workspaceName : "" };
|
|
290
|
+
// The space's template, omitted when it came from none.
|
|
291
|
+
if (typeof templateSlug === "string" && templateSlug.length > 0 && templateSlug.length <= 200) row.templateSlug = templateSlug;
|
|
292
|
+
out.push(row);
|
|
293
|
+
}
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* `synap_acts_as`: read from EVERY token response, never carried
|
|
299
|
+
* over from the previous one, so a pod that stops saying "person" (or never
|
|
300
|
+
* did) gives an agent connection.
|
|
301
|
+
*/
|
|
302
|
+
function parseActsAs(value: unknown): PodActsAs | undefined {
|
|
303
|
+
return value === "person" || value === "agent" ? value : undefined;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const APP_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* `synap_app` (Synap extension): the pod's id for this app connection and its
|
|
310
|
+
* page for it. The page is on the pod's console, which may be another host
|
|
311
|
+
* than the issuer, so it is kept when it is an absolute https URL (http only on
|
|
312
|
+
* loopback) without credentials; anything else is dropped.
|
|
313
|
+
*/
|
|
314
|
+
function parseApp(value: unknown): { appId?: string; manageUrl?: string } {
|
|
315
|
+
if (!value || typeof value !== "object") return {};
|
|
316
|
+
const { id, manageUrl } = value as Record<string, unknown>;
|
|
317
|
+
const out: { appId?: string; manageUrl?: string } = {};
|
|
318
|
+
if (typeof id === "string" && APP_ID_RE.test(id)) out.appId = id;
|
|
319
|
+
if (typeof manageUrl === "string" && manageUrl.length <= 2048) {
|
|
320
|
+
try {
|
|
321
|
+
const url = new URL(manageUrl);
|
|
322
|
+
const secure = url.protocol === "https:" || (url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname));
|
|
323
|
+
if (secure && !url.username && !url.password) out.manageUrl = url.href;
|
|
324
|
+
} catch {
|
|
325
|
+
// not a URL: no link
|
|
326
|
+
}
|
|
269
327
|
}
|
|
270
328
|
return out;
|
|
271
329
|
}
|
|
@@ -278,7 +336,9 @@ function parseUser(value: unknown): PodUser | null {
|
|
|
278
336
|
|
|
279
337
|
/**
|
|
280
338
|
* A token response → a connection. On a refresh, `previous` supplies what the
|
|
281
|
-
* response may omit (grant, user).
|
|
339
|
+
* response may omit (grant, user, app; never `actsAs`). `expires_in` is read as sent: a refresh
|
|
340
|
+
* answered within the pod's reuse grace hands back the live key the
|
|
341
|
+
* first redeem got, with less than an hour left. A refresh response WITHOUT a new
|
|
282
342
|
* refresh_token drops the old one: with single-use rotation, resending a
|
|
283
343
|
* consumed token reads as theft and revokes the whole family.
|
|
284
344
|
*/
|
|
@@ -322,6 +382,13 @@ export function parseTokenResponse(
|
|
|
322
382
|
user,
|
|
323
383
|
connectedAt: ctx.previous?.connectedAt ?? ctx.now,
|
|
324
384
|
};
|
|
385
|
+
const app = parseApp(body.synap_app);
|
|
386
|
+
const appId = app.appId ?? ctx.previous?.appId;
|
|
387
|
+
const manageUrl = app.manageUrl ?? ctx.previous?.manageUrl;
|
|
388
|
+
if (appId !== undefined) connection.appId = appId;
|
|
389
|
+
if (manageUrl !== undefined) connection.manageUrl = manageUrl;
|
|
390
|
+
const actsAs = parseActsAs(body.synap_acts_as);
|
|
391
|
+
if (actsAs !== undefined) connection.actsAs = actsAs;
|
|
325
392
|
if (typeof refresh_token === "string" && refresh_token !== "") {
|
|
326
393
|
connection.refreshToken = refresh_token;
|
|
327
394
|
const idle =
|
|
@@ -335,7 +402,7 @@ export function parseTokenResponse(
|
|
|
335
402
|
|
|
336
403
|
/**
|
|
337
404
|
* How long a rotation's result is remembered for its SPENT refresh token.
|
|
338
|
-
* The pod
|
|
405
|
+
* The pod refuses a concurrent second redeem softly, but a token
|
|
339
406
|
* presented AGAIN after its use is a reuse: the whole family and the live
|
|
340
407
|
* access key are revoked. A browser easily sends that replay: parallel
|
|
341
408
|
* requests, a second tab, a response it had not stored yet, all still
|
|
@@ -361,7 +428,7 @@ function pruneRotations(now: number): void {
|
|
|
361
428
|
|
|
362
429
|
/**
|
|
363
430
|
* Exchange the refresh token for a new access token and the rotated refresh
|
|
364
|
-
* token (
|
|
431
|
+
* token (single-use, 30 days from issue, its reach re-derived by the pod).
|
|
365
432
|
*
|
|
366
433
|
* SINGLE-FLIGHT per connection within this process: concurrent callers share
|
|
367
434
|
* one request, and for `ROTATION_MEMORY_MS` after it a caller still holding
|
|
@@ -375,7 +442,7 @@ function pruneRotations(now: number): void {
|
|
|
375
442
|
* (invalid_grant while it should live: spent, revoked, the app removed or
|
|
376
443
|
* narrowed to nothing, or a concurrent redeem lost), `misconfigured`
|
|
377
444
|
* (invalid_client, unauthorized_client), `rate_limited`, `pod_unreachable`,
|
|
378
|
-
* `server_error`.
|
|
445
|
+
* `pod_moved` (the token endpoint answered a redirect), `server_error`.
|
|
379
446
|
*/
|
|
380
447
|
export function refreshConnection(connection: PodConnection, opts: FlowOptions = {}): Promise<PodConnection> {
|
|
381
448
|
const clock = opts.now ?? Date.now;
|
|
@@ -438,8 +505,9 @@ export async function revokeConnection(connection: PodConnection, opts: FlowOpti
|
|
|
438
505
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
439
506
|
body: new URLSearchParams({ token, token_type_hint: hint, client_id: connection.clientId }).toString(),
|
|
440
507
|
redirect: "manual",
|
|
441
|
-
signal:
|
|
508
|
+
signal: timeoutSignal(TIMEOUT_MS),
|
|
442
509
|
});
|
|
510
|
+
await res.body?.cancel().catch(() => {});
|
|
443
511
|
if (res.status !== 200) revoked = false;
|
|
444
512
|
} catch {
|
|
445
513
|
revoked = false;
|