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.
Files changed (33) hide show
  1. package/dist/credentials/broker.js +32 -16
  2. package/dist/credentials/cookies.d.ts +33 -0
  3. package/dist/credentials/cookies.js +151 -0
  4. package/dist/credentials/http.d.ts +28 -3
  5. package/dist/credentials/http.js +34 -10
  6. package/dist/credentials/plugin.d.ts +21 -2
  7. package/dist/credentials/plugin.js +8 -3
  8. package/dist/credentials/policy.js +2 -0
  9. package/dist/credentials/registry.d.ts +25 -0
  10. package/dist/credentials/registry.js +84 -0
  11. package/dist/credentials/transport.d.ts +24 -0
  12. package/dist/credentials/transport.js +222 -7
  13. package/dist/credentials/types.d.ts +74 -1
  14. package/dist/index.d.ts +4 -4
  15. package/dist/index.js +3 -3
  16. package/dist/plugins/_shared/attachments.js +97 -0
  17. package/dist/plugins/_shared/microsoftAuth.js +33 -4
  18. package/dist/plugins/_shared/schemas.js +8 -0
  19. package/dist/plugins/agentMail/src/drafts.js +2 -1
  20. package/dist/plugins/agentMail/src/inboxes.js +2 -1
  21. package/dist/plugins/agentMail/src/messages.js +2 -1
  22. package/dist/plugins/agentMail/src/schemas.js +4 -41
  23. package/dist/plugins/agentMail/src/shared.js +28 -50
  24. package/dist/plugins/agentMail/src/threads.js +2 -1
  25. package/dist/plugins/gmail/src/index.js +10 -22
  26. package/dist/plugins/microsoftCalendar/src/index.js +513 -17
  27. package/dist/plugins/microsoftFiles/src/index.js +3 -3
  28. package/dist/plugins/microsoftMail/src/index.js +305 -54
  29. package/dist/plugins/todoist/src/credentials.js +4 -11
  30. package/dist/plugins/todoist/src/index.js +390 -281
  31. package/dist/plugins/zohoMail/src/credentials.js +92 -0
  32. package/dist/plugins/zohoMail/src/index.js +241 -0
  33. 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`, each stamped with the authority
84
- * (`expected`) it was issued under. Only those two are ever renewed and
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: { ...session, binding: expected } };
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 cached = current.awsSession;
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 && cached?.binding === expected
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
- * Fetch a provider-issued preauthenticated URL without bearer tokens or cookies.
12
- * Hosts approve exact origins and enforce DNS/IP egress through their fetch hook.
13
- * The signed URL is itself secret: errors never include it. No redirects or retries.
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;
@@ -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
- * Fetch a provider-issued preauthenticated URL without bearer tokens or cookies.
82
- * Hosts approve exact origins and enforce DNS/IP egress through their fetch hook.
83
- * The signed URL is itself secret: errors never include it. No redirects or retries.
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
- export async function downloadResource(value, options) {
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
- return sendResource(url.toString(), "GET", new Headers(), undefined, {
105
- fetch: options.fetch,
106
- timeoutMs: 20_000,
107
- maxResponseBytes,
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
- * grant shape for OAuth methods, one way to turn public config into an
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
- * grant shape for OAuth methods, one way to turn public config into an
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. */