@synap-core/sdk 1.2.0 → 1.4.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/README.md +154 -0
- package/dist/chunk-J7XHAAFJ.js +79 -0
- package/dist/chunk-J7XHAAFJ.js.map +1 -0
- package/dist/connect.browser.cjs +22 -0
- package/dist/connect.browser.cjs.map +1 -0
- package/dist/connect.browser.d.cts +2 -0
- package/dist/connect.browser.d.ts +2 -0
- package/dist/connect.browser.js +5 -0
- package/dist/connect.browser.js.map +1 -0
- package/dist/connect.cjs +1487 -0
- package/dist/connect.cjs.map +1 -0
- package/dist/connect.d.cts +647 -0
- package/dist/connect.d.ts +647 -0
- package/dist/connect.js +1365 -0
- package/dist/connect.js.map +1 -0
- package/dist/testing.cjs +431 -19
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +160 -1
- package/dist/testing.d.ts +160 -1
- package/dist/testing.js +404 -19
- package/dist/testing.js.map +1 -1
- package/dist/types-C2KGNXEn.d.cts +95 -0
- package/dist/types-C2KGNXEn.d.ts +95 -0
- package/package.json +18 -10
- package/src/connect/auth.ts +535 -0
- package/src/connect/browser.ts +10 -0
- package/src/connect/client.ts +299 -0
- package/src/connect/cloud.ts +206 -0
- package/src/connect/discovery.ts +263 -0
- package/src/connect/encoding.ts +93 -0
- package/src/connect/errors.ts +115 -0
- package/src/connect/flow.ts +489 -0
- package/src/connect/guard.ts +25 -0
- package/src/connect/index.ts +87 -0
- package/src/connect/seal.ts +119 -0
- package/src/connect/store.ts +189 -0
- package/src/connect/types.ts +100 -0
- package/src/testing/auth-server.ts +512 -0
- package/src/testing/cloud.ts +84 -0
- package/src/testing/index.ts +7 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pod discovery (spec 17 §6.3): a typed address or a launch `iss` → the pod's
|
|
3
|
+
* authorization server metadata (RFC 8414), validated before any redirect.
|
|
4
|
+
*
|
|
5
|
+
* - the metadata's `issuer` must equal the address it was fetched for, byte
|
|
6
|
+
* for byte (RFC 8414 §3.3): a document naming another issuer is not used;
|
|
7
|
+
* - it must advertise CIMD clients, `iss` on every authorization response
|
|
8
|
+
* (RFC 9207) and PKCE S256, else the pod is too old for app sign-in;
|
|
9
|
+
* - every endpoint must live on the issuer's own origin.
|
|
10
|
+
*
|
|
11
|
+
* The fetch runs on the app's server with an address a visitor typed, so it is
|
|
12
|
+
* fenced: https only (http only on loopback when `allowInsecureLoopback`), no
|
|
13
|
+
* IP literal outside public space, no single-label or `.local`/`.internal`
|
|
14
|
+
* host, no redirect followed, 5 s, 64 KB. Residual: a public DNS name that
|
|
15
|
+
* resolves to a private address cannot be refused without a resolver; a
|
|
16
|
+
* deployment that cares adds egress rules.
|
|
17
|
+
*
|
|
18
|
+
* Connecting through Synap Cloud (the CP pod chooser, wave C1) is ./cloud.ts.
|
|
19
|
+
*/
|
|
20
|
+
import { ConnectError, retryAfterSeconds } from "./errors";
|
|
21
|
+
import type { PodIssuer } from "./types";
|
|
22
|
+
|
|
23
|
+
const METADATA_PATH = "/.well-known/oauth-authorization-server";
|
|
24
|
+
const TIMEOUT_MS = 5_000;
|
|
25
|
+
const MAX_METADATA_BYTES = 64 * 1024;
|
|
26
|
+
|
|
27
|
+
export interface DiscoverOptions {
|
|
28
|
+
fetch?: typeof fetch;
|
|
29
|
+
/** Allow `http://localhost` (and 127.0.0.1, [::1]) pods: development only. */
|
|
30
|
+
allowInsecureLoopback?: boolean;
|
|
31
|
+
now?: () => number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
35
|
+
|
|
36
|
+
function isPrivateIpv4(host: string): boolean {
|
|
37
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
38
|
+
if (!m) return false;
|
|
39
|
+
const [a, b] = [Number(m[1]), Number(m[2])];
|
|
40
|
+
return (
|
|
41
|
+
a === 0 ||
|
|
42
|
+
a === 10 ||
|
|
43
|
+
a === 127 ||
|
|
44
|
+
(a === 100 && b >= 64 && b <= 127) ||
|
|
45
|
+
(a === 169 && b === 254) ||
|
|
46
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
47
|
+
(a === 192 && b === 168) ||
|
|
48
|
+
a >= 224
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The canonical issuer for a typed address, or a ConnectError. Adds `https://`
|
|
54
|
+
* when no scheme is given; drops a trailing slash.
|
|
55
|
+
*/
|
|
56
|
+
export function canonicalPodUrl(input: string, opts: { allowInsecureLoopback?: boolean } = {}): string {
|
|
57
|
+
const raw = input.trim();
|
|
58
|
+
if (!raw || raw.length > 512) throw new ConnectError("invalid_pod_address", "Empty or overlong pod address");
|
|
59
|
+
let url: URL;
|
|
60
|
+
try {
|
|
61
|
+
url = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`);
|
|
62
|
+
} catch {
|
|
63
|
+
throw new ConnectError("invalid_pod_address", "Not a URL");
|
|
64
|
+
}
|
|
65
|
+
const host = url.hostname.toLowerCase();
|
|
66
|
+
const loopback = LOOPBACK_HOSTS.has(host); // WHATWG keeps the brackets: "[::1]"
|
|
67
|
+
if (url.protocol === "http:" && !(loopback && opts.allowInsecureLoopback)) {
|
|
68
|
+
throw new ConnectError("invalid_pod_address", "A pod address must use https");
|
|
69
|
+
}
|
|
70
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
71
|
+
throw new ConnectError("invalid_pod_address", "A pod address must use https");
|
|
72
|
+
}
|
|
73
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
74
|
+
throw new ConnectError("invalid_pod_address", "A pod address has no credentials, query or fragment");
|
|
75
|
+
}
|
|
76
|
+
if (!(loopback && opts.allowInsecureLoopback)) {
|
|
77
|
+
if (loopback || host.startsWith("[") || isPrivateIpv4(host) || /^\d+$/.test(host)) {
|
|
78
|
+
throw new ConnectError("invalid_pod_address", "A pod address must be a public host name");
|
|
79
|
+
}
|
|
80
|
+
if (!host.includes(".") || /\.(local|internal|localhost|lan|home\.arpa)$/.test(host)) {
|
|
81
|
+
throw new ConnectError("invalid_pod_address", "A pod address must be a public host name");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const path = url.pathname.replace(/\/+$/, "");
|
|
85
|
+
return `${url.origin}${path}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function metadataUrl(issuer: string): string {
|
|
89
|
+
const url = new URL(issuer);
|
|
90
|
+
const path = url.pathname === "/" ? "" : url.pathname;
|
|
91
|
+
return `${url.origin}${METADATA_PATH}${path}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function sameOriginEndpoint(value: unknown, issuer: string): string | null {
|
|
95
|
+
if (typeof value !== "string") return null;
|
|
96
|
+
try {
|
|
97
|
+
const url = new URL(value);
|
|
98
|
+
return url.origin === new URL(issuer).origin && !url.username && !url.hash ? url.href : null;
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function readJsonCapped(res: Response): Promise<unknown> {
|
|
105
|
+
const text = await res.text();
|
|
106
|
+
if (text.length > MAX_METADATA_BYTES) throw new ConnectError("not_a_pod", "Metadata document too large");
|
|
107
|
+
try {
|
|
108
|
+
return JSON.parse(text);
|
|
109
|
+
} catch {
|
|
110
|
+
throw new ConnectError("not_a_pod", "Metadata is not JSON");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Fetch and validate one candidate issuer's metadata. */
|
|
115
|
+
async function discoverIssuer(issuer: string, opts: DiscoverOptions): Promise<PodIssuer> {
|
|
116
|
+
const doFetch = opts.fetch ?? fetch;
|
|
117
|
+
let res: Response;
|
|
118
|
+
try {
|
|
119
|
+
res = await doFetch(metadataUrl(issuer), {
|
|
120
|
+
method: "GET",
|
|
121
|
+
headers: { Accept: "application/json" },
|
|
122
|
+
redirect: "manual",
|
|
123
|
+
signal: typeof AbortSignal.timeout === "function" ? AbortSignal.timeout(TIMEOUT_MS) : undefined,
|
|
124
|
+
});
|
|
125
|
+
} catch {
|
|
126
|
+
throw new ConnectError("pod_unreachable", "The pod did not answer");
|
|
127
|
+
}
|
|
128
|
+
if (res.status === 429) {
|
|
129
|
+
throw new ConnectError("rate_limited", "The pod asked to slow down", {
|
|
130
|
+
retryAfterSec: retryAfterSeconds(res.headers.get("retry-after"), (opts.now ?? Date.now)()),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
if (res.status >= 500) throw new ConnectError("pod_unreachable", `The pod answered ${res.status}`);
|
|
134
|
+
if (res.status !== 200) throw new ConnectError("not_a_pod", `No authorization server metadata (${res.status})`);
|
|
135
|
+
const doc = await readJsonCapped(res);
|
|
136
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc)) throw new ConnectError("not_a_pod", "Metadata is not an object");
|
|
137
|
+
const m = doc as Record<string, unknown>;
|
|
138
|
+
|
|
139
|
+
// RFC 8414 §3.3 — the mix-up defence at discovery time.
|
|
140
|
+
if (m.issuer !== issuer) {
|
|
141
|
+
throw new ConnectError("not_a_pod", "The metadata names a different issuer than the address it was fetched from");
|
|
142
|
+
}
|
|
143
|
+
const list = (v: unknown) => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : null);
|
|
144
|
+
const pkce = list(m.code_challenge_methods_supported);
|
|
145
|
+
const responseTypes = list(m.response_types_supported);
|
|
146
|
+
if (
|
|
147
|
+
m.authorization_response_iss_parameter_supported !== true ||
|
|
148
|
+
m.client_id_metadata_document_supported !== true ||
|
|
149
|
+
!pkce?.includes("S256") ||
|
|
150
|
+
(responseTypes !== null && !responseTypes.includes("code"))
|
|
151
|
+
) {
|
|
152
|
+
throw new ConnectError("pod_unsupported", "This pod does not support app sign-in (CIMD, iss, S256)");
|
|
153
|
+
}
|
|
154
|
+
const authorizationEndpoint = sameOriginEndpoint(m.authorization_endpoint, issuer);
|
|
155
|
+
const tokenEndpoint = sameOriginEndpoint(m.token_endpoint, issuer);
|
|
156
|
+
if (!authorizationEndpoint || !tokenEndpoint) {
|
|
157
|
+
throw new ConnectError("pod_unsupported", "The pod's endpoints are missing or not on its own origin");
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
issuer,
|
|
161
|
+
authorizationEndpoint,
|
|
162
|
+
tokenEndpoint,
|
|
163
|
+
revocationEndpoint: sameOriginEndpoint(m.revocation_endpoint, issuer),
|
|
164
|
+
refreshSupported: list(m.grant_types_supported)?.includes("refresh_token") ?? false,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* A typed pod address (or a launch `iss`) → its validated authorization server.
|
|
170
|
+
* Tries the address, then `pod.<host>` (so "antoine.synap.live" finds
|
|
171
|
+
* "pod.antoine.synap.live").
|
|
172
|
+
*/
|
|
173
|
+
export async function discoverPod(input: string, opts: DiscoverOptions = {}): Promise<PodIssuer> {
|
|
174
|
+
const first = canonicalPodUrl(input, opts);
|
|
175
|
+
try {
|
|
176
|
+
return await discoverIssuer(first, opts);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
const url = new URL(first);
|
|
179
|
+
const retry =
|
|
180
|
+
err instanceof ConnectError &&
|
|
181
|
+
(err.code === "not_a_pod" || err.code === "pod_unreachable") &&
|
|
182
|
+
!url.hostname.startsWith("pod.") &&
|
|
183
|
+
url.pathname === "/" &&
|
|
184
|
+
url.hostname.includes(".");
|
|
185
|
+
if (!retry) throw err;
|
|
186
|
+
const second = `${url.protocol}//pod.${url.host}`;
|
|
187
|
+
try {
|
|
188
|
+
return await discoverIssuer(second, opts);
|
|
189
|
+
} catch (err2) {
|
|
190
|
+
// A pod that is there but too old says more than "nothing answered".
|
|
191
|
+
throw err2 instanceof ConnectError && err2.code === "pod_unsupported" ? err2 : err;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* A same-origin path to return to after connecting, or null. Accepts a
|
|
198
|
+
* relative path (never `//host` or `/\host`) or an absolute URL on the app's
|
|
199
|
+
* own origin; anything else is dropped (no open redirect).
|
|
200
|
+
*/
|
|
201
|
+
export function safeNextPath(value: string | null | undefined, appOrigin: string): string | null {
|
|
202
|
+
if (!value || value.length > 2048) return null;
|
|
203
|
+
if (value.startsWith("/") && !value.startsWith("//") && !value.startsWith("/\\")) {
|
|
204
|
+
try {
|
|
205
|
+
const url = new URL(value, appOrigin);
|
|
206
|
+
return url.origin === appOrigin ? `${url.pathname}${url.search}${url.hash}` : null;
|
|
207
|
+
} catch {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
const url = new URL(value);
|
|
213
|
+
return url.origin === appOrigin ? `${url.pathname}${url.search}${url.hash}` : null;
|
|
214
|
+
} catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface LaunchHint {
|
|
220
|
+
/** The pod issuer the launcher named (`iss`), to pre-fill and CONFIRM (T6), never to auto-connect. */
|
|
221
|
+
pod: string | null;
|
|
222
|
+
/** Where to land after connecting (`target_link_uri`), same origin only. */
|
|
223
|
+
next: string | null;
|
|
224
|
+
/** A space to preselect on consent (`synap_space_hint`). */
|
|
225
|
+
spaceHint: string | null;
|
|
226
|
+
/**
|
|
227
|
+
* The `state` of a Synap Cloud return (the CP pod chooser answers
|
|
228
|
+
* `initiate_login_uri?iss=…&state=…`, or `?state=…&error=access_denied` when
|
|
229
|
+
* the person cancelled), shape-checked only. NOT verified here:
|
|
230
|
+
* send the request's query to the cloud handler (GET `paths.cloud`), which
|
|
231
|
+
* checks it against this browser's sealed cookie. Never act on `pod` because
|
|
232
|
+
* a state is present.
|
|
233
|
+
*/
|
|
234
|
+
cloudState: string | null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Read a third-party-initiated login (OIDC Core §4, spec 17 §7.2): the browser
|
|
239
|
+
* opens the app's `initiate_login_uri?iss=…&target_link_uri=…`. One parser for
|
|
240
|
+
* the browser launch and the Synap Cloud chooser's return (`cloudState`).
|
|
241
|
+
*/
|
|
242
|
+
export function acceptLaunch(url: string | URL, appOrigin: string, opts: { allowInsecureLoopback?: boolean } = {}): LaunchHint {
|
|
243
|
+
const params = new URL(url).searchParams;
|
|
244
|
+
let pod: string | null = null;
|
|
245
|
+
const iss = params.get("iss");
|
|
246
|
+
if (iss) {
|
|
247
|
+
try {
|
|
248
|
+
pod = canonicalPodUrl(iss, opts);
|
|
249
|
+
} catch {
|
|
250
|
+
pod = null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const hint = params.get("synap_space_hint");
|
|
254
|
+
const state = params.get("state");
|
|
255
|
+
return {
|
|
256
|
+
pod,
|
|
257
|
+
next: safeNextPath(params.get("target_link_uri"), appOrigin),
|
|
258
|
+
spaceHint: hint && /^[A-Za-z0-9_-]{1,64}$/.test(hint) ? hint : null,
|
|
259
|
+
// A browser launch never carries a state: any well-formed one is a Synap Cloud return
|
|
260
|
+
// (a pick: iss + state; a cancel: state + error=access_denied, no iss).
|
|
261
|
+
cloudState: state && /^[A-Za-z0-9._~-]{16,256}$/.test(state) ? state : null,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web-standard primitives (WebCrypto, TextEncoder, btoa/atob): the same code
|
|
3
|
+
* runs on Node 18+, edge runtimes and workers. No Node import.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const encoder = new TextEncoder();
|
|
7
|
+
const decoder = new TextDecoder();
|
|
8
|
+
|
|
9
|
+
/** `Uint8Array<ArrayBuffer>`: what WebCrypto's `BufferSource` takes under TS 5.7+ lib types. */
|
|
10
|
+
export type Bytes = Uint8Array<ArrayBuffer>;
|
|
11
|
+
|
|
12
|
+
export function utf8(text: string): Bytes {
|
|
13
|
+
return encoder.encode(text);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function fromUtf8(bytes: Uint8Array): string {
|
|
17
|
+
return decoder.decode(bytes);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function base64url(bytes: Uint8Array): string {
|
|
21
|
+
let binary = "";
|
|
22
|
+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
23
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Null when `text` is not base64url. */
|
|
27
|
+
export function fromBase64url(text: string): Bytes | null {
|
|
28
|
+
if (!/^[A-Za-z0-9_-]*$/.test(text)) return null;
|
|
29
|
+
const padded = text.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((text.length + 3) % 4);
|
|
30
|
+
let binary: string;
|
|
31
|
+
try {
|
|
32
|
+
binary = atob(padded);
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const out = new Uint8Array(binary.length);
|
|
37
|
+
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function randomBytes(length: number): Bytes {
|
|
42
|
+
const out = new Uint8Array(length);
|
|
43
|
+
crypto.getRandomValues(out);
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 32 random bytes, base64url (43 chars): a state value or a PKCE verifier. */
|
|
48
|
+
export function randomToken(): string {
|
|
49
|
+
return base64url(randomBytes(32));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** RFC 7636 §4.2: `BASE64URL(SHA256(ASCII(code_verifier)))`. */
|
|
53
|
+
export async function pkceChallenge(verifier: string): Promise<string> {
|
|
54
|
+
const digest = await crypto.subtle.digest("SHA-256", utf8(verifier));
|
|
55
|
+
return base64url(new Uint8Array(digest));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Compare two strings without an early exit on the first differing byte. */
|
|
59
|
+
export function constantTimeEqual(a: string, b: string): boolean {
|
|
60
|
+
const x = utf8(a);
|
|
61
|
+
const y = utf8(b);
|
|
62
|
+
let diff = x.length ^ y.length;
|
|
63
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) diff |= (x[i] ?? 0) ^ (y[i] ?? 0);
|
|
64
|
+
return diff === 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The request's cookies by name (the first value wins, as browsers order the most specific first). */
|
|
68
|
+
export function parseCookies(header: string | null): Map<string, string> {
|
|
69
|
+
const out = new Map<string, string>();
|
|
70
|
+
if (!header) return out;
|
|
71
|
+
for (const part of header.split(";")) {
|
|
72
|
+
const eq = part.indexOf("=");
|
|
73
|
+
if (eq < 0) continue;
|
|
74
|
+
const name = part.slice(0, eq).trim();
|
|
75
|
+
if (!name || out.has(name)) continue;
|
|
76
|
+
let value = part.slice(eq + 1).trim();
|
|
77
|
+
if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1);
|
|
78
|
+
out.set(name, value);
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface CookieAttributes {
|
|
84
|
+
maxAgeSec: number;
|
|
85
|
+
secure: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** `HttpOnly; SameSite=Lax; Path=/` always; `Secure` on https. Never a `Domain`. */
|
|
89
|
+
export function serializeCookie(name: string, value: string, attrs: CookieAttributes): string {
|
|
90
|
+
const parts = [`${name}=${value}`, "Path=/", "HttpOnly", "SameSite=Lax", `Max-Age=${Math.max(0, Math.floor(attrs.maxAgeSec))}`];
|
|
91
|
+
if (attrs.secure) parts.push("Secure");
|
|
92
|
+
return parts.join("; ");
|
|
93
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one error type of `@synap-core/sdk/connect`. `code` is the contract a UI
|
|
3
|
+
* branches on; `message` and `detail` are for the developer's logs, never for
|
|
4
|
+
* the person (a sign-in failure page says one sentence per code, never why).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type ConnectErrorCode =
|
|
8
|
+
/** The typed pod address is not a usable URL. */
|
|
9
|
+
| "invalid_pod_address"
|
|
10
|
+
/** Something answered, but it is not a Synap pod (no AS metadata). */
|
|
11
|
+
| "not_a_pod"
|
|
12
|
+
/** The pod did not answer (network, timeout, 5xx). */
|
|
13
|
+
| "pod_unreachable"
|
|
14
|
+
/** A Synap pod, but too old for app sign-in (no CIMD, no `iss`, no S256). */
|
|
15
|
+
| "pod_unsupported"
|
|
16
|
+
/** Connecting through Synap Cloud is not configured for this app (no Control Plane URL). */
|
|
17
|
+
| "cp_unavailable"
|
|
18
|
+
/** The person said no on the consent screen. */
|
|
19
|
+
| "denied"
|
|
20
|
+
/** No sign-in in progress here, or it is older than 10 minutes. */
|
|
21
|
+
| "expired_transaction"
|
|
22
|
+
/** The answer does not belong to the sign-in this browser started (`state` or `iss`). */
|
|
23
|
+
| "mismatch"
|
|
24
|
+
/** The app's own setup is wrong (client metadata, scopes, secret, redirect). */
|
|
25
|
+
| "misconfigured"
|
|
26
|
+
/** The pod asked to slow down; see `retryAfterSec`. */
|
|
27
|
+
| "rate_limited"
|
|
28
|
+
/** The connection lapsed (refresh token idle past its life, or none was issued). */
|
|
29
|
+
| "expired"
|
|
30
|
+
/** The pod refused the connection's refresh token while it should still live. */
|
|
31
|
+
| "revoked"
|
|
32
|
+
/** The pod answered something the SDK cannot use. */
|
|
33
|
+
| "server_error";
|
|
34
|
+
|
|
35
|
+
export class ConnectError extends Error {
|
|
36
|
+
readonly code: ConnectErrorCode;
|
|
37
|
+
/** Seconds to wait, from `Retry-After`, when `code` is `rate_limited`. */
|
|
38
|
+
readonly retryAfterSec?: number;
|
|
39
|
+
/** The OAuth error code the pod sent, when there was one (logs only). */
|
|
40
|
+
readonly oauthError?: string;
|
|
41
|
+
|
|
42
|
+
constructor(
|
|
43
|
+
code: ConnectErrorCode,
|
|
44
|
+
message: string,
|
|
45
|
+
extra: { retryAfterSec?: number; oauthError?: string } = {},
|
|
46
|
+
) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = "ConnectError";
|
|
49
|
+
this.code = code;
|
|
50
|
+
if (extra.retryAfterSec !== undefined) this.retryAfterSec = extra.retryAfterSec;
|
|
51
|
+
if (extra.oauthError !== undefined) this.oauthError = extra.oauthError;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function isConnectError(err: unknown): err is ConnectError {
|
|
56
|
+
return err instanceof ConnectError;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const CONNECT_ERROR_CODES: readonly ConnectErrorCode[] = [
|
|
60
|
+
"invalid_pod_address",
|
|
61
|
+
"not_a_pod",
|
|
62
|
+
"pod_unreachable",
|
|
63
|
+
"pod_unsupported",
|
|
64
|
+
"cp_unavailable",
|
|
65
|
+
"denied",
|
|
66
|
+
"expired_transaction",
|
|
67
|
+
"mismatch",
|
|
68
|
+
"misconfigured",
|
|
69
|
+
"rate_limited",
|
|
70
|
+
"expired",
|
|
71
|
+
"revoked",
|
|
72
|
+
"server_error",
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
/** Parse an untrusted `?error=` value back into a code (null when unknown). */
|
|
76
|
+
export function parseConnectErrorCode(value: string | null | undefined): ConnectErrorCode | null {
|
|
77
|
+
return (CONNECT_ERROR_CODES as readonly string[]).includes(value ?? "") ? (value as ConnectErrorCode) : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* RFC 6749 §4.1.2.1 / §5.2 error codes → ours. `invalid_grant` at the token
|
|
82
|
+
* endpoint during a callback means the code was used, expired or does not
|
|
83
|
+
* match: for the person that is "the link expired".
|
|
84
|
+
*/
|
|
85
|
+
export function fromOAuthError(oauthError: string, during: "authorize" | "token" | "refresh"): ConnectErrorCode {
|
|
86
|
+
switch (oauthError) {
|
|
87
|
+
case "access_denied":
|
|
88
|
+
return "denied";
|
|
89
|
+
case "temporarily_unavailable":
|
|
90
|
+
case "server_error":
|
|
91
|
+
return "pod_unreachable";
|
|
92
|
+
case "invalid_grant":
|
|
93
|
+
return during === "refresh" ? "revoked" : "expired_transaction";
|
|
94
|
+
case "invalid_request":
|
|
95
|
+
case "invalid_client":
|
|
96
|
+
case "invalid_scope":
|
|
97
|
+
case "invalid_target":
|
|
98
|
+
case "unauthorized_client":
|
|
99
|
+
case "unsupported_grant_type":
|
|
100
|
+
case "unsupported_response_type":
|
|
101
|
+
return "misconfigured";
|
|
102
|
+
default:
|
|
103
|
+
return "server_error";
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** `Retry-After` as whole seconds (delta-seconds or an HTTP date); undefined when unusable. */
|
|
108
|
+
export function retryAfterSeconds(header: string | null, now: number): number | undefined {
|
|
109
|
+
if (!header) return undefined;
|
|
110
|
+
const trimmed = header.trim();
|
|
111
|
+
if (/^\d{1,9}$/.test(trimmed)) return Number(trimmed);
|
|
112
|
+
const at = Date.parse(trimmed);
|
|
113
|
+
if (Number.isNaN(at)) return undefined;
|
|
114
|
+
return Math.max(0, Math.ceil((at - now) / 1000));
|
|
115
|
+
}
|