@pooriaarab/foxlink 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +339 -2
- package/dist/calendar.d.ts +45 -0
- package/dist/calendar.js +83 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.js +18 -0
- package/dist/gated.d.ts +20 -0
- package/dist/gated.js +20 -0
- package/dist/gmail.d.ts +68 -0
- package/dist/gmail.js +157 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/link.d.ts +67 -0
- package/dist/link.js +299 -0
- package/dist/provider.d.ts +63 -0
- package/dist/provider.js +105 -0
- package/dist/text.d.ts +20 -0
- package/dist/text.js +94 -0
- package/package.json +47 -4
package/dist/gmail.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { FoxlinkError } from "./errors.js";
|
|
2
|
+
import { FOXLINK_TOOLS, gated, hasControl } from "./gated.js";
|
|
3
|
+
import { GOOGLE_ENDPOINTS, GOOGLE_SCOPES } from "./provider.js";
|
|
4
|
+
import { htmlToText } from "./text.js";
|
|
5
|
+
const SEND_SCOPES = [GOOGLE_SCOPES.gmailSend, "https://www.googleapis.com/auth/gmail.modify", "https://mail.google.com/"];
|
|
6
|
+
const ADDRESS = /^[^\s@<>,;:"()[\]\\]+@[^\s@<>,;:"()[\]\\]+\.[^\s@<>,;:"()[\]\\]+$/;
|
|
7
|
+
const b64 = (text) => {
|
|
8
|
+
let binary = "";
|
|
9
|
+
for (const byte of new TextEncoder().encode(text))
|
|
10
|
+
binary += String.fromCharCode(byte);
|
|
11
|
+
return btoa(binary);
|
|
12
|
+
};
|
|
13
|
+
function validMessage(m) {
|
|
14
|
+
if (!m || typeof m.to !== "string" || typeof m.subject !== "string" || typeof m.body !== "string")
|
|
15
|
+
return false;
|
|
16
|
+
const to = m.to.split(",").map((a) => a.trim());
|
|
17
|
+
if (to.length > 10 || !to.every((a) => ADDRESS.test(a)))
|
|
18
|
+
return false;
|
|
19
|
+
return !hasControl(m.to) && !hasControl(m.subject) && m.subject.length <= 500 && m.body.length <= 100_000;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* RFC 2047 encoded words of 75 characters at most, folded on new lines. Each
|
|
23
|
+
* word holds whole characters (45 bytes at most), so no character is split.
|
|
24
|
+
*/
|
|
25
|
+
function encodedWords(text) {
|
|
26
|
+
const words = [];
|
|
27
|
+
let chunk = "";
|
|
28
|
+
for (const char of text) {
|
|
29
|
+
if (new TextEncoder().encode(chunk + char).length > 45) {
|
|
30
|
+
words.push(chunk);
|
|
31
|
+
chunk = "";
|
|
32
|
+
}
|
|
33
|
+
chunk += char;
|
|
34
|
+
}
|
|
35
|
+
if (chunk)
|
|
36
|
+
words.push(chunk);
|
|
37
|
+
return words.map((w) => `=?UTF-8?B?${b64(w)}?=`).join("\r\n ");
|
|
38
|
+
}
|
|
39
|
+
/** An RFC 5322 message with a UTF-8 text body. A non-ASCII subject is RFC 2047 encoded. */
|
|
40
|
+
function mime(m) {
|
|
41
|
+
const plain = /^[\x20-\x7e]*$/.test(m.subject) && !m.subject.includes("=?");
|
|
42
|
+
const subject = plain ? m.subject : encodedWords(m.subject);
|
|
43
|
+
const body = (b64(m.body).match(/.{1,76}/g) ?? []).join("\r\n");
|
|
44
|
+
return [`To: ${m.to.split(",").map((a) => a.trim()).join(", ")}`, `Subject: ${subject}`, "MIME-Version: 1.0", 'Content-Type: text/plain; charset="UTF-8"', "Content-Transfer-Encoding: base64", "", body].join("\r\n");
|
|
45
|
+
}
|
|
46
|
+
const READ_SCOPES = [GOOGLE_SCOPES.gmailRead, "https://www.googleapis.com/auth/gmail.modify", "https://mail.google.com/"];
|
|
47
|
+
const ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
48
|
+
const PAGE_TOKEN = /^[A-Za-z0-9_-]{1,256}$/;
|
|
49
|
+
const header = (part, name) => part?.headers?.find((h) => h.name.toLowerCase() === name)?.value ?? "";
|
|
50
|
+
function decodeBody(data) {
|
|
51
|
+
const binary = atob(data.replace(/-/g, "+").replace(/_/g, "/"));
|
|
52
|
+
return new TextDecoder().decode(Uint8Array.from(binary, (c) => c.charCodeAt(0)));
|
|
53
|
+
}
|
|
54
|
+
/** Every part with a body, depth first, with no attachments. */
|
|
55
|
+
function bodies(part, out = []) {
|
|
56
|
+
if (!part)
|
|
57
|
+
return out;
|
|
58
|
+
if (part.parts?.length)
|
|
59
|
+
for (const child of part.parts)
|
|
60
|
+
bodies(child, out);
|
|
61
|
+
else if (!part.filename && !part.body?.attachmentId && typeof part.body?.data === "string")
|
|
62
|
+
out.push(part);
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
/** The plain text of a message: the first text/plain part, else the first text/html part as text. */
|
|
66
|
+
export function messageText(payload) {
|
|
67
|
+
const parts = bodies(payload);
|
|
68
|
+
const plain = parts.find((p) => p.mimeType === "text/plain");
|
|
69
|
+
if (plain?.body?.data !== undefined)
|
|
70
|
+
return decodeBody(plain.body.data).replace(/\r\n/g, "\n").trim();
|
|
71
|
+
const html = parts.find((p) => p.mimeType === "text/html");
|
|
72
|
+
return html?.body?.data !== undefined ? htmlToText(decodeBody(html.body.data)) : "";
|
|
73
|
+
}
|
|
74
|
+
const summary = (m) => ({
|
|
75
|
+
trust: "untrusted",
|
|
76
|
+
source: "gmail",
|
|
77
|
+
id: m.id,
|
|
78
|
+
threadId: m.threadId ?? m.id,
|
|
79
|
+
from: header(m.payload, "from"),
|
|
80
|
+
to: header(m.payload, "to"),
|
|
81
|
+
subject: header(m.payload, "subject"),
|
|
82
|
+
date: header(m.payload, "date"),
|
|
83
|
+
snippet: m.snippet ?? "",
|
|
84
|
+
});
|
|
85
|
+
const clampMax = (value) => (typeof value === "number" && Number.isFinite(value) ? Math.min(Math.max(Math.trunc(value), 1), 50) : 10);
|
|
86
|
+
export function gmail(link, options = {}) {
|
|
87
|
+
const base = options.baseUrl ?? link.provider.endpoints?.gmailBase ?? GOOGLE_ENDPOINTS.gmailBase;
|
|
88
|
+
async function hasAny(scopes) {
|
|
89
|
+
for (const s of scopes)
|
|
90
|
+
if (await link.hasScope(s))
|
|
91
|
+
return true;
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
async function needScope(scopes) {
|
|
95
|
+
if (await hasAny(scopes))
|
|
96
|
+
return;
|
|
97
|
+
throw new FoxlinkError("missing-scope", `This call needs the scope ${scopes[0] ?? ""}. Connect again and grant it.`);
|
|
98
|
+
}
|
|
99
|
+
async function getJson(path, params = []) {
|
|
100
|
+
const url = new URL(`${base}/users/me/${path}`);
|
|
101
|
+
for (const [k, v] of params)
|
|
102
|
+
url.searchParams.append(k, v);
|
|
103
|
+
const res = await link.fetch(url);
|
|
104
|
+
if (!res.ok)
|
|
105
|
+
throw new FoxlinkError("http-error", `Gmail answered HTTP ${res.status}.`, { status: res.status });
|
|
106
|
+
return (await res.json());
|
|
107
|
+
}
|
|
108
|
+
return Object.freeze({
|
|
109
|
+
/** Newest messages first, with their headers. One list request and one request for each message. */
|
|
110
|
+
async listMessages(request = {}) {
|
|
111
|
+
await needScope(READ_SCOPES);
|
|
112
|
+
if (request.pageToken !== undefined && !PAGE_TOKEN.test(request.pageToken))
|
|
113
|
+
throw new FoxlinkError("bad-input", "pageToken is not a Gmail page token.");
|
|
114
|
+
const params = [["maxResults", String(clampMax(request.max))]];
|
|
115
|
+
if (request.query)
|
|
116
|
+
params.push(["q", String(request.query).slice(0, 500)]);
|
|
117
|
+
if (request.pageToken)
|
|
118
|
+
params.push(["pageToken", request.pageToken]);
|
|
119
|
+
const list = await getJson("messages", params);
|
|
120
|
+
const ids = (list.messages ?? []).map((m) => m.id).filter((id) => ID.test(id));
|
|
121
|
+
const messages = [];
|
|
122
|
+
for (let i = 0; i < ids.length; i += 5) {
|
|
123
|
+
const chunk = ids.slice(i, i + 5).map((id) => getJson(`messages/${id}`, [["format", "metadata"], ...["From", "To", "Subject", "Date"].map((h) => ["metadataHeaders", h])]));
|
|
124
|
+
messages.push(...(await Promise.all(chunk)).map(summary));
|
|
125
|
+
}
|
|
126
|
+
return list.nextPageToken ? { messages, nextPageToken: list.nextPageToken } : { messages };
|
|
127
|
+
},
|
|
128
|
+
/** One message, with its body as plain text. HTML loses scripts, styles, and images. */
|
|
129
|
+
async getMessage(id) {
|
|
130
|
+
await needScope(READ_SCOPES);
|
|
131
|
+
if (typeof id !== "string" || !ID.test(id))
|
|
132
|
+
throw new FoxlinkError("bad-input", "id is not a Gmail message ID.");
|
|
133
|
+
const m = await getJson(`messages/${id}`, [["format", "full"]]);
|
|
134
|
+
return { ...summary(m), text: messageText(m.payload) };
|
|
135
|
+
},
|
|
136
|
+
/**
|
|
137
|
+
* Send a plain text message, after a foxgate decision with scope `submit`.
|
|
138
|
+
* The first call returns `ask`. Call again with the approval token.
|
|
139
|
+
*/
|
|
140
|
+
async sendMessage(message, approval = {}) {
|
|
141
|
+
if (!validMessage(message))
|
|
142
|
+
return { status: "refused", reason: "bad-input" };
|
|
143
|
+
if (!(await hasAny(SEND_SCOPES)))
|
|
144
|
+
return { status: "refused", reason: "missing-scope" };
|
|
145
|
+
const args = { to: message.to, subject: message.subject, body: message.body };
|
|
146
|
+
const action = { tool: "foxlink.gmail.send", scope: FOXLINK_TOOLS["foxlink.gmail.send"], domain: new URL(base).hostname, args };
|
|
147
|
+
return gated(options.gate, action, approval.token, async (judged) => {
|
|
148
|
+
const raw = b64(mime(judged)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
149
|
+
const res = await link.fetch(`${base}/users/me/messages/send`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ raw }) });
|
|
150
|
+
if (!res.ok)
|
|
151
|
+
throw new FoxlinkError("http-error", `Gmail answered HTTP ${res.status}.`, { status: res.status });
|
|
152
|
+
const sent = (await res.json());
|
|
153
|
+
return { status: "sent", id: String(sent.id ?? "") };
|
|
154
|
+
});
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { FoxlinkError, type FoxlinkErrorCode } from "./errors.js";
|
|
2
|
+
export { GOOGLE_ENDPOINTS, GOOGLE_SCOPES, defineProvider, googleProvider, loopbackRedirectUrl, type GoogleEndpoints, type Provider, type ProviderConfig, type ProviderOptions, } from "./provider.js";
|
|
3
|
+
export { createLink, type IdentityLike, type Link, type LinkOptions, type LinkStatus } from "./link.js";
|
|
4
|
+
export { gmail, messageText, type Gmail, type Message, type MessageSummary, type OutgoingMessage } from "./gmail.js";
|
|
5
|
+
export { decodeEntities, htmlToText, toPromptText, type Untrusted } from "./text.js";
|
|
6
|
+
export { calendar, type Calendar, type CalendarEvent, type NewEvent } from "./calendar.js";
|
|
7
|
+
export { FOXLINK_TOOLS } from "./gated.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { FoxlinkError } from "./errors.js";
|
|
2
|
+
export { GOOGLE_ENDPOINTS, GOOGLE_SCOPES, defineProvider, googleProvider, loopbackRedirectUrl, } from "./provider.js";
|
|
3
|
+
export { createLink } from "./link.js";
|
|
4
|
+
export { gmail, messageText } from "./gmail.js";
|
|
5
|
+
export { decodeEntities, htmlToText, toPromptText } from "./text.js";
|
|
6
|
+
export { calendar } from "./calendar.js";
|
|
7
|
+
export { FOXLINK_TOOLS } from "./gated.js";
|
package/dist/link.d.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { type Store } from "foxgate";
|
|
2
|
+
import type { Vault } from "foxvault";
|
|
3
|
+
import { type GoogleEndpoints, type Provider } from "./provider.js";
|
|
4
|
+
/** The parts of `browser.identity` that foxlink uses. */
|
|
5
|
+
export interface IdentityLike {
|
|
6
|
+
getRedirectURL(): string;
|
|
7
|
+
launchWebAuthFlow(details: {
|
|
8
|
+
url: string;
|
|
9
|
+
interactive: boolean;
|
|
10
|
+
}): Promise<string>;
|
|
11
|
+
}
|
|
12
|
+
export interface LinkOptions {
|
|
13
|
+
/** From `defineProvider` or `googleProvider`. */
|
|
14
|
+
provider: Provider & {
|
|
15
|
+
readonly endpoints?: Readonly<GoogleEndpoints>;
|
|
16
|
+
};
|
|
17
|
+
/** A foxvault vault. It keeps the access token and the refresh token. */
|
|
18
|
+
vault: Vault;
|
|
19
|
+
/** Where the token record (scopes and expiry, no tokens) lives. Default: memoryStore(). */
|
|
20
|
+
store?: Store;
|
|
21
|
+
/** `browser.identity` in Firefox. */
|
|
22
|
+
identity?: IdentityLike;
|
|
23
|
+
/** The redirect URI. Default: from `identity` and `provider.redirect`. */
|
|
24
|
+
redirectUri?: string;
|
|
25
|
+
/** Opens the authorize URL and returns the redirect URL. Default: `identity.launchWebAuthFlow`. */
|
|
26
|
+
launch?: (details: {
|
|
27
|
+
url: string;
|
|
28
|
+
interactive: boolean;
|
|
29
|
+
}) => Promise<string>;
|
|
30
|
+
/** `inject`: foxvault adds the header in Firefox (attachHeaderInjection). `use`: foxlink adds it in host code. Default: `use`. */
|
|
31
|
+
transport?: "use" | "inject";
|
|
32
|
+
fetch?: typeof fetch;
|
|
33
|
+
/** The clock, in ms since 1970. Default: Date.now. */
|
|
34
|
+
now?: () => number;
|
|
35
|
+
/** Refresh this long before the access token expires. Default: 60 seconds. */
|
|
36
|
+
skewMs?: number;
|
|
37
|
+
}
|
|
38
|
+
export interface LinkStatus {
|
|
39
|
+
connected: boolean;
|
|
40
|
+
scopes: string[];
|
|
41
|
+
/** When the access token expires, in ms since 1970, by the local clock. */
|
|
42
|
+
expiresAt?: number;
|
|
43
|
+
/** False when the provider sent no refresh token. Then the user must connect again when the access token expires. */
|
|
44
|
+
refreshable?: boolean;
|
|
45
|
+
}
|
|
46
|
+
export declare function createLink(options: LinkOptions): Readonly<{
|
|
47
|
+
provider: Readonly<Required<Omit<import("./provider.js").ProviderConfig, "clientSecret" | "revokeUrl">>> & {
|
|
48
|
+
readonly clientSecret?: string;
|
|
49
|
+
readonly revokeUrl?: string;
|
|
50
|
+
} & {
|
|
51
|
+
readonly endpoints?: Readonly<GoogleEndpoints>;
|
|
52
|
+
};
|
|
53
|
+
connect: (request?: {
|
|
54
|
+
scopes?: string[];
|
|
55
|
+
interactive?: boolean;
|
|
56
|
+
}) => Promise<LinkStatus>;
|
|
57
|
+
status: () => Promise<LinkStatus>;
|
|
58
|
+
fetch: (input: string | URL, init?: RequestInit) => Promise<Response>;
|
|
59
|
+
disconnect: () => Promise<{
|
|
60
|
+
revoked: boolean;
|
|
61
|
+
}>;
|
|
62
|
+
/** True when the user granted this scope. */
|
|
63
|
+
hasScope: (scope: string) => Promise<boolean>;
|
|
64
|
+
/** The redirect URI to register with the provider. */
|
|
65
|
+
redirectUri: () => string;
|
|
66
|
+
}>;
|
|
67
|
+
export type Link = ReturnType<typeof createLink>;
|
package/dist/link.js
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { memoryStore } from "foxgate";
|
|
2
|
+
import { FoxlinkError, safeCode } from "./errors.js";
|
|
3
|
+
import { loopbackRedirectUrl } from "./provider.js";
|
|
4
|
+
const b64url = (bytes) => btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
5
|
+
const random = (n) => b64url(crypto.getRandomValues(new Uint8Array(n)));
|
|
6
|
+
const sha256 = async (text) => b64url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text))));
|
|
7
|
+
/** Keep expires_in between 60 seconds and 1 day. A missing or bad value counts as 300 seconds. */
|
|
8
|
+
const lifetimeMs = (value) => {
|
|
9
|
+
const n = typeof value === "number" ? value : typeof value === "string" && value.trim() ? Number(value) : Number.NaN;
|
|
10
|
+
return (Number.isFinite(n) ? Math.min(Math.max(n, 60), 86_400) : 300) * 1000;
|
|
11
|
+
};
|
|
12
|
+
function tokenResult(result) {
|
|
13
|
+
if (!result.ok) {
|
|
14
|
+
const oauthError = safeCode(result.body.error);
|
|
15
|
+
throw new FoxlinkError("token-error", `The token endpoint refused${oauthError ? ` with ${oauthError}` : ""} (HTTP ${result.status}).`, { status: result.status, oauthError });
|
|
16
|
+
}
|
|
17
|
+
const token = result.body.access_token;
|
|
18
|
+
if (typeof token !== "string" || token.length < 8 || token.length > 4096)
|
|
19
|
+
throw new FoxlinkError("token-error", "The token endpoint sent no usable access token.");
|
|
20
|
+
return result.body;
|
|
21
|
+
}
|
|
22
|
+
const idTokenFail = () => new FoxlinkError("id-token", "The ID token does not match this sign-in.");
|
|
23
|
+
export function createLink(options) {
|
|
24
|
+
const { provider, vault } = options;
|
|
25
|
+
const store = options.store ?? memoryStore();
|
|
26
|
+
const doFetch = options.fetch ?? ((input, init) => fetch(input, init));
|
|
27
|
+
const now = options.now ?? Date.now;
|
|
28
|
+
const transport = options.transport ?? "use";
|
|
29
|
+
if (transport !== "use" && transport !== "inject")
|
|
30
|
+
throw new FoxlinkError("bad-config", "transport must be use or inject.");
|
|
31
|
+
const key = `foxlink:${provider.id}`;
|
|
32
|
+
const handles = { access: `vault:flk-${provider.id}-access`, refresh: `vault:flk-${provider.id}-refresh` };
|
|
33
|
+
const tokenHosts = [...new Set([provider.tokenUrl, provider.revokeUrl ?? provider.tokenUrl].map((u) => new URL(u).hostname))];
|
|
34
|
+
let connecting = false;
|
|
35
|
+
let refreshing;
|
|
36
|
+
/** Goes up by one with each new access token, so two 401s for one token send one refresh. */
|
|
37
|
+
let generation = 0;
|
|
38
|
+
const redirectUri = () => {
|
|
39
|
+
if (options.redirectUri)
|
|
40
|
+
return options.redirectUri;
|
|
41
|
+
if (!options.identity)
|
|
42
|
+
throw new FoxlinkError("bad-config", "Pass identity (browser.identity) or redirectUri.");
|
|
43
|
+
const url = options.identity.getRedirectURL();
|
|
44
|
+
return provider.redirect === "loopback" ? loopbackRedirectUrl(url) : url;
|
|
45
|
+
};
|
|
46
|
+
const readRecord = async () => {
|
|
47
|
+
const value = (await store.get(key));
|
|
48
|
+
return value && typeof value === "object" && Array.isArray(value.scopes) && typeof value.expiresAt === "number" && typeof value.issuedAt === "number" ? value : undefined;
|
|
49
|
+
};
|
|
50
|
+
/** POST a form. It never throws, so it can run inside vault.use. */
|
|
51
|
+
async function postForm(url, params, asClient = true) {
|
|
52
|
+
const body = new URLSearchParams(asClient ? { client_id: provider.clientId, ...params } : params);
|
|
53
|
+
if (asClient && provider.clientSecret)
|
|
54
|
+
body.set("client_secret", provider.clientSecret);
|
|
55
|
+
try {
|
|
56
|
+
const res = await doFetch(url, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: body.toString() });
|
|
57
|
+
const parsed = await res.json().catch(() => ({}));
|
|
58
|
+
return { ok: res.ok, status: res.status, body: parsed && typeof parsed === "object" ? parsed : {} };
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return { ok: false, status: 0, body: {} };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Revoke a token that foxlink will not keep. Errors are ignored: the token is dropped either way. */
|
|
65
|
+
async function revokeQuietly(token) {
|
|
66
|
+
if (provider.revokeUrl && token)
|
|
67
|
+
await postForm(provider.revokeUrl, { token }, false);
|
|
68
|
+
}
|
|
69
|
+
async function save(tokens, scopes, replaceRefresh) {
|
|
70
|
+
await vault.remove(handles.access);
|
|
71
|
+
await vault.set(handles.access, tokens.access_token, { domains: provider.apiHosts });
|
|
72
|
+
if (transport === "inject") {
|
|
73
|
+
await vault.injectHeader({ handle: handles.access, header: "Authorization", hosts: provider.apiHosts, format: "Bearer {secret}", allowHttp: provider.allowHttp });
|
|
74
|
+
}
|
|
75
|
+
if (typeof tokens.refresh_token === "string" && tokens.refresh_token.length >= 8) {
|
|
76
|
+
await vault.remove(handles.refresh);
|
|
77
|
+
await vault.set(handles.refresh, tokens.refresh_token, { domains: tokenHosts });
|
|
78
|
+
}
|
|
79
|
+
else if (replaceRefresh) {
|
|
80
|
+
await vault.remove(handles.refresh);
|
|
81
|
+
}
|
|
82
|
+
const refreshable = (await vault.list()).some((s) => s.handle === handles.refresh);
|
|
83
|
+
const issuedAt = now();
|
|
84
|
+
const record = { scopes, issuedAt, expiresAt: issuedAt + lifetimeMs(tokens.expires_in), refreshable };
|
|
85
|
+
await store.set(key, record);
|
|
86
|
+
return record;
|
|
87
|
+
}
|
|
88
|
+
function checkIdToken(idToken, nonce) {
|
|
89
|
+
const payload = typeof idToken === "string" ? idToken.split(".")[1] : undefined;
|
|
90
|
+
let claims;
|
|
91
|
+
try {
|
|
92
|
+
claims = JSON.parse(atob((payload ?? "").replace(/-/g, "+").replace(/_/g, "/")));
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
throw idTokenFail();
|
|
96
|
+
}
|
|
97
|
+
const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
|
|
98
|
+
if (claims.nonce !== nonce || !aud.includes(provider.clientId))
|
|
99
|
+
throw idTokenFail();
|
|
100
|
+
if (provider.issuers.length && !provider.issuers.includes(String(claims.iss)))
|
|
101
|
+
throw idTokenFail();
|
|
102
|
+
}
|
|
103
|
+
async function connect(request = {}) {
|
|
104
|
+
if (connecting)
|
|
105
|
+
throw new FoxlinkError("busy", "A connect is already running.");
|
|
106
|
+
connecting = true;
|
|
107
|
+
// A refresh that is still running belongs to the old grant. It throws its result away.
|
|
108
|
+
generation += 1;
|
|
109
|
+
try {
|
|
110
|
+
const scopes = [...new Set(request.scopes ?? provider.scopes)];
|
|
111
|
+
if (scopes.length === 0 || !scopes.every((s) => provider.allowedScopes.includes(s))) {
|
|
112
|
+
throw new FoxlinkError("bad-scope", "Ask only for scopes that the provider config allows.");
|
|
113
|
+
}
|
|
114
|
+
const redirect = redirectUri();
|
|
115
|
+
const verifier = random(32);
|
|
116
|
+
const state = random(24);
|
|
117
|
+
const nonce = scopes.includes("openid") ? random(24) : undefined;
|
|
118
|
+
const url = new URL(provider.authorizeUrl);
|
|
119
|
+
const params = {
|
|
120
|
+
...provider.authParams,
|
|
121
|
+
client_id: provider.clientId,
|
|
122
|
+
redirect_uri: redirect,
|
|
123
|
+
response_type: "code",
|
|
124
|
+
scope: scopes.join(" "),
|
|
125
|
+
state,
|
|
126
|
+
code_challenge: await sha256(verifier),
|
|
127
|
+
code_challenge_method: "S256",
|
|
128
|
+
...(nonce ? { nonce } : {}),
|
|
129
|
+
};
|
|
130
|
+
for (const [name, value] of Object.entries(params))
|
|
131
|
+
url.searchParams.set(name, value);
|
|
132
|
+
const launch = options.launch ?? options.identity?.launchWebAuthFlow.bind(options.identity);
|
|
133
|
+
if (!launch)
|
|
134
|
+
throw new FoxlinkError("bad-config", "Pass identity (browser.identity) or launch.");
|
|
135
|
+
let back;
|
|
136
|
+
try {
|
|
137
|
+
back = new URL(await launch({ url: url.href, interactive: request.interactive ?? true }));
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
throw new FoxlinkError("authorize-failed", "The sign-in window failed or was closed.");
|
|
141
|
+
}
|
|
142
|
+
const expected = new URL(redirect);
|
|
143
|
+
if (back.origin !== expected.origin || back.pathname !== expected.pathname)
|
|
144
|
+
throw new FoxlinkError("redirect-mismatch", "The sign-in returned to a URL that is not the redirect URI.");
|
|
145
|
+
if (back.searchParams.get("state") !== state)
|
|
146
|
+
throw new FoxlinkError("state-mismatch", "The sign-in state does not match. Try to connect again.");
|
|
147
|
+
const error = back.searchParams.get("error");
|
|
148
|
+
if (error === "access_denied")
|
|
149
|
+
throw new FoxlinkError("access-denied", "The user did not allow access.");
|
|
150
|
+
const code = back.searchParams.get("code");
|
|
151
|
+
if (error || !code) {
|
|
152
|
+
const oauthError = safeCode(error);
|
|
153
|
+
throw new FoxlinkError("authorize-failed", `The sign-in failed${oauthError ? ` with ${oauthError}` : ""}.`, { oauthError });
|
|
154
|
+
}
|
|
155
|
+
const tokens = tokenResult(await postForm(provider.tokenUrl, { grant_type: "authorization_code", code, redirect_uri: redirect, code_verifier: verifier }));
|
|
156
|
+
const granted = typeof tokens.scope === "string" ? tokens.scope.split(" ").filter(Boolean) : scopes;
|
|
157
|
+
try {
|
|
158
|
+
if (granted.some((s) => !scopes.includes(s)))
|
|
159
|
+
throw new FoxlinkError("scope-creep", "The provider granted more scopes than foxlink asked for.");
|
|
160
|
+
if (nonce)
|
|
161
|
+
checkIdToken(tokens.id_token, nonce);
|
|
162
|
+
}
|
|
163
|
+
catch (refused) {
|
|
164
|
+
await revokeQuietly(tokens.refresh_token ?? tokens.access_token);
|
|
165
|
+
throw refused;
|
|
166
|
+
}
|
|
167
|
+
if (refreshing)
|
|
168
|
+
await refreshing.catch(() => undefined);
|
|
169
|
+
generation += 1;
|
|
170
|
+
await save(tokens, granted, true);
|
|
171
|
+
return await status();
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
connecting = false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
async function forget() {
|
|
178
|
+
await vault.remove(handles.access);
|
|
179
|
+
await vault.remove(handles.refresh);
|
|
180
|
+
await store.set(key, null);
|
|
181
|
+
generation += 1;
|
|
182
|
+
}
|
|
183
|
+
async function doRefresh() {
|
|
184
|
+
const started = generation;
|
|
185
|
+
const record = await readRecord();
|
|
186
|
+
if (!record)
|
|
187
|
+
throw new FoxlinkError("not-connected", "Connect first.");
|
|
188
|
+
if (!record.refreshable) {
|
|
189
|
+
await forget();
|
|
190
|
+
throw new FoxlinkError("reconnect", "The access token expired and there is no refresh token. Connect again.");
|
|
191
|
+
}
|
|
192
|
+
const result = await vault.use(handles.refresh, (token) => postForm(provider.tokenUrl, { grant_type: "refresh_token", refresh_token: token }));
|
|
193
|
+
if (result.status >= 400 && result.status < 500 && safeCode(result.body.error) === "invalid_grant") {
|
|
194
|
+
await forget();
|
|
195
|
+
throw new FoxlinkError("reconnect", "The provider refused the refresh token. Connect again.", { status: result.status, oauthError: "invalid_grant" });
|
|
196
|
+
}
|
|
197
|
+
// A connect or a disconnect ran meanwhile. This token is from a grant that foxlink no longer keeps.
|
|
198
|
+
if (generation !== started)
|
|
199
|
+
return;
|
|
200
|
+
const tokens = tokenResult(result);
|
|
201
|
+
// A refresh cannot add scopes. Keep the ones that both lists hold.
|
|
202
|
+
const granted = typeof tokens.scope === "string" ? tokens.scope.split(" ").filter((s) => record.scopes.includes(s)) : record.scopes;
|
|
203
|
+
await save(tokens, granted, false);
|
|
204
|
+
generation += 1;
|
|
205
|
+
}
|
|
206
|
+
/** Refresh the access token. Calls at the same time share one request. */
|
|
207
|
+
function refresh() {
|
|
208
|
+
refreshing ??= doRefresh().finally(() => {
|
|
209
|
+
refreshing = undefined;
|
|
210
|
+
});
|
|
211
|
+
return refreshing;
|
|
212
|
+
}
|
|
213
|
+
async function sendOnce(url, init) {
|
|
214
|
+
const headers = new Headers(init.headers);
|
|
215
|
+
headers.delete("authorization");
|
|
216
|
+
if (transport === "inject")
|
|
217
|
+
return doFetch(url.href, { ...init, headers });
|
|
218
|
+
const out = await vault.use(handles.access, async (token) => {
|
|
219
|
+
headers.set("authorization", `Bearer ${token}`);
|
|
220
|
+
try {
|
|
221
|
+
return { response: await doFetch(url.href, { ...init, headers }) };
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return { response: undefined };
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
if (!out.response)
|
|
228
|
+
throw new FoxlinkError("http-error", `The request to ${url.hostname} failed.`, { status: 0 });
|
|
229
|
+
return out.response;
|
|
230
|
+
}
|
|
231
|
+
async function send(url, init) {
|
|
232
|
+
if (refreshing)
|
|
233
|
+
await refreshing;
|
|
234
|
+
return sendOnce(url, init);
|
|
235
|
+
}
|
|
236
|
+
/** fetch for the API hosts, with the access token. It refreshes on expiry and one time on 401. */
|
|
237
|
+
async function authorizedFetch(input, init = {}) {
|
|
238
|
+
let url;
|
|
239
|
+
try {
|
|
240
|
+
url = new URL(String(input));
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
throw new FoxlinkError("bad-host", "Not a URL.");
|
|
244
|
+
}
|
|
245
|
+
const httpOk = url.protocol === "https:" || (provider.allowHttp && url.protocol === "http:");
|
|
246
|
+
if (!httpOk || !provider.apiHosts.includes(url.hostname))
|
|
247
|
+
throw new FoxlinkError("bad-host", `${url.hostname} is not an API host of this provider.`);
|
|
248
|
+
const record = await readRecord();
|
|
249
|
+
if (!record)
|
|
250
|
+
throw new FoxlinkError("not-connected", "Connect first.");
|
|
251
|
+
// The margin is at most half of the token life, so a short-lived token is not refreshed before every call.
|
|
252
|
+
const margin = Math.min(options.skewMs ?? 60_000, (record.expiresAt - record.issuedAt) / 2);
|
|
253
|
+
if (now() >= record.expiresAt - margin)
|
|
254
|
+
await refresh();
|
|
255
|
+
if (!(await readRecord()))
|
|
256
|
+
throw new FoxlinkError("not-connected", "Connect first.");
|
|
257
|
+
const seen = generation;
|
|
258
|
+
const first = await send(url, init);
|
|
259
|
+
if (first.status !== 401)
|
|
260
|
+
return first;
|
|
261
|
+
if (seen === generation)
|
|
262
|
+
await refresh();
|
|
263
|
+
if (!(await readRecord()))
|
|
264
|
+
throw new FoxlinkError("not-connected", "Connect first.");
|
|
265
|
+
const second = await send(url, init);
|
|
266
|
+
if (second.status === 401)
|
|
267
|
+
throw new FoxlinkError("unauthorized", "The provider refused the new access token.", { status: 401 });
|
|
268
|
+
return second;
|
|
269
|
+
}
|
|
270
|
+
/** Revoke the grant at the provider, then forget every token. It forgets them also when the revoke fails. */
|
|
271
|
+
async function disconnect() {
|
|
272
|
+
let revoked = false;
|
|
273
|
+
const held = new Set((await vault.list()).map((s) => s.handle));
|
|
274
|
+
const handle = held.has(handles.refresh) ? handles.refresh : held.has(handles.access) ? handles.access : undefined;
|
|
275
|
+
if (provider.revokeUrl && handle) {
|
|
276
|
+
const revokeUrl = provider.revokeUrl;
|
|
277
|
+
revoked = (await vault.use(handle, (token) => postForm(revokeUrl, { token }, false))).ok;
|
|
278
|
+
}
|
|
279
|
+
await forget();
|
|
280
|
+
return { revoked };
|
|
281
|
+
}
|
|
282
|
+
async function status() {
|
|
283
|
+
const record = await readRecord();
|
|
284
|
+
if (!record)
|
|
285
|
+
return { connected: false, scopes: [] };
|
|
286
|
+
return { connected: true, scopes: [...record.scopes], expiresAt: record.expiresAt, refreshable: record.refreshable };
|
|
287
|
+
}
|
|
288
|
+
return Object.freeze({
|
|
289
|
+
provider,
|
|
290
|
+
connect,
|
|
291
|
+
status,
|
|
292
|
+
fetch: authorizedFetch,
|
|
293
|
+
disconnect,
|
|
294
|
+
/** True when the user granted this scope. */
|
|
295
|
+
hasScope: async (scope) => Boolean((await readRecord())?.scopes.includes(scope)),
|
|
296
|
+
/** The redirect URI to register with the provider. */
|
|
297
|
+
redirectUri,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** An OAuth 2.0 provider that issues codes with PKCE. */
|
|
2
|
+
export interface ProviderConfig {
|
|
3
|
+
/** A short name, `a-z 0-9 -`. foxlink uses it in vault handles and store keys. */
|
|
4
|
+
id: string;
|
|
5
|
+
clientId: string;
|
|
6
|
+
/** Only for providers that want one from an installed app. It is sent to the token endpoint only. */
|
|
7
|
+
clientSecret?: string;
|
|
8
|
+
authorizeUrl: string;
|
|
9
|
+
tokenUrl: string;
|
|
10
|
+
revokeUrl?: string;
|
|
11
|
+
/** The scopes that `connect()` asks for by default. */
|
|
12
|
+
scopes: string[];
|
|
13
|
+
/** The most that `connect({ scopes })` may ask for. Default: `scopes`. */
|
|
14
|
+
allowedScopes?: string[];
|
|
15
|
+
/** The hosts that get the access token. */
|
|
16
|
+
apiHosts: string[];
|
|
17
|
+
/** More parameters for the authorize URL. */
|
|
18
|
+
authParams?: Record<string, string>;
|
|
19
|
+
/** `loopback` gives `http://127.0.0.1/mozoauth2/<id>`. `extension` gives `identity.getRedirectURL()`. */
|
|
20
|
+
redirect?: "loopback" | "extension";
|
|
21
|
+
/** The `iss` values to accept in an ID token. */
|
|
22
|
+
issuers?: string[];
|
|
23
|
+
/** Allow `http:` endpoints and API hosts. Only for test servers. */
|
|
24
|
+
allowHttp?: boolean;
|
|
25
|
+
}
|
|
26
|
+
export type Provider = Readonly<Required<Omit<ProviderConfig, "clientSecret" | "revokeUrl">>> & {
|
|
27
|
+
readonly clientSecret?: string;
|
|
28
|
+
readonly revokeUrl?: string;
|
|
29
|
+
};
|
|
30
|
+
/** Check a provider config and freeze it. Throws `bad-config`. */
|
|
31
|
+
export declare function defineProvider(config: ProviderConfig): Provider;
|
|
32
|
+
/** `http://127.0.0.1/mozoauth2/<subdomain>` from the URL that `identity.getRedirectURL()` returns (Firefox 86+). */
|
|
33
|
+
export declare function loopbackRedirectUrl(extensionRedirectUrl: string): string;
|
|
34
|
+
/** Google scopes that the Gmail and Calendar clients use. */
|
|
35
|
+
export declare const GOOGLE_SCOPES: Readonly<{
|
|
36
|
+
gmailRead: "https://www.googleapis.com/auth/gmail.readonly";
|
|
37
|
+
gmailSend: "https://www.googleapis.com/auth/gmail.send";
|
|
38
|
+
calendarRead: "https://www.googleapis.com/auth/calendar.readonly";
|
|
39
|
+
calendarEvents: "https://www.googleapis.com/auth/calendar.events";
|
|
40
|
+
}>;
|
|
41
|
+
export interface GoogleEndpoints {
|
|
42
|
+
authorizeUrl: string;
|
|
43
|
+
tokenUrl: string;
|
|
44
|
+
revokeUrl: string;
|
|
45
|
+
gmailBase: string;
|
|
46
|
+
calendarBase: string;
|
|
47
|
+
}
|
|
48
|
+
export declare const GOOGLE_ENDPOINTS: Readonly<GoogleEndpoints>;
|
|
49
|
+
export interface ProviderOptions {
|
|
50
|
+
clientId: string;
|
|
51
|
+
clientSecret?: string;
|
|
52
|
+
/** Default: Gmail read and Calendar read. */
|
|
53
|
+
scopes?: string[];
|
|
54
|
+
/** Default: `scopes`. Add `GOOGLE_SCOPES.gmailSend` or `calendarEvents` to let `connect` ask for them. */
|
|
55
|
+
allowedScopes?: string[];
|
|
56
|
+
/** Other endpoints, for a test server. */
|
|
57
|
+
endpoints?: Partial<GoogleEndpoints>;
|
|
58
|
+
allowHttp?: boolean;
|
|
59
|
+
}
|
|
60
|
+
/** The Google preset: loopback redirect, offline access, and read-only scopes by default. */
|
|
61
|
+
export declare function googleProvider(options: ProviderOptions): Provider & {
|
|
62
|
+
readonly endpoints: Readonly<GoogleEndpoints>;
|
|
63
|
+
};
|