runline 0.37.0 → 0.39.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/dist/credentials/broker.js +32 -16
- package/dist/credentials/cookies.d.ts +33 -0
- package/dist/credentials/cookies.js +151 -0
- package/dist/credentials/http.d.ts +28 -3
- package/dist/credentials/http.js +34 -10
- package/dist/credentials/plugin.d.ts +21 -2
- package/dist/credentials/plugin.js +8 -3
- package/dist/credentials/policy.js +2 -0
- package/dist/credentials/registry.d.ts +25 -0
- package/dist/credentials/registry.js +84 -0
- package/dist/credentials/transport.d.ts +24 -0
- package/dist/credentials/transport.js +222 -7
- package/dist/credentials/types.d.ts +74 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +3 -3
- package/dist/plugins/_shared/attachments.js +97 -0
- package/dist/plugins/_shared/microsoftAuth.js +33 -4
- package/dist/plugins/_shared/schemas.js +8 -0
- package/dist/plugins/agentMail/src/drafts.js +2 -1
- package/dist/plugins/agentMail/src/inboxes.js +2 -1
- package/dist/plugins/agentMail/src/messages.js +2 -1
- package/dist/plugins/agentMail/src/schemas.js +4 -41
- package/dist/plugins/agentMail/src/shared.js +28 -50
- package/dist/plugins/agentMail/src/threads.js +2 -1
- package/dist/plugins/gmail/src/index.js +10 -22
- package/dist/plugins/microsoftCalendar/src/index.js +513 -17
- package/dist/plugins/microsoftFiles/src/index.js +3 -3
- package/dist/plugins/microsoftMail/src/index.js +305 -54
- package/dist/plugins/todoist/src/credentials.js +4 -11
- package/dist/plugins/todoist/src/index.js +390 -281
- package/dist/plugins/zohoMail/src/credentials.js +92 -0
- package/dist/plugins/zohoMail/src/index.js +241 -0
- package/package.json +1 -1
|
@@ -78,11 +78,26 @@ function joinedPart(current, source) {
|
|
|
78
78
|
}
|
|
79
79
|
return joined;
|
|
80
80
|
}
|
|
81
|
+
/** An issued credential as stored flat: stamped with the authority it was issued under. */
|
|
82
|
+
function stamped(issued, expected) {
|
|
83
|
+
return { ...issued, binding: expected };
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* A flat issued credential without its stamp, when it was issued under
|
|
87
|
+
* `expected`; undefined for one issued under any other selection, which is
|
|
88
|
+
* never used.
|
|
89
|
+
*/
|
|
90
|
+
function issuedUnder(stored, expected) {
|
|
91
|
+
if (!stored || typeof stored !== "object")
|
|
92
|
+
return undefined;
|
|
93
|
+
const { binding, ...issued } = stored;
|
|
94
|
+
return binding === expected ? issued : undefined;
|
|
95
|
+
}
|
|
81
96
|
/**
|
|
82
97
|
* How an issued credential is stored flat: an OAuth grant as its token
|
|
83
|
-
* fields, a role session as `awsSession`,
|
|
84
|
-
* (`expected`) it was issued under. Only
|
|
85
|
-
* written back.
|
|
98
|
+
* fields, a role session as `awsSession`, a cookie jar as `cookieSession`,
|
|
99
|
+
* each stamped with the authority (`expected`) it was issued under. Only
|
|
100
|
+
* those three are ever renewed and written back.
|
|
86
101
|
*/
|
|
87
102
|
function writeBack(auth, expected) {
|
|
88
103
|
if (auth.kind === "awsSigV4" && auth.assumeRole) {
|
|
@@ -91,9 +106,16 @@ function writeBack(auth, expected) {
|
|
|
91
106
|
const session = patch[sessionField];
|
|
92
107
|
if (!session?.sessionToken)
|
|
93
108
|
throw new AuthError("invalid_credentials");
|
|
94
|
-
return { awsSession:
|
|
109
|
+
return { awsSession: stamped(session, expected) };
|
|
95
110
|
};
|
|
96
111
|
}
|
|
112
|
+
if (auth.kind === "cookie")
|
|
113
|
+
return (patch) => {
|
|
114
|
+
const session = patch[auth.field];
|
|
115
|
+
if (!session?.revision)
|
|
116
|
+
throw new AuthError("invalid_credentials");
|
|
117
|
+
return { cookieSession: stamped(session, expected) };
|
|
118
|
+
};
|
|
97
119
|
if (auth.kind !== "oauth2")
|
|
98
120
|
throw new AuthError("invalid_definition");
|
|
99
121
|
return (patch, current) => {
|
|
@@ -136,23 +158,17 @@ function localSigner(ctx, declaration) {
|
|
|
136
158
|
const key = selection.awsProfile
|
|
137
159
|
? await awsProfileKey(selection.awsProfile)
|
|
138
160
|
: staticSecret(current, selection.localSecret, ["sessionToken"]);
|
|
139
|
-
const
|
|
161
|
+
const session = issuedUnder(current.awsSession, expected);
|
|
140
162
|
const sessionField = auth.assumeRole?.sessionField;
|
|
141
163
|
return {
|
|
142
164
|
...(key ? { [auth.field]: key } : {}),
|
|
143
|
-
...(sessionField &&
|
|
144
|
-
? {
|
|
145
|
-
[sessionField]: {
|
|
146
|
-
accessKeyId: cached.accessKeyId,
|
|
147
|
-
secretAccessKey: cached.secretAccessKey,
|
|
148
|
-
sessionToken: cached.sessionToken,
|
|
149
|
-
expiresAt: cached.expiresAt,
|
|
150
|
-
revision: cached.revision,
|
|
151
|
-
},
|
|
152
|
-
}
|
|
153
|
-
: {}),
|
|
165
|
+
...(sessionField && session ? { [sessionField]: session } : {}),
|
|
154
166
|
};
|
|
155
167
|
}
|
|
168
|
+
if (auth.kind === "cookie") {
|
|
169
|
+
const session = issuedUnder(current.cookieSession, expected);
|
|
170
|
+
return session ? { [auth.field]: session } : {};
|
|
171
|
+
}
|
|
156
172
|
const compatible = current.authTokenBinding === undefined ||
|
|
157
173
|
current.authTokenBinding === expected;
|
|
158
174
|
const tokens = {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { StoredCookie } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* A cookie jar for one connection, after RFC 6265 §5: what a `Set-Cookie`
|
|
4
|
+
* stores, replaces or deletes, and which cookies a request carries. Pure:
|
|
5
|
+
* the transport decides which responses reach it (only its targets') and
|
|
6
|
+
* persists what it answers.
|
|
7
|
+
*/
|
|
8
|
+
/** The most cookies a jar keeps; the oldest go first beyond it. */
|
|
9
|
+
export declare const MAX_COOKIES = 64;
|
|
10
|
+
/** A cookie name: an RFC 7230 token. */
|
|
11
|
+
export declare function isCookieName(name: unknown): name is string;
|
|
12
|
+
/**
|
|
13
|
+
* One `Set-Cookie` from a response to `url`: the cookie it stores, with
|
|
14
|
+
* `expired` when it deletes (Max-Age ≤ 0, or an Expires in the past).
|
|
15
|
+
* Undefined for one a jar ignores: no name, a value or name it cannot
|
|
16
|
+
* send back, an oversized one, or a Domain the host does not match.
|
|
17
|
+
*/
|
|
18
|
+
export declare function parseSetCookie(header: string, url: URL, now: number): {
|
|
19
|
+
cookie: StoredCookie;
|
|
20
|
+
expired: boolean;
|
|
21
|
+
} | undefined;
|
|
22
|
+
/** The cookies still alive at `now`. */
|
|
23
|
+
export declare function liveCookies(cookies: readonly StoredCookie[], now: number): StoredCookie[];
|
|
24
|
+
/**
|
|
25
|
+
* The jar after a response to `url` set `headers`: each cookie replaces
|
|
26
|
+
* the one of the same name, domain and path, a deletion removes it, and
|
|
27
|
+
* expired cookies are dropped. At most `MAX_COOKIES`, newest kept.
|
|
28
|
+
*/
|
|
29
|
+
export declare function applySetCookies(cookies: readonly StoredCookie[], headers: readonly string[], url: URL, now: number): StoredCookie[];
|
|
30
|
+
/** The Cookie header for a request to `url`, or undefined when none match. */
|
|
31
|
+
export declare function cookieHeader(cookies: readonly StoredCookie[], url: URL, now: number): string | undefined;
|
|
32
|
+
/** The value of cookie `name` a request to `url` would carry. */
|
|
33
|
+
export declare function cookieFor(cookies: readonly StoredCookie[], name: string, url: URL, now: number): string | undefined;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
/**
|
|
3
|
+
* A cookie jar for one connection, after RFC 6265 §5: what a `Set-Cookie`
|
|
4
|
+
* stores, replaces or deletes, and which cookies a request carries. Pure:
|
|
5
|
+
* the transport decides which responses reach it (only its targets') and
|
|
6
|
+
* persists what it answers.
|
|
7
|
+
*/
|
|
8
|
+
/** The most cookies a jar keeps; the oldest go first beyond it. */
|
|
9
|
+
export const MAX_COOKIES = 64;
|
|
10
|
+
/** The largest name plus value a cookie may have (RFC 6265 §6.1's floor). */
|
|
11
|
+
const MAX_COOKIE_BYTES = 4096;
|
|
12
|
+
const TOKEN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
13
|
+
/** A cookie name: an RFC 7230 token. */
|
|
14
|
+
export function isCookieName(name) {
|
|
15
|
+
return typeof name === "string" && TOKEN.test(name);
|
|
16
|
+
}
|
|
17
|
+
/** A value safe to send back in a Cookie header: printable ASCII without `;`. */
|
|
18
|
+
function cookieValue(value) {
|
|
19
|
+
return /^[\x20-\x3a\x3c-\x7e]*$/.test(value);
|
|
20
|
+
}
|
|
21
|
+
function domainMatch(host, domain) {
|
|
22
|
+
return host === domain || (host.endsWith(`.${domain}`) && !isIP(host));
|
|
23
|
+
}
|
|
24
|
+
/** RFC 6265 §5.1.4: the directory of the request path. */
|
|
25
|
+
function defaultPath(pathname) {
|
|
26
|
+
if (!pathname.startsWith("/"))
|
|
27
|
+
return "/";
|
|
28
|
+
const last = pathname.lastIndexOf("/");
|
|
29
|
+
return last <= 0 ? "/" : pathname.slice(0, last);
|
|
30
|
+
}
|
|
31
|
+
function pathMatch(requestPath, cookiePath) {
|
|
32
|
+
return (requestPath === cookiePath ||
|
|
33
|
+
(requestPath.startsWith(cookiePath) &&
|
|
34
|
+
(cookiePath.endsWith("/") || requestPath[cookiePath.length] === "/")));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* One `Set-Cookie` from a response to `url`: the cookie it stores, with
|
|
38
|
+
* `expired` when it deletes (Max-Age ≤ 0, or an Expires in the past).
|
|
39
|
+
* Undefined for one a jar ignores: no name, a value or name it cannot
|
|
40
|
+
* send back, an oversized one, or a Domain the host does not match.
|
|
41
|
+
*/
|
|
42
|
+
export function parseSetCookie(header, url, now) {
|
|
43
|
+
const [pair, ...attributes] = header.split(";");
|
|
44
|
+
const equals = pair.indexOf("=");
|
|
45
|
+
if (equals < 0)
|
|
46
|
+
return undefined;
|
|
47
|
+
const name = pair.slice(0, equals).trim();
|
|
48
|
+
const value = pair.slice(equals + 1).trim();
|
|
49
|
+
if (!isCookieName(name) ||
|
|
50
|
+
!cookieValue(value) ||
|
|
51
|
+
name.length + value.length > MAX_COOKIE_BYTES)
|
|
52
|
+
return undefined;
|
|
53
|
+
const host = url.hostname.toLowerCase();
|
|
54
|
+
let domain;
|
|
55
|
+
let path;
|
|
56
|
+
let maxAge;
|
|
57
|
+
let expires;
|
|
58
|
+
let secure = false;
|
|
59
|
+
let httpOnly = false;
|
|
60
|
+
for (const attribute of attributes) {
|
|
61
|
+
const at = attribute.indexOf("=");
|
|
62
|
+
const key = (at < 0 ? attribute : attribute.slice(0, at))
|
|
63
|
+
.trim()
|
|
64
|
+
.toLowerCase();
|
|
65
|
+
const raw = at < 0 ? "" : attribute.slice(at + 1).trim();
|
|
66
|
+
if (key === "max-age" && /^-?\d+$/.test(raw))
|
|
67
|
+
maxAge = Number(raw);
|
|
68
|
+
else if (key === "expires") {
|
|
69
|
+
const parsed = Date.parse(raw);
|
|
70
|
+
if (Number.isFinite(parsed))
|
|
71
|
+
expires = parsed;
|
|
72
|
+
}
|
|
73
|
+
else if (key === "domain" && raw) {
|
|
74
|
+
domain = raw.replace(/^\./, "").toLowerCase();
|
|
75
|
+
}
|
|
76
|
+
else if (key === "path")
|
|
77
|
+
path = raw.startsWith("/") ? raw : undefined;
|
|
78
|
+
else if (key === "secure")
|
|
79
|
+
secure = true;
|
|
80
|
+
else if (key === "httponly")
|
|
81
|
+
httpOnly = true;
|
|
82
|
+
}
|
|
83
|
+
// A Domain the host does not match is refused, and so is a bare
|
|
84
|
+
// top-level label. A wider Domain still reaches only the method's own
|
|
85
|
+
// targets: the jar signs nothing else.
|
|
86
|
+
if (domain !== undefined &&
|
|
87
|
+
(!domainMatch(host, domain) || (domain !== host && !domain.includes("."))))
|
|
88
|
+
return undefined;
|
|
89
|
+
const until = maxAge !== undefined ? now + maxAge * 1000 : expires;
|
|
90
|
+
const expiresAt = until === undefined
|
|
91
|
+
? undefined
|
|
92
|
+
: Math.min(Math.max(0, until), Number.MAX_SAFE_INTEGER);
|
|
93
|
+
const cookie = {
|
|
94
|
+
name,
|
|
95
|
+
value,
|
|
96
|
+
domain: domain ?? host,
|
|
97
|
+
hostOnly: domain === undefined,
|
|
98
|
+
path: path ?? defaultPath(url.pathname),
|
|
99
|
+
secure,
|
|
100
|
+
httpOnly,
|
|
101
|
+
...(expiresAt !== undefined ? { expiresAt } : {}),
|
|
102
|
+
};
|
|
103
|
+
return { cookie, expired: expiresAt !== undefined && expiresAt <= now };
|
|
104
|
+
}
|
|
105
|
+
/** The cookies still alive at `now`. */
|
|
106
|
+
export function liveCookies(cookies, now) {
|
|
107
|
+
return cookies.filter((cookie) => cookie.expiresAt === undefined || cookie.expiresAt > now);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The jar after a response to `url` set `headers`: each cookie replaces
|
|
111
|
+
* the one of the same name, domain and path, a deletion removes it, and
|
|
112
|
+
* expired cookies are dropped. At most `MAX_COOKIES`, newest kept.
|
|
113
|
+
*/
|
|
114
|
+
export function applySetCookies(cookies, headers, url, now) {
|
|
115
|
+
let jar = liveCookies(cookies, now);
|
|
116
|
+
for (const header of headers) {
|
|
117
|
+
const parsed = parseSetCookie(header, url, now);
|
|
118
|
+
if (!parsed)
|
|
119
|
+
continue;
|
|
120
|
+
const { cookie, expired } = parsed;
|
|
121
|
+
jar = jar.filter((kept) => kept.name !== cookie.name ||
|
|
122
|
+
kept.domain !== cookie.domain ||
|
|
123
|
+
kept.path !== cookie.path);
|
|
124
|
+
if (!expired)
|
|
125
|
+
jar.push(cookie);
|
|
126
|
+
}
|
|
127
|
+
return jar.slice(-MAX_COOKIES);
|
|
128
|
+
}
|
|
129
|
+
/** The cookies a request to `url` carries, longest path first. */
|
|
130
|
+
function matching(cookies, url, now) {
|
|
131
|
+
const host = url.hostname.toLowerCase();
|
|
132
|
+
return liveCookies(cookies, now)
|
|
133
|
+
.filter((cookie) => (cookie.hostOnly
|
|
134
|
+
? host === cookie.domain
|
|
135
|
+
: domainMatch(host, cookie.domain)) &&
|
|
136
|
+
pathMatch(url.pathname, cookie.path) &&
|
|
137
|
+
(!cookie.secure || url.protocol === "https:"))
|
|
138
|
+
.sort((a, b) => b.path.length - a.path.length);
|
|
139
|
+
}
|
|
140
|
+
/** The Cookie header for a request to `url`, or undefined when none match. */
|
|
141
|
+
export function cookieHeader(cookies, url, now) {
|
|
142
|
+
const sent = matching(cookies, url, now);
|
|
143
|
+
return sent.length
|
|
144
|
+
? sent.map((cookie) => `${cookie.name}=${cookie.value}`).join("; ")
|
|
145
|
+
: undefined;
|
|
146
|
+
}
|
|
147
|
+
/** The value of cookie `name` a request to `url` would carry. */
|
|
148
|
+
export function cookieFor(cookies, name, url, now) {
|
|
149
|
+
return matching(cookies, url, now).find((cookie) => cookie.name === name)
|
|
150
|
+
?.value;
|
|
151
|
+
}
|
|
@@ -4,14 +4,39 @@ export interface ResourceOptions {
|
|
|
4
4
|
timeoutMs: number;
|
|
5
5
|
maxResponseBytes: number;
|
|
6
6
|
resumableUpload?: boolean;
|
|
7
|
+
/**
|
|
8
|
+
* The 3xx that come back as they are, Location and all, judged by their
|
|
9
|
+
* Location; nothing is followed. Without it, or when it refuses, a 3xx
|
|
10
|
+
* fails as transport_failed, bar a resumable upload's 308.
|
|
11
|
+
*/
|
|
12
|
+
redirect?: (location: string | null) => boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Receives the response's `Set-Cookie` values before they are stripped:
|
|
15
|
+
* the only way a cookie leaves this function, and never to a caller.
|
|
16
|
+
*/
|
|
17
|
+
setCookie?: (values: string[]) => void;
|
|
7
18
|
}
|
|
8
19
|
/** Internal send primitive. Callers validate destination policy before reaching it. */
|
|
9
20
|
export declare function sendResource(url: string, method: HttpMethod, headers: Headers, body: Buffer | undefined, options: ResourceOptions): Promise<Response>;
|
|
21
|
+
export interface PresignedRequestOptions {
|
|
22
|
+
/** Exact origins the URL may be on; anything else is refused. */
|
|
23
|
+
allowedOrigins: string[];
|
|
24
|
+
fetch: typeof globalThis.fetch;
|
|
25
|
+
method?: "GET" | "PUT" | "DELETE";
|
|
26
|
+
/** Request headers; credentials and cookies are never among them. */
|
|
27
|
+
headers?: Record<string, string>;
|
|
28
|
+
body?: Uint8Array;
|
|
29
|
+
maxResponseBytes?: number;
|
|
30
|
+
}
|
|
10
31
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
32
|
+
* One request to a provider-issued preauthenticated URL — a signed
|
|
33
|
+
* download, an upload session — without bearer tokens or cookies.
|
|
34
|
+
* Hosts approve exact origins and enforce DNS/IP egress through their
|
|
35
|
+
* fetch hook. The signed URL is itself secret: errors never include it.
|
|
36
|
+
* No redirects or retries.
|
|
14
37
|
*/
|
|
38
|
+
export declare function presignedRequest(value: string, options: PresignedRequestOptions): Promise<Response>;
|
|
39
|
+
/** A GET of a provider-issued preauthenticated URL; see presignedRequest. */
|
|
15
40
|
export declare function downloadResource(value: string, options: {
|
|
16
41
|
allowedOrigins: string[];
|
|
17
42
|
fetch: typeof globalThis.fetch;
|
package/dist/credentials/http.js
CHANGED
|
@@ -14,7 +14,7 @@ export async function sendResource(url, method, headers, body, options) {
|
|
|
14
14
|
method,
|
|
15
15
|
headers,
|
|
16
16
|
body: body === undefined ? undefined : Buffer.from(body),
|
|
17
|
-
redirect: "error",
|
|
17
|
+
redirect: options.redirect ? "manual" : "error",
|
|
18
18
|
credentials: "omit",
|
|
19
19
|
signal: controller.signal,
|
|
20
20
|
});
|
|
@@ -22,6 +22,7 @@ export async function sendResource(url, method, headers, body, options) {
|
|
|
22
22
|
response.redirected ||
|
|
23
23
|
(response.status >= 300 &&
|
|
24
24
|
response.status < 400 &&
|
|
25
|
+
!options.redirect?.(response.headers.get("location")) &&
|
|
25
26
|
!(options.resumableUpload &&
|
|
26
27
|
method === "PUT" &&
|
|
27
28
|
headers.has("content-range") &&
|
|
@@ -56,6 +57,7 @@ export async function sendResource(url, method, headers, body, options) {
|
|
|
56
57
|
controller.signal.removeEventListener("abort", cancel);
|
|
57
58
|
reader?.releaseLock();
|
|
58
59
|
}
|
|
60
|
+
options.setCookie?.(response.headers.getSetCookie());
|
|
59
61
|
const responseHeaders = new Headers(response.headers);
|
|
60
62
|
// Fetch supplies decoded bytes, not the original wire representation.
|
|
61
63
|
responseHeaders.delete("content-encoding");
|
|
@@ -78,11 +80,19 @@ export async function sendResource(url, method, headers, body, options) {
|
|
|
78
80
|
}
|
|
79
81
|
}
|
|
80
82
|
/**
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
83
|
+
* Headers a presigned request never sends: the URL is its only authority,
|
|
84
|
+
* so no credential or cookie rides along, and Destination belongs to the
|
|
85
|
+
* credential transport's COPY and MOVE.
|
|
84
86
|
*/
|
|
85
|
-
|
|
87
|
+
const PRESIGNED_REFUSED_HEADERS = ["authorization", "cookie", "destination"];
|
|
88
|
+
/**
|
|
89
|
+
* One request to a provider-issued preauthenticated URL — a signed
|
|
90
|
+
* download, an upload session — without bearer tokens or cookies.
|
|
91
|
+
* Hosts approve exact origins and enforce DNS/IP egress through their
|
|
92
|
+
* fetch hook. The signed URL is itself secret: errors never include it.
|
|
93
|
+
* No redirects or retries.
|
|
94
|
+
*/
|
|
95
|
+
export async function presignedRequest(value, options) {
|
|
86
96
|
let url;
|
|
87
97
|
try {
|
|
88
98
|
url = new URL(value);
|
|
@@ -101,9 +111,23 @@ export async function downloadResource(value, options) {
|
|
|
101
111
|
maxResponseBytes <= 0 ||
|
|
102
112
|
maxResponseBytes > 64 * 1024 * 1024)
|
|
103
113
|
throw new AuthError("invalid_definition");
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
114
|
+
const method = options.method ?? "GET";
|
|
115
|
+
const headers = new Headers();
|
|
116
|
+
try {
|
|
117
|
+
for (const [name, value] of Object.entries(options.headers ?? {})) {
|
|
118
|
+
if (PRESIGNED_REFUSED_HEADERS.includes(name.toLowerCase()))
|
|
119
|
+
throw new Error();
|
|
120
|
+
headers.set(name, value);
|
|
121
|
+
}
|
|
122
|
+
if (options.body !== undefined && method !== "PUT")
|
|
123
|
+
throw new Error();
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
throw new AuthError("request_not_allowed");
|
|
127
|
+
}
|
|
128
|
+
return sendResource(url.toString(), method, headers, options.body === undefined ? undefined : Buffer.from(options.body), { fetch: options.fetch, timeoutMs: 20_000, maxResponseBytes });
|
|
129
|
+
}
|
|
130
|
+
/** A GET of a provider-issued preauthenticated URL; see presignedRequest. */
|
|
131
|
+
export function downloadResource(value, options) {
|
|
132
|
+
return presignedRequest(value, options);
|
|
109
133
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The plugin side of the credential broker, public to every plugin —
|
|
3
3
|
* bundled or custom: one declaration factory for static keys, one stored
|
|
4
|
-
*
|
|
5
|
-
* HTTPS target, one request path.
|
|
4
|
+
* shape each for OAuth and cookie methods, one way to turn public config
|
|
5
|
+
* into an HTTPS target, one request path.
|
|
6
6
|
*/
|
|
7
7
|
import * as t from "typebox";
|
|
8
8
|
import type { ActionContext } from "../plugin/types.js";
|
|
@@ -24,6 +24,25 @@ export declare const grantSchema: t.TObject<{
|
|
|
24
24
|
revision: t.TString;
|
|
25
25
|
}>>;
|
|
26
26
|
}>;
|
|
27
|
+
/**
|
|
28
|
+
* The stored shape of a cookie method: one optional cookie jar in the
|
|
29
|
+
* `session` field, absent until the first login.
|
|
30
|
+
*/
|
|
31
|
+
export declare const cookieSessionSchema: t.TObject<{
|
|
32
|
+
session: t.TOptional<t.TObject<{
|
|
33
|
+
cookies: t.TArray<t.TObject<{
|
|
34
|
+
name: t.TString;
|
|
35
|
+
value: t.TString;
|
|
36
|
+
domain: t.TString;
|
|
37
|
+
hostOnly: t.TBoolean;
|
|
38
|
+
path: t.TString;
|
|
39
|
+
secure: t.TBoolean;
|
|
40
|
+
httpOnly: t.TBoolean;
|
|
41
|
+
expiresAt: t.TOptional<t.TInteger>;
|
|
42
|
+
}>>;
|
|
43
|
+
revision: t.TString;
|
|
44
|
+
}>>;
|
|
45
|
+
}>;
|
|
27
46
|
/**
|
|
28
47
|
* A flat config field, a fixed value such as Freshdesk's "X" password, or
|
|
29
48
|
* the two joined in order, such as Zendesk's `{email}/token` username.
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The plugin side of the credential broker, public to every plugin —
|
|
3
3
|
* bundled or custom: one declaration factory for static keys, one stored
|
|
4
|
-
*
|
|
5
|
-
* HTTPS target, one request path.
|
|
4
|
+
* shape each for OAuth and cookie methods, one way to turn public config
|
|
5
|
+
* into an HTTPS target, one request path.
|
|
6
6
|
*/
|
|
7
7
|
import * as t from "typebox";
|
|
8
8
|
import { AuthError } from "../auth/errors.js";
|
|
9
9
|
import { brokerFor } from "./broker.js";
|
|
10
|
-
import { OAuthGrantSchema, staticSecretSchema } from "./registry.js";
|
|
10
|
+
import { CookieSessionSchema, OAuthGrantSchema, staticSecretSchema, } from "./registry.js";
|
|
11
11
|
/**
|
|
12
12
|
* The stored shape of an OAuth method: one optional revisioned grant in the
|
|
13
13
|
* `grant` field, absent until the first issuance.
|
|
14
14
|
*/
|
|
15
15
|
export const grantSchema = t.Object({ grant: t.Optional(OAuthGrantSchema) }, { additionalProperties: false });
|
|
16
|
+
/**
|
|
17
|
+
* The stored shape of a cookie method: one optional cookie jar in the
|
|
18
|
+
* `session` field, absent until the first login.
|
|
19
|
+
*/
|
|
20
|
+
export const cookieSessionSchema = t.Object({ session: t.Optional(CookieSessionSchema) }, { additionalProperties: false });
|
|
16
21
|
/** The parts and placements a shorthand, or the general form, declares. */
|
|
17
22
|
function placementsOf(auth) {
|
|
18
23
|
if (auth.kind === "static") {
|
|
@@ -119,6 +119,8 @@ export function injectedHeaders(auth, target) {
|
|
|
119
119
|
return ["authorization"];
|
|
120
120
|
if (auth.kind === "awsSigV4")
|
|
121
121
|
return [...SIGV4_HEADERS];
|
|
122
|
+
if (auth.kind === "cookie")
|
|
123
|
+
return auth.csrf ? [headerName(auth.csrf.header)] : [];
|
|
122
124
|
return placementsFor(auth, target).flatMap((placement) => placement.in === "header" ||
|
|
123
125
|
placement.in === "jwt" ||
|
|
124
126
|
placement.in === "querySignature"
|
|
@@ -43,6 +43,31 @@ export declare const AwsSessionSchema: t.TObject<{
|
|
|
43
43
|
expiresAt: t.TInteger;
|
|
44
44
|
revision: t.TString;
|
|
45
45
|
}>;
|
|
46
|
+
/** Stored shape of one cookie in a session's jar. */
|
|
47
|
+
export declare const StoredCookieSchema: t.TObject<{
|
|
48
|
+
name: t.TString;
|
|
49
|
+
value: t.TString;
|
|
50
|
+
domain: t.TString;
|
|
51
|
+
hostOnly: t.TBoolean;
|
|
52
|
+
path: t.TString;
|
|
53
|
+
secure: t.TBoolean;
|
|
54
|
+
httpOnly: t.TBoolean;
|
|
55
|
+
expiresAt: t.TOptional<t.TInteger>;
|
|
56
|
+
}>;
|
|
57
|
+
/** Stored shape of a cookie session: its jar and the login it came from. */
|
|
58
|
+
export declare const CookieSessionSchema: t.TObject<{
|
|
59
|
+
cookies: t.TArray<t.TObject<{
|
|
60
|
+
name: t.TString;
|
|
61
|
+
value: t.TString;
|
|
62
|
+
domain: t.TString;
|
|
63
|
+
hostOnly: t.TBoolean;
|
|
64
|
+
path: t.TString;
|
|
65
|
+
secure: t.TBoolean;
|
|
66
|
+
httpOnly: t.TBoolean;
|
|
67
|
+
expiresAt: t.TOptional<t.TInteger>;
|
|
68
|
+
}>>;
|
|
69
|
+
revision: t.TString;
|
|
70
|
+
}>;
|
|
46
71
|
/** No implicit defaults, provider inference, executable expressions, or replacement. */
|
|
47
72
|
export declare class CredentialRegistry {
|
|
48
73
|
private readonly types;
|
|
@@ -4,6 +4,7 @@ import { AuthError } from "../auth/errors.js";
|
|
|
4
4
|
import { validateOAuth2ExchangePolicy } from "../auth/oauth2.js";
|
|
5
5
|
import { oauthEndpoint, providerHeaders, providerParameters, } from "../auth/token.js";
|
|
6
6
|
import { isAwsScope } from "./aws.js";
|
|
7
|
+
import { isCookieName, MAX_COOKIES } from "./cookies.js";
|
|
7
8
|
import { bounded, HTTP_METHODS, headerName, injectedHeaders, injectedParams, injectedPointers, placementsFor, refuseCredentialParams, resourceUrl, TARGET_RESPONSE_LIMIT_BYTES, TARGET_TIMEOUT_LIMIT_MS, TRANSPORT_HEADERS, targetBase, targetOf, } from "./policy.js";
|
|
8
9
|
/** Normalized grant storage; application registration remains host-owned. */
|
|
9
10
|
export const OAuthTokensSchema = t.Object({
|
|
@@ -44,6 +45,22 @@ export const AwsSessionSchema = t.Object({
|
|
|
44
45
|
expiresAt: t.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER }),
|
|
45
46
|
revision: t.String({ minLength: 1 }),
|
|
46
47
|
}, { additionalProperties: false });
|
|
48
|
+
/** Stored shape of one cookie in a session's jar. */
|
|
49
|
+
export const StoredCookieSchema = t.Object({
|
|
50
|
+
name: t.String({ minLength: 1 }),
|
|
51
|
+
value: t.String(),
|
|
52
|
+
domain: t.String({ minLength: 1 }),
|
|
53
|
+
hostOnly: t.Boolean(),
|
|
54
|
+
path: t.String({ minLength: 1 }),
|
|
55
|
+
secure: t.Boolean(),
|
|
56
|
+
httpOnly: t.Boolean(),
|
|
57
|
+
expiresAt: t.Optional(t.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER })),
|
|
58
|
+
}, { additionalProperties: false });
|
|
59
|
+
/** Stored shape of a cookie session: its jar and the login it came from. */
|
|
60
|
+
export const CookieSessionSchema = t.Object({
|
|
61
|
+
cookies: t.Array(StoredCookieSchema, { maxItems: MAX_COOKIES }),
|
|
62
|
+
revision: t.String({ minLength: 1 }),
|
|
63
|
+
}, { additionalProperties: false });
|
|
47
64
|
/** Whether two strict object schemas store the same fields, alike required. */
|
|
48
65
|
function sameShape(actual, expected) {
|
|
49
66
|
const shape = actual;
|
|
@@ -77,6 +94,70 @@ function identifier(value) {
|
|
|
77
94
|
if (typeof value !== "string" || !/^[a-zA-Z][a-zA-Z0-9_.-]*$/.test(value))
|
|
78
95
|
throw new AuthError("invalid_definition");
|
|
79
96
|
}
|
|
97
|
+
/** A login form's field name, brackets allowed (Rails' `user[email]`). */
|
|
98
|
+
function formField(value) {
|
|
99
|
+
if (typeof value !== "string" || !/^[A-Za-z_][\w.[\]-]{0,99}$/.test(value))
|
|
100
|
+
throw new AuthError("invalid_definition");
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
/** The longest body marker a rejection may declare. */
|
|
104
|
+
const MAX_MARKER_LENGTH = 128;
|
|
105
|
+
/**
|
|
106
|
+
* A cookie method stores an optional jar in its field, logs in on one of
|
|
107
|
+
* its own targets with a POST (and a GET, with a preflight), and declares
|
|
108
|
+
* at least one rejection signal; every path is checked as a request path
|
|
109
|
+
* beneath the login target is.
|
|
110
|
+
*/
|
|
111
|
+
function validateCookie(auth, method) {
|
|
112
|
+
const schema = method.schema;
|
|
113
|
+
if (!sameShape(schema.properties?.[auth.field], CookieSessionSchema) ||
|
|
114
|
+
schema.required?.includes(auth.field))
|
|
115
|
+
throw new AuthError("invalid_definition");
|
|
116
|
+
const { login, rejection, csrf } = auth;
|
|
117
|
+
const target = login && targetOf(method, login.target);
|
|
118
|
+
if (!target ||
|
|
119
|
+
target.socket ||
|
|
120
|
+
!target.methods.includes("POST") ||
|
|
121
|
+
(login.preflight !== undefined && !target.methods.includes("GET")) ||
|
|
122
|
+
!["form", "json"].includes(login.encoding))
|
|
123
|
+
throw new AuthError("invalid_definition");
|
|
124
|
+
resourceUrl(target, login.path);
|
|
125
|
+
if (login.preflight !== undefined)
|
|
126
|
+
resourceUrl(target, login.preflight);
|
|
127
|
+
const names = [
|
|
128
|
+
formField(login.fields?.username ?? "username"),
|
|
129
|
+
formField(login.fields?.password ?? "password"),
|
|
130
|
+
];
|
|
131
|
+
for (const [name, value] of Object.entries(login.parameters ?? {})) {
|
|
132
|
+
names.push(formField(name));
|
|
133
|
+
if (typeof value !== "string" || !/^[\x20-\x7e]{0,1024}$/.test(value))
|
|
134
|
+
throw new AuthError("invalid_definition");
|
|
135
|
+
}
|
|
136
|
+
if (new Set(names).size !== names.length)
|
|
137
|
+
throw new AuthError("invalid_definition");
|
|
138
|
+
const { statuses, loginPaths, marker } = rejection ?? {};
|
|
139
|
+
if (!rejection ||
|
|
140
|
+
(statuses !== undefined &&
|
|
141
|
+
(!Array.isArray(statuses) ||
|
|
142
|
+
!statuses.length ||
|
|
143
|
+
statuses.some((status) => !Number.isInteger(status) || status < 400 || status > 499))) ||
|
|
144
|
+
(loginPaths !== undefined &&
|
|
145
|
+
(!Array.isArray(loginPaths) || !loginPaths.length)) ||
|
|
146
|
+
(marker !== undefined &&
|
|
147
|
+
(typeof marker !== "string" ||
|
|
148
|
+
marker.length > MAX_MARKER_LENGTH ||
|
|
149
|
+
!/^[\x20-\x7e]+$/.test(marker))) ||
|
|
150
|
+
(statuses === undefined && loginPaths === undefined && marker === undefined))
|
|
151
|
+
throw new AuthError("invalid_definition");
|
|
152
|
+
for (const path of loginPaths ?? [])
|
|
153
|
+
resourceUrl(target, path);
|
|
154
|
+
if (csrf !== undefined) {
|
|
155
|
+
if (!isCookieName(csrf.cookie))
|
|
156
|
+
throw new AuthError("invalid_definition");
|
|
157
|
+
if (TRANSPORT_HEADERS.includes(headerName(csrf.header)))
|
|
158
|
+
throw new AuthError("invalid_definition");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
80
161
|
/**
|
|
81
162
|
* A static secret's parts and placements agree with each other, with the
|
|
82
163
|
* stored shape and with the method's targets: the stored field holds
|
|
@@ -201,6 +282,8 @@ function validateMethod(method) {
|
|
|
201
282
|
validateStatic(auth, schema.properties[auth.field], Object.keys(method.targets ?? {}));
|
|
202
283
|
else if (auth.kind === "awsSigV4")
|
|
203
284
|
validateAwsSigV4(auth, schema);
|
|
285
|
+
else if (auth.kind === "cookie")
|
|
286
|
+
validateCookie(auth, method);
|
|
204
287
|
else if (auth.kind === "oauth2") {
|
|
205
288
|
identifier(auth.definition.id);
|
|
206
289
|
identifier(auth.definition.provider);
|
|
@@ -304,6 +387,7 @@ function validateMethod(method) {
|
|
|
304
387
|
(target.socket !== true ||
|
|
305
388
|
auth.kind === "oauth2" ||
|
|
306
389
|
auth.kind === "awsSigV4" ||
|
|
390
|
+
auth.kind === "cookie" ||
|
|
307
391
|
target.allowedHeaders !== undefined ||
|
|
308
392
|
target.idempotency !== undefined ||
|
|
309
393
|
target.resumableUpload !== undefined ||
|
|
@@ -77,6 +77,30 @@ export declare class CredentialTransport {
|
|
|
77
77
|
*/
|
|
78
78
|
private commitIssued;
|
|
79
79
|
private renew;
|
|
80
|
+
/**
|
|
81
|
+
* A cookie session logged in under the connection's update ownership, so
|
|
82
|
+
* parallel calls on an expired session log in once: one another caller
|
|
83
|
+
* committed meanwhile is kept. With `rejected`, the jar sent at
|
|
84
|
+
* `observedRevision` was refused and only a newer login is kept.
|
|
85
|
+
*/
|
|
86
|
+
private loginSession;
|
|
87
|
+
/**
|
|
88
|
+
* The declared login: a preflight GET when declared, then the owner's
|
|
89
|
+
* POST, both carrying the jar and absorbing what they set. The jar is
|
|
90
|
+
* merged into, never replaced, so a device-trust cookie survives. A
|
|
91
|
+
* login refused by status is `provider_rejected` with it; one the app
|
|
92
|
+
* answers with a rejection signal, or that leaves no cookie, is
|
|
93
|
+
* `reconnect_required`. The password goes in this one request only.
|
|
94
|
+
*/
|
|
95
|
+
private logIn;
|
|
96
|
+
/**
|
|
97
|
+
* What a response of the session sent at `revision` set, merged into the
|
|
98
|
+
* stored jar: a rotated, added or deleted cookie. Only while the jar is
|
|
99
|
+
* still that login's, so a stale response never undoes a newer login.
|
|
100
|
+
* Best effort: a store that fails leaves the response standing, and the
|
|
101
|
+
* session is renewed when it is next rejected.
|
|
102
|
+
*/
|
|
103
|
+
private absorb;
|
|
80
104
|
/** A role session for the binding's `awsRole`, assumed with the stored key. */
|
|
81
105
|
private renewAwsSession;
|
|
82
106
|
/** New tokens by the method's declared renewal, from what the binding supplies. */
|