runline 0.38.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 +11 -0
- package/dist/credentials/http.js +3 -1
- 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 +3 -3
- package/dist/index.js +2 -2
- 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,6 +4,17 @@ 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>;
|
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");
|
|
@@ -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. */
|
|
@@ -3,9 +3,10 @@ import { Check } from "typebox/value";
|
|
|
3
3
|
import { AuthError } from "../auth/errors.js";
|
|
4
4
|
import { acquireOAuth2ClientToken, acquireOAuth2JwtToken, acquireOAuth2PasswordToken, refreshOAuth2Token, } from "../auth/oauth2.js";
|
|
5
5
|
import { assumeAwsRole, awsAccessKey } from "./aws.js";
|
|
6
|
+
import { applySetCookies, cookieFor, cookieHeader, liveCookies, } from "./cookies.js";
|
|
6
7
|
import { sendResource } from "./http.js";
|
|
7
8
|
import { bodyFields, bodyFormat, bounded, headerName, injectedFields, injectedHeaders, injectedParams, injectedPointers, placementsFor, pointerSlot, refuseCredentialParams, resourceUrl, secret, TARGET_RESPONSE_LIMIT_BYTES, TARGET_TIMEOUT_LIMIT_MS, TRANSPORT_HEADERS, targetBase, targetOf, } from "./policy.js";
|
|
8
|
-
import { OAuthGrantSchema, validateCredential, } from "./registry.js";
|
|
9
|
+
import { CookieSessionSchema, OAuthGrantSchema, validateCredential, } from "./registry.js";
|
|
9
10
|
import { canonicalQuery, signSigV4 } from "./sigv4.js";
|
|
10
11
|
function boundSnapshot(binding, method, snapshot) {
|
|
11
12
|
if (snapshot.name !== binding.identity.name ||
|
|
@@ -29,6 +30,67 @@ function grantFrom(method, config) {
|
|
|
29
30
|
throw new AuthError("unsupported_operation");
|
|
30
31
|
return result;
|
|
31
32
|
}
|
|
33
|
+
function cookieSessionFrom(auth, config) {
|
|
34
|
+
const session = config[auth.field];
|
|
35
|
+
if (session === undefined)
|
|
36
|
+
return undefined;
|
|
37
|
+
if (!Check(CookieSessionSchema, session))
|
|
38
|
+
throw new AuthError("invalid_credentials");
|
|
39
|
+
return session;
|
|
40
|
+
}
|
|
41
|
+
/** Methods that change state, on which a CSRF header is sent. */
|
|
42
|
+
function unsafe(method) {
|
|
43
|
+
return method !== "GET" && method !== "HEAD";
|
|
44
|
+
}
|
|
45
|
+
/** Signs with the jar's cookies for the request's URL, and the CSRF header on an unsafe one. */
|
|
46
|
+
function cookieSigner(auth, cookies) {
|
|
47
|
+
return ({ method, headers, url }) => {
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
const cookie = cookieHeader(cookies, url, now);
|
|
50
|
+
if (cookie)
|
|
51
|
+
headers.set("cookie", cookie);
|
|
52
|
+
if (auth.csrf && unsafe(method)) {
|
|
53
|
+
const token = cookieFor(cookies, auth.csrf.cookie, url, now);
|
|
54
|
+
if (token)
|
|
55
|
+
headers.set(headerName(auth.csrf.header), token);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** Whether a Location, read against the request's `url`, falls beneath a declared login path. */
|
|
60
|
+
function toLogin(auth, loginTarget, location, url) {
|
|
61
|
+
if (!location || !auth.rejection.loginPaths)
|
|
62
|
+
return false;
|
|
63
|
+
let next;
|
|
64
|
+
try {
|
|
65
|
+
next = new URL(location, url);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
return auth.rejection.loginPaths.some((path) => {
|
|
71
|
+
const login = resourceUrl(loginTarget, path);
|
|
72
|
+
return (next.origin === login.origin && next.pathname.startsWith(login.pathname));
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* How a response shows a cookie session was rejected, by the method's
|
|
77
|
+
* declared signals: its status, a redirect to a login path, or a body
|
|
78
|
+
* marker on an HTML answer. Null when it shows none.
|
|
79
|
+
*/
|
|
80
|
+
async function sessionRejection(auth, loginTarget, response, url) {
|
|
81
|
+
const { statuses, marker } = auth.rejection;
|
|
82
|
+
if (statuses?.includes(response.status))
|
|
83
|
+
return "status";
|
|
84
|
+
if (toLogin(auth, loginTarget, response.headers.get("location"), url))
|
|
85
|
+
return "redirect";
|
|
86
|
+
if (marker &&
|
|
87
|
+
response.status >= 200 &&
|
|
88
|
+
response.status < 300 &&
|
|
89
|
+
(response.headers.get("content-type") ?? "").includes("text/html") &&
|
|
90
|
+
(await response.clone().text()).includes(marker))
|
|
91
|
+
return "marker";
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
32
94
|
function pinBinding(selection) {
|
|
33
95
|
return {
|
|
34
96
|
...selection,
|
|
@@ -313,6 +375,118 @@ export class CredentialTransport {
|
|
|
313
375
|
},
|
|
314
376
|
});
|
|
315
377
|
}
|
|
378
|
+
/**
|
|
379
|
+
* A cookie session logged in under the connection's update ownership, so
|
|
380
|
+
* parallel calls on an expired session log in once: one another caller
|
|
381
|
+
* committed meanwhile is kept. With `rejected`, the jar sent at
|
|
382
|
+
* `observedRevision` was refused and only a newer login is kept.
|
|
383
|
+
*/
|
|
384
|
+
loginSession(binding, method, observedRevision, rejected = false) {
|
|
385
|
+
const auth = method.authentication;
|
|
386
|
+
if (auth.kind !== "cookie")
|
|
387
|
+
throw new AuthError("invalid_definition");
|
|
388
|
+
return this.commitIssued(binding, method, {
|
|
389
|
+
field: auth.field,
|
|
390
|
+
read: (config) => cookieSessionFrom(auth, config),
|
|
391
|
+
usable: (previous) => !!previous &&
|
|
392
|
+
liveCookies(previous.cookies, Date.now()).length > 0 &&
|
|
393
|
+
(!rejected || previous.revision !== observedRevision),
|
|
394
|
+
issue: (previous) => this.logIn(auth, method, binding, previous),
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* The declared login: a preflight GET when declared, then the owner's
|
|
399
|
+
* POST, both carrying the jar and absorbing what they set. The jar is
|
|
400
|
+
* merged into, never replaced, so a device-trust cookie survives. A
|
|
401
|
+
* login refused by status is `provider_rejected` with it; one the app
|
|
402
|
+
* answers with a rejection signal, or that leaves no cookie, is
|
|
403
|
+
* `reconnect_required`. The password goes in this one request only.
|
|
404
|
+
*/
|
|
405
|
+
async logIn(auth, method, binding, previous) {
|
|
406
|
+
const owner = binding.resourceOwner;
|
|
407
|
+
if (!owner?.username || !owner.password)
|
|
408
|
+
throw new AuthError("invalid_credentials");
|
|
409
|
+
const { login } = auth;
|
|
410
|
+
const target = targetOf(method, login.target);
|
|
411
|
+
if (!target)
|
|
412
|
+
throw new AuthError("invalid_definition");
|
|
413
|
+
let cookies = liveCookies(previous?.cookies ?? [], Date.now());
|
|
414
|
+
const exchange = async (path, verb, body) => {
|
|
415
|
+
const url = resourceUrl(target, path);
|
|
416
|
+
const headers = new Headers({ accept: "*/*" });
|
|
417
|
+
if (body)
|
|
418
|
+
headers.set("content-type", body.type);
|
|
419
|
+
const sign = cookieSigner(auth, cookies);
|
|
420
|
+
sign({ method: verb, headers, url, base: targetBase(target).pathname });
|
|
421
|
+
let set = [];
|
|
422
|
+
const response = await sendResource(url.toString(), verb, headers, body?.bytes, {
|
|
423
|
+
fetch: this.options.fetch,
|
|
424
|
+
timeoutMs: this.options.timeoutMs,
|
|
425
|
+
maxResponseBytes: this.options.maxResponseBytes,
|
|
426
|
+
redirect: () => true,
|
|
427
|
+
setCookie: (values) => {
|
|
428
|
+
set = values;
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
cookies = applySetCookies(cookies, set, url, Date.now());
|
|
432
|
+
return { response, url };
|
|
433
|
+
};
|
|
434
|
+
if (login.preflight !== undefined) {
|
|
435
|
+
const { response } = await exchange(login.preflight, "GET");
|
|
436
|
+
if (response.status >= 400)
|
|
437
|
+
throw new AuthError("provider_rejected", response.status);
|
|
438
|
+
}
|
|
439
|
+
const fields = {
|
|
440
|
+
...login.parameters,
|
|
441
|
+
[login.fields?.username ?? "username"]: owner.username,
|
|
442
|
+
[login.fields?.password ?? "password"]: owner.password,
|
|
443
|
+
};
|
|
444
|
+
const body = login.encoding === "json"
|
|
445
|
+
? {
|
|
446
|
+
type: "application/json",
|
|
447
|
+
bytes: Buffer.from(JSON.stringify(fields)),
|
|
448
|
+
}
|
|
449
|
+
: {
|
|
450
|
+
type: "application/x-www-form-urlencoded",
|
|
451
|
+
bytes: Buffer.from(new URLSearchParams(fields).toString()),
|
|
452
|
+
};
|
|
453
|
+
const { response, url } = await exchange(login.path, "POST", body);
|
|
454
|
+
if (response.status >= 400)
|
|
455
|
+
throw new AuthError("provider_rejected", response.status);
|
|
456
|
+
if ((await sessionRejection(auth, target, response, url)) ||
|
|
457
|
+
!liveCookies(cookies, Date.now()).length)
|
|
458
|
+
throw new AuthError("reconnect_required");
|
|
459
|
+
return { cookies, revision: randomUUID() };
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* What a response of the session sent at `revision` set, merged into the
|
|
463
|
+
* stored jar: a rotated, added or deleted cookie. Only while the jar is
|
|
464
|
+
* still that login's, so a stale response never undoes a newer login.
|
|
465
|
+
* Best effort: a store that fails leaves the response standing, and the
|
|
466
|
+
* session is renewed when it is next rejected.
|
|
467
|
+
*/
|
|
468
|
+
async absorb(binding, method, revision, values, url) {
|
|
469
|
+
const auth = method.authentication;
|
|
470
|
+
if (auth.kind !== "cookie" || !values.length)
|
|
471
|
+
return;
|
|
472
|
+
try {
|
|
473
|
+
await binding.connection.update(async (current) => {
|
|
474
|
+
validateCredential(method, current);
|
|
475
|
+
const stored = cookieSessionFrom(auth, current);
|
|
476
|
+
if (stored?.revision !== revision)
|
|
477
|
+
return undefined;
|
|
478
|
+
return {
|
|
479
|
+
[auth.field]: {
|
|
480
|
+
cookies: applySetCookies(stored.cookies, values, url, Date.now()),
|
|
481
|
+
revision,
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
catch {
|
|
487
|
+
// The response stands; the jar catches up at the next rejection.
|
|
488
|
+
}
|
|
489
|
+
}
|
|
316
490
|
/** A role session for the binding's `awsRole`, assumed with the stored key. */
|
|
317
491
|
renewAwsSession(binding, method, observedRevision) {
|
|
318
492
|
const auth = method.authentication;
|
|
@@ -442,9 +616,17 @@ export class CredentialTransport {
|
|
|
442
616
|
}
|
|
443
617
|
const replay = input.retry !== "never" &&
|
|
444
618
|
(verb === "GET" || verb === "HEAD" || input.idempotencyKey !== undefined);
|
|
619
|
+
// A rejected credential is dead whether or not this request may be sent
|
|
620
|
+
// again, so it is renewed either way and the next call signs with the
|
|
621
|
+
// renewal. Only a replayable request is sent again, exactly once; any
|
|
622
|
+
// other is answered with its rejection.
|
|
623
|
+
const afterRejection = async (rejection, renew, resend) => {
|
|
624
|
+
const renewed = await renew();
|
|
625
|
+
return replay ? resend(renewed) : rejection;
|
|
626
|
+
};
|
|
445
627
|
const base = targetBase(target).pathname;
|
|
446
|
-
|
|
447
|
-
const send = async (signer) => {
|
|
628
|
+
const { sign, grant, session } = await this.authorize(binding, method, input.target);
|
|
629
|
+
const send = async (signer, cookies) => {
|
|
448
630
|
const outgoing = {
|
|
449
631
|
method: verb,
|
|
450
632
|
headers: new Headers(headers),
|
|
@@ -458,17 +640,43 @@ export class CredentialTransport {
|
|
|
458
640
|
timeoutMs: Math.min(target.timeoutMs ?? this.options.timeoutMs, this.options.maxTargetTimeoutMs),
|
|
459
641
|
maxResponseBytes: Math.min(target.maxResponseBytes ?? this.options.maxResponseBytes, this.options.maxTargetResponseBytes),
|
|
460
642
|
resumableUpload: target.resumableUpload,
|
|
643
|
+
...cookies,
|
|
461
644
|
});
|
|
462
645
|
};
|
|
646
|
+
if (auth.kind === "cookie" && session) {
|
|
647
|
+
const loginTarget = targetOf(method, auth.login.target);
|
|
648
|
+
if (!loginTarget)
|
|
649
|
+
throw new AuthError("invalid_definition");
|
|
650
|
+
const attempt = async (current) => {
|
|
651
|
+
let set = [];
|
|
652
|
+
const response = await send(cookieSigner(auth, current.cookies), {
|
|
653
|
+
redirect: (location) => toLogin(auth, loginTarget, location, url),
|
|
654
|
+
setCookie: (values) => {
|
|
655
|
+
set = values;
|
|
656
|
+
},
|
|
657
|
+
});
|
|
658
|
+
await this.absorb(binding, method, current.revision, set, url);
|
|
659
|
+
const rejection = await sessionRejection(auth, loginTarget, response, url);
|
|
660
|
+
// A rejection by redirect or marker reads as the 401 it means; one
|
|
661
|
+
// by status keeps the app's own answer.
|
|
662
|
+
return {
|
|
663
|
+
rejection,
|
|
664
|
+
response: rejection === "redirect" || rejection === "marker"
|
|
665
|
+
? new Response(null, { status: 401 })
|
|
666
|
+
: response,
|
|
667
|
+
};
|
|
668
|
+
};
|
|
669
|
+
const first = await attempt(session);
|
|
670
|
+
if (!first.rejection)
|
|
671
|
+
return first.response;
|
|
672
|
+
return afterRejection(first.response, () => this.loginSession(binding, method, session.revision, true), async (renewed) => (await attempt(renewed)).response);
|
|
673
|
+
}
|
|
463
674
|
const first = await send(sign);
|
|
464
675
|
if (auth.kind !== "oauth2" ||
|
|
465
676
|
!grant ||
|
|
466
|
-
!replay ||
|
|
467
677
|
first.status !== (auth.rejectionStatus ?? 401))
|
|
468
678
|
return first;
|
|
469
|
-
|
|
470
|
-
grant = await this.renew(binding, method, grant.revision, true);
|
|
471
|
-
return send(bearer(secret(grant.tokens.accessToken)));
|
|
679
|
+
return afterRejection(first, () => this.renew(binding, method, grant.revision, true), (renewed) => send(bearer(secret(renewed.tokens.accessToken))));
|
|
472
680
|
}
|
|
473
681
|
/**
|
|
474
682
|
* A socket target's URL, signed by its query placements, for a host's
|
|
@@ -521,6 +729,13 @@ export class CredentialTransport {
|
|
|
521
729
|
const auth = method.authentication;
|
|
522
730
|
if (auth.kind === "none")
|
|
523
731
|
return { sign: () => { } };
|
|
732
|
+
if (auth.kind === "cookie") {
|
|
733
|
+
const stored = cookieSessionFrom(auth, config);
|
|
734
|
+
const session = stored && liveCookies(stored.cookies, Date.now()).length
|
|
735
|
+
? stored
|
|
736
|
+
: await this.loginSession(binding, method, stored?.revision);
|
|
737
|
+
return { sign: cookieSigner(auth, session.cookies), session };
|
|
738
|
+
}
|
|
524
739
|
if (auth.kind === "awsSigV4") {
|
|
525
740
|
const scope = targetOf(method, target)?.aws;
|
|
526
741
|
if (!scope)
|
|
@@ -139,7 +139,8 @@ export type SecretPlacement = ({
|
|
|
139
139
|
* How a request is signed. `field` names the one top-level config field
|
|
140
140
|
* holding the secret: for `static`, a record of the named `parts`
|
|
141
141
|
* (`staticSecretSchema(parts)`), each sent through its placements; for
|
|
142
|
-
* `oauth2`, a revisioned OAuthGrant; for `awsSigV4`, an AWS access key
|
|
142
|
+
* `oauth2`, a revisioned OAuthGrant; for `awsSigV4`, an AWS access key;
|
|
143
|
+
* for `cookie`, a revisioned `CookieSession` jar.
|
|
143
144
|
* `none` signs nothing: the method of a connection that holds no optional
|
|
144
145
|
* credential, still pinned to its targets.
|
|
145
146
|
*/
|
|
@@ -190,7 +191,79 @@ export type CredentialAuthentication = {
|
|
|
190
191
|
assumeRole?: {
|
|
191
192
|
sessionField: string;
|
|
192
193
|
};
|
|
194
|
+
} | {
|
|
195
|
+
/**
|
|
196
|
+
* A web app's cookie session. The transport logs in with the
|
|
197
|
+
* binding's `resourceOwner` as `login` declares, keeps every cookie
|
|
198
|
+
* the method's targets set in a jar in `field` (`CookieSessionSchema`),
|
|
199
|
+
* sends each only where RFC 6265 matches it, and logs in again when a
|
|
200
|
+
* response shows the session was rejected. The plugin never sees the
|
|
201
|
+
* password, the jar, or a `Set-Cookie`.
|
|
202
|
+
*/
|
|
203
|
+
kind: "cookie";
|
|
204
|
+
field: string;
|
|
205
|
+
login: CookieLogin;
|
|
206
|
+
rejection: CookieRejection;
|
|
207
|
+
/**
|
|
208
|
+
* Cookie-based CSRF (Django's `csrftoken`, `XSRF-TOKEN`): the named
|
|
209
|
+
* cookie's value is copied into `header` on every unsafe request, the
|
|
210
|
+
* login included. The header is reserved: callers never set it.
|
|
211
|
+
*/
|
|
212
|
+
csrf?: {
|
|
213
|
+
cookie: string;
|
|
214
|
+
header: string;
|
|
215
|
+
};
|
|
193
216
|
};
|
|
217
|
+
/**
|
|
218
|
+
* How the transport logs in: an optional preflight GET (a CSRF cookie, a
|
|
219
|
+
* session to log into), then one POST of the owner's username and password
|
|
220
|
+
* under the app's own field names, beside fixed `parameters`. Both go to
|
|
221
|
+
* `target` and carry the jar, so a device-trust cookie rides along.
|
|
222
|
+
*/
|
|
223
|
+
export interface CookieLogin {
|
|
224
|
+
target: string;
|
|
225
|
+
/** Relative to the target's base, as a request path is. */
|
|
226
|
+
path: string;
|
|
227
|
+
encoding: "form" | "json";
|
|
228
|
+
/** The app's names for the owner's fields; `username` and `password` when absent. */
|
|
229
|
+
fields?: {
|
|
230
|
+
username?: string;
|
|
231
|
+
password?: string;
|
|
232
|
+
};
|
|
233
|
+
/** Fixed extra fields, such as `remember: "1"`. Never a secret. */
|
|
234
|
+
parameters?: Record<string, string>;
|
|
235
|
+
/** A path on `target` to GET before the POST. */
|
|
236
|
+
preflight?: string;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* What a rejected session looks like, at least one of: a status; a 3xx
|
|
240
|
+
* whose Location falls under one of `loginPaths` (relative to the login
|
|
241
|
+
* target, as `login.path` is); or a 2xx HTML answer containing `marker`.
|
|
242
|
+
*/
|
|
243
|
+
export interface CookieRejection {
|
|
244
|
+
/** 4xx statuses, such as 401, 403, Laravel's 419 or IIS's 440. */
|
|
245
|
+
statuses?: number[];
|
|
246
|
+
loginPaths?: string[];
|
|
247
|
+
marker?: string;
|
|
248
|
+
}
|
|
249
|
+
/** One cookie in a session's jar, as RFC 6265 stores it. */
|
|
250
|
+
export interface StoredCookie {
|
|
251
|
+
name: string;
|
|
252
|
+
value: string;
|
|
253
|
+
/** Lowercased: the exact host when `hostOnly`, else the Domain attribute. */
|
|
254
|
+
domain: string;
|
|
255
|
+
hostOnly: boolean;
|
|
256
|
+
path: string;
|
|
257
|
+
secure: boolean;
|
|
258
|
+
httpOnly: boolean;
|
|
259
|
+
/** Milliseconds since epoch; absent for a session cookie, kept until replaced. */
|
|
260
|
+
expiresAt?: number;
|
|
261
|
+
}
|
|
262
|
+
/** A cookie jar as stored. `revision` changes on every login, never on a rotated cookie. */
|
|
263
|
+
export interface CookieSession {
|
|
264
|
+
cookies: StoredCookie[];
|
|
265
|
+
revision: string;
|
|
266
|
+
}
|
|
194
267
|
export interface CredentialProbe {
|
|
195
268
|
target: string;
|
|
196
269
|
/** Fixed relative path, including any fixed query. No expressions or callbacks. */
|
package/dist/index.d.ts
CHANGED
|
@@ -18,11 +18,11 @@ export { awsCredential, awsFailed, awsJson, awsList, awsQuery, awsTarget, } from
|
|
|
18
18
|
export { brokerFor } from "./credentials/broker.js";
|
|
19
19
|
export { downloadResource, type PresignedRequestOptions, presignedRequest, } from "./credentials/http.js";
|
|
20
20
|
export type { CredentialCall, EmptyAnswer, ErrorEnvelope, StaticCredentialSpec, } from "./credentials/plugin.js";
|
|
21
|
-
export { answerFailed, configChoice, configFlag, credentialJson, credentialOk, credentialRequest, credentialSocketUrl, errorIdentifier, failureMessage, grantSchema, graphqlFailed, hostLabel, httpsBase, jsonAnswer, jsonOrAcknowledged, multipartBody, pathSegment, pathSegments, pathWithin, refuseUnderHost, requestFailed, responseFailed, slashEncodedSegment, staticCredential, } from "./credentials/plugin.js";
|
|
22
|
-
export { AwsAccessKeySchema, AwsSessionSchema, CredentialRegistry, OAuthGrantSchema, OAuthTokensSchema, staticSecretSchema, validateCredential, } from "./credentials/registry.js";
|
|
21
|
+
export { answerFailed, configChoice, configFlag, cookieSessionSchema, credentialJson, credentialOk, credentialRequest, credentialSocketUrl, errorIdentifier, failureMessage, grantSchema, graphqlFailed, hostLabel, httpsBase, jsonAnswer, jsonOrAcknowledged, multipartBody, pathSegment, pathSegments, pathWithin, refuseUnderHost, requestFailed, responseFailed, slashEncodedSegment, staticCredential, } from "./credentials/plugin.js";
|
|
22
|
+
export { AwsAccessKeySchema, AwsSessionSchema, CookieSessionSchema, CredentialRegistry, OAuthGrantSchema, OAuthTokensSchema, StoredCookieSchema, staticSecretSchema, validateCredential, } from "./credentials/registry.js";
|
|
23
23
|
export type { AuthenticatedRequest, CredentialBroker, CredentialBrokerCall, CredentialTransportOptions, SocketRequest, } from "./credentials/transport.js";
|
|
24
24
|
export { CredentialTransport } from "./credentials/transport.js";
|
|
25
|
-
export type { AwsAccessKey, AwsRole, AwsScope, AwsSession, CredentialAuthentication, CredentialBinding, CredentialDeclaration, CredentialMethod, CredentialProbe, CredentialProbeResult, CredentialSelection, CredentialTarget, CredentialType, HttpMethod, LocalSecretPart, LocalSecretSource, OAuthGrant, SecretPlacement, } from "./credentials/types.js";
|
|
25
|
+
export type { AwsAccessKey, AwsRole, AwsScope, AwsSession, CookieLogin, CookieRejection, CookieSession, CredentialAuthentication, CredentialBinding, CredentialDeclaration, CredentialMethod, CredentialProbe, CredentialProbeResult, CredentialSelection, CredentialTarget, CredentialType, HttpMethod, LocalSecretPart, LocalSecretSource, OAuthGrant, SecretPlacement, StoredCookie, } from "./credentials/types.js";
|
|
26
26
|
export type { ActionDefinition, PluginFunction, RunlinePluginAPI, } from "./plugin/api.js";
|
|
27
27
|
export { createPluginAPI, isPluginFunction, resolvePluginExport, } from "./plugin/api.js";
|
|
28
28
|
export type { InstalledPlugin, PluginSource } from "./plugin/installer.js";
|
package/dist/index.js
CHANGED
|
@@ -11,8 +11,8 @@ export { awsEndpoint, awsRegion, parseAwsXml } from "./credentials/aws.js";
|
|
|
11
11
|
export { awsCredential, awsFailed, awsJson, awsList, awsQuery, awsTarget, } from "./credentials/aws-plugin.js";
|
|
12
12
|
export { brokerFor } from "./credentials/broker.js";
|
|
13
13
|
export { downloadResource, presignedRequest, } from "./credentials/http.js";
|
|
14
|
-
export { answerFailed, configChoice, configFlag, credentialJson, credentialOk, credentialRequest, credentialSocketUrl, errorIdentifier, failureMessage, grantSchema, graphqlFailed, hostLabel, httpsBase, jsonAnswer, jsonOrAcknowledged, multipartBody, pathSegment, pathSegments, pathWithin, refuseUnderHost, requestFailed, responseFailed, slashEncodedSegment, staticCredential, } from "./credentials/plugin.js";
|
|
15
|
-
export { AwsAccessKeySchema, AwsSessionSchema, CredentialRegistry, OAuthGrantSchema, OAuthTokensSchema, staticSecretSchema, validateCredential, } from "./credentials/registry.js";
|
|
14
|
+
export { answerFailed, configChoice, configFlag, cookieSessionSchema, credentialJson, credentialOk, credentialRequest, credentialSocketUrl, errorIdentifier, failureMessage, grantSchema, graphqlFailed, hostLabel, httpsBase, jsonAnswer, jsonOrAcknowledged, multipartBody, pathSegment, pathSegments, pathWithin, refuseUnderHost, requestFailed, responseFailed, slashEncodedSegment, staticCredential, } from "./credentials/plugin.js";
|
|
15
|
+
export { AwsAccessKeySchema, AwsSessionSchema, CookieSessionSchema, CredentialRegistry, OAuthGrantSchema, OAuthTokensSchema, StoredCookieSchema, staticSecretSchema, validateCredential, } from "./credentials/registry.js";
|
|
16
16
|
export { CredentialTransport } from "./credentials/transport.js";
|
|
17
17
|
export { createPluginAPI, isPluginFunction, resolvePluginExport, } from "./plugin/api.js";
|
|
18
18
|
export { installPlugin, listInstalled, parsePluginSource, removePlugin, } from "./plugin/installer.js";
|