Repository navigation
Expand file tree
/
Copy pathauthFetch.ts
More file actions
144 lines (133 loc) · 6.38 KB
/
Copy pathauthFetch.ts
File metadata and controls
144 lines (133 loc) · 6.38 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
// SPDX-FileCopyrightText: 2026 Anton Wiklund and Solid Memo contributors
//
// SPDX-License-Identifier: MIT
import { AppError } from "@solid-memo/domain/appError";
import { isInFolder } from "@solid-memo/domain/instanceLayout";
import type { SessionClient } from "./client/session";
/** Window event dispatched once the user's session expired (createAuthFetch says when). */
export const SESSION_EXPIRED_EVENT = "solid-memo:session-expired";
/** A challenge (WWW-Authenticate) that says the access token sent is no longer good. */
const INVALID_TOKEN = /\berror\s*=\s*"?invalid_token\b/i;
/** Methods that only read; every other one writes. */
const READS = new Set(["GET", "HEAD", "OPTIONS"]);
/** The statuses that send a request on to another address. */
const REDIRECTS = new Set([301, 302, 303, 307, 308]);
/** A fetch as someone with no login: no cookie, no credentials of any kind. */
export const anonymousFetch: typeof globalThis.fetch = (input, init) =>
globalThis.fetch(input, { ...init, credentials: "omit" });
/**
* The client's fetch, which always uses the CURRENT session (a bound
* reference captured at wiring time would predate login), and `forget`,
* which a logout calls.
*
* It announces an expired session (SESSION_EXPIRED_EVENT) on a 401 in
* two cases alone: the sign-in client, which had a user, has none now
* (its token expired, or the provider refused to refresh it), or a 401
* that names the token invalid (`invalid_token`) comes from one of the
* user's own storages (`ownStorages`) or their identity provider. A 401
* from any other server, such as an attached instance elsewhere that
* does not take the user's login, is the caller's to read as refused
* access: it says nothing of the session.
*
* It follows no redirect (`redirect: "error"`), whatever the caller
* asks: the fences in front of it (docs/boundaries.md) check the address
* asked for, so neither the browser nor the sign-in client, which asks
* again with fresh credentials where a redirected request ends 401 or
* 403, may take the user's credentials to another. When a request
* fails, and wasn't aborted, a HEAD as no one asks whether the address
* redirects, aborted with the request and its failure passed on if the
* request is aborted meanwhile; if it does, a read is asked again as no
* one, the redirect followed (a public profile behind one, say), and
* its answer kept only when it did follow one; a write is refused
* (`redirectRefused`). Any other failure is passed on. The HEAD asks as
* no one, so a server that sends only visitors with no login elsewhere
* (to a login page, say) shows a failed write there as a refused
* redirect.
*
* It refuses, before anything is sent, an address the proof of the
* user's login would name another server for (`credentialsRefused`,
* namesAnotherServer), whatever the method, whichever adapter asks.
*/
export function createAuthFetch(
client: SessionClient,
{
ownStorages = async () => [],
}: {
/** The user's own storages, by their WebID: where a token refused as invalid means the session expired. */
ownStorages?: (webId: string) => Promise<readonly string[]>;
} = {},
): { fetch: typeof globalThis.fetch; forget: () => void } {
/** The user the client last had, until the session ends or is logged out of. */
let user: string | undefined;
/** Whether a 401 from `url` says the session expired. */
async function expired(url: string, response: Response): Promise<boolean> {
if (user === undefined) return false;
const current = client.webId();
if (current === undefined) {
user = undefined;
return true;
}
if (!INVALID_TOKEN.test(response.headers.get("WWW-Authenticate") ?? "")) return false;
const issuer = await client.sessionIssuer().catch(() => null);
const provider = issuer === null ? undefined : URL.parse(issuer)?.origin;
if (provider !== undefined && URL.parse(url)?.origin === provider) return true;
const storages = await ownStorages(current).catch(() => []);
return storages.some((storage) => isInFolder(url, storage));
}
const fetch: typeof globalThis.fetch = async (input, init) => {
user = client.webId() ?? user;
const request = input instanceof Request ? input : undefined;
const url = request?.url ?? String(input);
if (namesAnotherServer(url)) throw new AppError("credentialsRefused", { url });
let response: Response;
try {
response = await client.fetch(input, { ...init, redirect: "error" });
} catch (error) {
const signal = init?.signal ?? request?.signal;
if (signal?.aborted || !(await redirects(url, signal)) || signal?.aborted) throw error;
const method = (init?.method ?? request?.method ?? "GET").toUpperCase();
if (!READS.has(method)) throw new AppError("redirectRefused", { method, url });
const answer = await anonymousFetch(input, init);
if (!answer.redirected) throw error;
return answer;
}
if (response.status === 401 && (await expired(url, response))) {
window.dispatchEvent(new Event(SESSION_EXPIRED_EVENT));
}
return response;
};
return {
fetch,
forget() {
user = undefined;
},
};
}
/**
* Whether the path of `url`, read as an address of its own, leads to
* another server, or to no address at all: Inrupt's sign-in client makes
* the address a proof of the login names in just that way, so the proof
* sent to `url` would be good at the other server. It is told from the
* parsed address, which is what a request sends: a `//` may be written
* `/.//`, with a backslash, or with a tab. An address with no origin (not
* one a login is for) or text that is no address names none.
*/
function namesAnotherServer(url: string): boolean {
const parsed = URL.parse(url);
if (parsed === null || parsed.origin === "null") return false;
return URL.parse(parsed.pathname, parsed.origin)?.origin !== parsed.origin;
}
/**
* Whether `url` answers with a redirect, asked as no one and not
* followed: a browser shows such an answer as an opaque one, other
* runtimes by its status. False when it can't be asked, or the
* request it asks for is aborted first (`signal`).
*/
async function redirects(url: string, signal: AbortSignal | undefined): Promise<boolean> {
try {
const answer = await anonymousFetch(url, { method: "HEAD", redirect: "manual", signal });
return answer.type === "opaqueredirect" || REDIRECTS.has(answer.status);
} catch {
return false;
}
}