@aplons/auth 0.1.0 → 0.2.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/README.md +88 -28
- package/dist/branding.d.ts +82 -0
- package/dist/branding.js +80 -0
- package/dist/client.d.ts +78 -55
- package/dist/client.js +173 -124
- package/dist/discovery.d.ts +11 -9
- package/dist/discovery.js +26 -26
- package/dist/errors.d.ts +12 -12
- package/dist/errors.js +49 -45
- package/dist/index.d.ts +12 -12
- package/dist/index.js +10 -10
- package/dist/next.d.ts +39 -37
- package/dist/next.js +259 -159
- package/dist/pkce.d.ts +27 -29
- package/dist/pkce.js +41 -43
- package/dist/types.d.ts +31 -32
- package/dist/verify.d.ts +14 -14
- package/dist/verify.js +47 -47
- package/package.json +8 -6
package/dist/client.js
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The connection to Aplons.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* der eine API dahinter absichert.
|
|
4
|
+
* Four steps, no more: start the login, complete it, refresh the tokens, log
|
|
5
|
+
* out. Plus verifying a token, for whoever has an API behind it.
|
|
7
6
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* `
|
|
7
|
+
* Everything else the flow needs — which endpoint lives where, which keys are
|
|
8
|
+
* currently valid — the package fetches itself. If you know `issuer` and
|
|
9
|
+
* `clientId`, you are done.
|
|
11
10
|
*/
|
|
12
|
-
import { AplonsError,
|
|
13
|
-
import { createChallenge, createState, createVerifier,
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
const
|
|
11
|
+
import { AplonsError, fromResponse } from "./errors.js";
|
|
12
|
+
import { createChallenge, createState, createVerifier, timingSafeEqual } from "./pkce.js";
|
|
13
|
+
import { fetchMetadata } from "./discovery.js";
|
|
14
|
+
import { jwksFor, verifyAccessToken, verifyIdToken } from "./verify.js";
|
|
15
|
+
const DEFAULT_SCOPE = ["openid", "profile", "email"];
|
|
17
16
|
export class AplonsAuth {
|
|
18
17
|
issuer;
|
|
19
18
|
clientId;
|
|
@@ -21,46 +20,48 @@ export class AplonsAuth {
|
|
|
21
20
|
scope;
|
|
22
21
|
#clientSecret;
|
|
23
22
|
#fetch;
|
|
24
|
-
#
|
|
23
|
+
#metadata;
|
|
25
24
|
constructor(options) {
|
|
26
|
-
if (!options.issuer)
|
|
27
|
-
throw new AplonsError({ code: "config", message: "issuer
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
if (!options.issuer) {
|
|
26
|
+
throw new AplonsError({ code: "config", message: "issuer is missing." });
|
|
27
|
+
}
|
|
28
|
+
if (!options.clientId) {
|
|
29
|
+
throw new AplonsError({ code: "config", message: "clientId is missing." });
|
|
30
|
+
}
|
|
30
31
|
if (!options.redirectUri) {
|
|
31
|
-
throw new AplonsError({ code: "config", message: "redirectUri
|
|
32
|
+
throw new AplonsError({ code: "config", message: "redirectUri is missing." });
|
|
32
33
|
}
|
|
33
|
-
//
|
|
34
|
+
// A trailing slash would otherwise produce `https://auth.example.com//oauth/…`.
|
|
34
35
|
this.issuer = options.issuer.replace(/\/+$/, "");
|
|
35
36
|
this.clientId = options.clientId;
|
|
36
37
|
this.redirectUri = options.redirectUri;
|
|
37
|
-
this.scope = options.scope ??
|
|
38
|
+
this.scope = options.scope ?? DEFAULT_SCOPE;
|
|
38
39
|
this.#clientSecret = options.clientSecret;
|
|
39
40
|
this.#fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
40
41
|
}
|
|
41
42
|
/**
|
|
42
|
-
*
|
|
43
|
+
* The endpoints, fetched once and then kept.
|
|
43
44
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
45
|
+
* Cached as a promise, not as a result: otherwise ten concurrent requests
|
|
46
|
+
* at startup fetch the same document ten times.
|
|
46
47
|
*/
|
|
47
|
-
|
|
48
|
-
this.#
|
|
49
|
-
return this.#
|
|
48
|
+
metadata() {
|
|
49
|
+
this.#metadata ??= fetchMetadata(this.issuer, this.#fetch);
|
|
50
|
+
return this.#metadata;
|
|
50
51
|
}
|
|
51
52
|
/**
|
|
52
|
-
*
|
|
53
|
+
* Step 1: where to send the browser.
|
|
53
54
|
*
|
|
54
|
-
* `verifier`
|
|
55
|
-
*
|
|
56
|
-
*
|
|
55
|
+
* `verifier`, `state` and `nonce` have to survive until the callback — in a
|
|
56
|
+
* short-lived, `httpOnly` cookie, not in localStorage: whatever sits there
|
|
57
|
+
* is readable by every script on the page.
|
|
57
58
|
*/
|
|
58
|
-
async
|
|
59
|
-
const
|
|
59
|
+
async startLogin(options) {
|
|
60
|
+
const metadata = await this.metadata();
|
|
60
61
|
const verifier = createVerifier();
|
|
61
62
|
const state = createState();
|
|
62
63
|
const nonce = createState();
|
|
63
|
-
const url = new URL(
|
|
64
|
+
const url = new URL(metadata.authorization_endpoint);
|
|
64
65
|
url.searchParams.set("response_type", "code");
|
|
65
66
|
url.searchParams.set("client_id", this.clientId);
|
|
66
67
|
url.searchParams.set("redirect_uri", this.redirectUri);
|
|
@@ -68,31 +69,31 @@ export class AplonsAuth {
|
|
|
68
69
|
url.searchParams.set("state", state);
|
|
69
70
|
url.searchParams.set("nonce", nonce);
|
|
70
71
|
url.searchParams.set("code_challenge", await createChallenge(verifier));
|
|
71
|
-
// S256
|
|
72
|
-
//
|
|
72
|
+
// S256 or nothing: "plain" puts the verifier in the same URL as the code
|
|
73
|
+
// and therefore protects against nothing.
|
|
73
74
|
url.searchParams.set("code_challenge_method", "S256");
|
|
74
|
-
if (options?.
|
|
75
|
+
if (options?.forceLogin)
|
|
75
76
|
url.searchParams.set("prompt", "login");
|
|
76
77
|
if (options?.tenant)
|
|
77
78
|
url.searchParams.set("tenant", options.tenant);
|
|
78
|
-
for (const [name,
|
|
79
|
-
url.searchParams.set(name,
|
|
79
|
+
for (const [name, value] of Object.entries(options?.extraParams ?? {})) {
|
|
80
|
+
url.searchParams.set(name, value);
|
|
80
81
|
}
|
|
81
82
|
return { url: url.toString(), verifier, state, nonce };
|
|
82
83
|
}
|
|
83
84
|
/**
|
|
84
|
-
*
|
|
85
|
+
* Step 2: the callback.
|
|
85
86
|
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
87
|
+
* Takes the full URL the browser came back with, plus the values from
|
|
88
|
+
* step 1.
|
|
88
89
|
*/
|
|
89
|
-
async
|
|
90
|
+
async completeLogin(options) {
|
|
90
91
|
const url = typeof options.url === "string" ? new URL(options.url) : options.url;
|
|
91
|
-
const
|
|
92
|
-
if (
|
|
92
|
+
const error = url.searchParams.get("error");
|
|
93
|
+
if (error) {
|
|
93
94
|
throw new AplonsError({
|
|
94
|
-
code:
|
|
95
|
-
message: `
|
|
95
|
+
code: error,
|
|
96
|
+
message: `The login was cancelled (${error}).` +
|
|
96
97
|
(url.searchParams.get("error_description")
|
|
97
98
|
? ` ${url.searchParams.get("error_description")}`
|
|
98
99
|
: ""),
|
|
@@ -101,148 +102,196 @@ export class AplonsAuth {
|
|
|
101
102
|
const state = url.searchParams.get("state");
|
|
102
103
|
const code = url.searchParams.get("code");
|
|
103
104
|
/*
|
|
104
|
-
|
|
105
|
+
The state is checked before anything happens with the code.
|
|
105
106
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
Daten ab.
|
|
107
|
+
Without this check someone could start a login with their own account
|
|
108
|
+
and hand the victim the finished redirect URL — the victim would end up
|
|
109
|
+
signed into a stranger's account and file their data there.
|
|
110
110
|
*/
|
|
111
|
-
if (!state || !
|
|
111
|
+
if (!state || !timingSafeEqual(state, options.state)) {
|
|
112
112
|
throw new AplonsError({
|
|
113
113
|
code: "state_mismatch",
|
|
114
|
-
message: "
|
|
115
|
-
"
|
|
116
|
-
"gestartet hat.",
|
|
114
|
+
message: "The state does not match. Either the login is too old, or the " +
|
|
115
|
+
"request did not come from the login this application started.",
|
|
117
116
|
});
|
|
118
117
|
}
|
|
119
118
|
if (!code) {
|
|
120
119
|
throw new AplonsError({
|
|
121
120
|
code: "missing_code",
|
|
122
|
-
message: "
|
|
121
|
+
message: "There is no code in the callback URL.",
|
|
123
122
|
});
|
|
124
123
|
}
|
|
125
|
-
const
|
|
124
|
+
const response = await this.#tokenRequest({
|
|
126
125
|
grant_type: "authorization_code",
|
|
127
126
|
code,
|
|
128
127
|
redirect_uri: this.redirectUri,
|
|
129
128
|
code_verifier: options.verifier,
|
|
130
129
|
});
|
|
131
|
-
return this.#
|
|
130
|
+
return this.#toSession(response, options.nonce);
|
|
132
131
|
}
|
|
133
|
-
/**
|
|
134
|
-
async
|
|
135
|
-
const
|
|
132
|
+
/** Step 3: trade an expired access token for a fresh one. */
|
|
133
|
+
async refresh(refreshToken) {
|
|
134
|
+
const response = await this.#tokenRequest({
|
|
136
135
|
grant_type: "refresh_token",
|
|
137
136
|
refresh_token: refreshToken,
|
|
138
137
|
});
|
|
139
|
-
return this.#
|
|
138
|
+
return this.#toSession(response);
|
|
140
139
|
}
|
|
141
140
|
/**
|
|
142
|
-
*
|
|
141
|
+
* Step 4: log out — at Aplons, not just here.
|
|
143
142
|
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
143
|
+
* Clearing your own session is not enough: the session at Aplons would
|
|
144
|
+
* survive, and the next login would sail through without a password. On a
|
|
145
|
+
* shared machine that is the difference between signed out and apparently
|
|
146
|
+
* signed out.
|
|
148
147
|
*/
|
|
149
|
-
async
|
|
150
|
-
const
|
|
151
|
-
const url = new URL(
|
|
148
|
+
async logoutUrl(options) {
|
|
149
|
+
const metadata = await this.metadata();
|
|
150
|
+
const url = new URL(required(metadata.end_session_endpoint, "end_session_endpoint", "Logging out"));
|
|
152
151
|
url.searchParams.set("client_id", this.clientId);
|
|
153
|
-
if (options?.
|
|
154
|
-
url.searchParams.set("post_logout_redirect_uri", options.
|
|
152
|
+
if (options?.returnTo) {
|
|
153
|
+
url.searchParams.set("post_logout_redirect_uri", options.returnTo);
|
|
154
|
+
}
|
|
155
155
|
if (options?.idToken)
|
|
156
156
|
url.searchParams.set("id_token_hint", options.idToken);
|
|
157
157
|
return url.toString();
|
|
158
158
|
}
|
|
159
159
|
/**
|
|
160
|
-
*
|
|
160
|
+
* Verify an access token — for whoever has their own API behind it.
|
|
161
161
|
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
162
|
+
* Checked against the public keys of Aplons, without a round trip on every
|
|
163
|
+
* call: signature, issuer and expiry. That is the difference between "the
|
|
164
|
+
* token looks real" and "the token is real".
|
|
165
165
|
*/
|
|
166
|
-
async
|
|
167
|
-
const
|
|
168
|
-
return
|
|
169
|
-
issuer:
|
|
170
|
-
|
|
166
|
+
async verifyAccessToken(token) {
|
|
167
|
+
const metadata = await this.metadata();
|
|
168
|
+
return verifyAccessToken(token, {
|
|
169
|
+
issuer: metadata.issuer,
|
|
170
|
+
keys: jwksFor(metadata.jwks_uri),
|
|
171
171
|
});
|
|
172
172
|
}
|
|
173
|
-
/**
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
173
|
+
/**
|
|
174
|
+
* Das Erscheinungsbild des Mandanten — Logo, Farben, Eckenradius.
|
|
175
|
+
*
|
|
176
|
+
* Ein Aufruf, damit eine eingebundene Oberfläche aussieht wie die Firma,
|
|
177
|
+
* für die sie gebaut ist, statt deren Logo ein zweites Mal zu pflegen:
|
|
178
|
+
*
|
|
179
|
+
* const branding = await auth.branding(session.accessToken);
|
|
180
|
+
*
|
|
181
|
+
* Welcher Mandant, steht im Token — nicht im Aufruf. Ein Konto bekommt
|
|
182
|
+
* damit immer genau das Erscheinungsbild, das es auf der Anmeldeseite auch
|
|
183
|
+
* gesehen hat.
|
|
184
|
+
*
|
|
185
|
+
* Es lohnt sich, das Ergebnis zwischenzuspeichern: ein Logo wechselt selten,
|
|
186
|
+
* und eine Seite, die es bei jedem Aufruf neu holt, wartet dafür jedes Mal
|
|
187
|
+
* auf eine Antwort aus dem Netz.
|
|
188
|
+
*
|
|
189
|
+
* Zwei Absagen sind möglich und meinen Verschiedenes: `insufficient_scope`
|
|
190
|
+
* heißt, das Token trägt den Bereich `branding` nicht — dann fehlt er in den
|
|
191
|
+
* `scope`s dieser Anwendung. `branding_not_shared` heißt, der Mandant gibt
|
|
192
|
+
* sein Erscheinungsbild nicht heraus; das steht in seinen Richtlinien und
|
|
193
|
+
* ist ab Werk aus.
|
|
194
|
+
*/
|
|
195
|
+
async branding(accessToken) {
|
|
196
|
+
const metadata = await this.metadata();
|
|
197
|
+
const response = await this.#fetch(
|
|
198
|
+
// Ältere Server kennen den Eintrag im Discovery-Dokument noch nicht.
|
|
199
|
+
// Die Adresse selbst gibt es dort aber schon, deshalb der Rückfall statt
|
|
200
|
+
// einer Fehlermeldung, die niemandem hilft.
|
|
201
|
+
metadata.branding_endpoint ?? `${this.issuer}/api/oauth/branding`, { headers: { authorization: `Bearer ${accessToken}` } });
|
|
202
|
+
if (!response.ok)
|
|
203
|
+
throw await fromResponse(response, "Loading the branding");
|
|
204
|
+
return (await response.json());
|
|
205
|
+
}
|
|
206
|
+
/** The details of the signed-in account, as far as the scopes allow. */
|
|
207
|
+
async userInfo(accessToken) {
|
|
208
|
+
const metadata = await this.metadata();
|
|
209
|
+
const response = await this.#fetch(metadata.userinfo_endpoint, {
|
|
177
210
|
headers: { authorization: `Bearer ${accessToken}` },
|
|
178
211
|
});
|
|
179
|
-
if (!
|
|
180
|
-
throw await
|
|
181
|
-
return (await
|
|
212
|
+
if (!response.ok)
|
|
213
|
+
throw await fromResponse(response, "Loading the profile");
|
|
214
|
+
return (await response.json());
|
|
182
215
|
}
|
|
183
216
|
/**
|
|
184
|
-
*
|
|
217
|
+
* Revoke a refresh token.
|
|
185
218
|
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
219
|
+
* Part of logging out: a token still valid for thirty days does not become
|
|
220
|
+
* invalid because a cookie was deleted.
|
|
188
221
|
*/
|
|
189
|
-
async
|
|
190
|
-
const
|
|
191
|
-
const
|
|
222
|
+
async revoke(refreshToken) {
|
|
223
|
+
const metadata = await this.metadata();
|
|
224
|
+
const body = new URLSearchParams({
|
|
192
225
|
token: refreshToken,
|
|
193
226
|
token_type_hint: "refresh_token",
|
|
194
227
|
client_id: this.clientId,
|
|
195
228
|
});
|
|
196
229
|
if (this.#clientSecret)
|
|
197
|
-
|
|
198
|
-
const
|
|
230
|
+
body.set("client_secret", this.#clientSecret);
|
|
231
|
+
const response = await this.#fetch(required(metadata.revocation_endpoint, "revocation_endpoint", "Revoking the token"), {
|
|
199
232
|
method: "POST",
|
|
200
233
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
201
|
-
body
|
|
234
|
+
body,
|
|
202
235
|
});
|
|
203
|
-
// RFC 7009
|
|
204
|
-
//
|
|
205
|
-
if (!
|
|
206
|
-
throw await
|
|
236
|
+
// RFC 7009 demands 200 even for a token that never existed — so nobody
|
|
237
|
+
// can find out from the status code which tokens are valid.
|
|
238
|
+
if (!response.ok)
|
|
239
|
+
throw await fromResponse(response, "Revoking the token");
|
|
207
240
|
}
|
|
208
|
-
async #
|
|
209
|
-
const
|
|
210
|
-
const
|
|
241
|
+
async #tokenRequest(fields) {
|
|
242
|
+
const metadata = await this.metadata();
|
|
243
|
+
const body = new URLSearchParams({ ...fields, client_id: this.clientId });
|
|
211
244
|
if (this.#clientSecret)
|
|
212
|
-
|
|
213
|
-
const
|
|
245
|
+
body.set("client_secret", this.#clientSecret);
|
|
246
|
+
const response = await this.#fetch(metadata.token_endpoint, {
|
|
214
247
|
method: "POST",
|
|
215
248
|
headers: {
|
|
216
249
|
"content-type": "application/x-www-form-urlencoded",
|
|
217
250
|
accept: "application/json",
|
|
218
251
|
},
|
|
219
|
-
body
|
|
252
|
+
body,
|
|
220
253
|
});
|
|
221
|
-
if (!
|
|
222
|
-
const
|
|
223
|
-
throw await
|
|
254
|
+
if (!response.ok) {
|
|
255
|
+
const isRefresh = fields.grant_type === "refresh_token";
|
|
256
|
+
throw await fromResponse(response, isRefresh ? "Refreshing the session" : "Exchanging the authorization code", isRefresh ? "refresh" : "code");
|
|
224
257
|
}
|
|
225
|
-
return (await
|
|
258
|
+
return (await response.json());
|
|
226
259
|
}
|
|
227
|
-
async #
|
|
228
|
-
const
|
|
229
|
-
const claims =
|
|
230
|
-
? await
|
|
231
|
-
issuer:
|
|
260
|
+
async #toSession(response, nonce) {
|
|
261
|
+
const metadata = await this.metadata();
|
|
262
|
+
const claims = response.id_token
|
|
263
|
+
? await verifyIdToken(response.id_token, {
|
|
264
|
+
issuer: metadata.issuer,
|
|
232
265
|
audience: this.clientId,
|
|
233
266
|
nonce,
|
|
234
|
-
|
|
267
|
+
keys: jwksFor(metadata.jwks_uri),
|
|
235
268
|
})
|
|
236
269
|
: undefined;
|
|
237
270
|
return {
|
|
238
|
-
accessToken:
|
|
239
|
-
refreshToken:
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
accessTokenExpiresAt: new Date(Date.now() +
|
|
243
|
-
idToken:
|
|
244
|
-
scope:
|
|
271
|
+
accessToken: response.access_token,
|
|
272
|
+
refreshToken: response.refresh_token,
|
|
273
|
+
// Turn the duration into a point in time right away: a duration is
|
|
274
|
+
// wrong the moment you store it somewhere.
|
|
275
|
+
accessTokenExpiresAt: new Date(Date.now() + response.expires_in * 1000),
|
|
276
|
+
idToken: response.id_token,
|
|
277
|
+
scope: response.scope ? response.scope.split(" ").filter(Boolean) : [],
|
|
245
278
|
claims,
|
|
246
279
|
};
|
|
247
280
|
}
|
|
248
281
|
}
|
|
282
|
+
/**
|
|
283
|
+
* Einen Eintrag aus dem Discovery-Dokument holen, den dieser Aufruf braucht.
|
|
284
|
+
*
|
|
285
|
+
* Fehlt er, sagt der Fehler *welcher* und *wofür* — statt eines nackten
|
|
286
|
+
* „Invalid URL" aus dem URL-Konstruktor, das beides verschweigt.
|
|
287
|
+
*/
|
|
288
|
+
function required(value, field, what) {
|
|
289
|
+
if (!value) {
|
|
290
|
+
throw new AplonsError({
|
|
291
|
+
code: "invalid_metadata",
|
|
292
|
+
message: `${what} is not possible: the server does not list a ${field} in ` +
|
|
293
|
+
"its /.well-known/openid-configuration.",
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
return value;
|
|
297
|
+
}
|
package/dist/discovery.d.ts
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Where things live at Aplons — asked for, not guessed.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* The endpoints could be hard-coded; that is exactly what went wrong in this
|
|
5
|
+
* project once already, when a hand-written list of scopes drifted away from
|
|
6
|
+
* the real one and applications requested the wrong things. Whoever asks
|
|
7
|
+
* gets today's answer.
|
|
8
8
|
*/
|
|
9
|
-
export type
|
|
9
|
+
export type Metadata = {
|
|
10
10
|
issuer: string;
|
|
11
11
|
authorization_endpoint: string;
|
|
12
12
|
token_endpoint: string;
|
|
13
13
|
userinfo_endpoint: string;
|
|
14
14
|
jwks_uri: string;
|
|
15
|
-
revocation_endpoint
|
|
16
|
-
end_session_endpoint
|
|
15
|
+
revocation_endpoint?: string;
|
|
16
|
+
end_session_endpoint?: string;
|
|
17
|
+
/** Das Erscheinungsbild des Mandanten. Eigene Zutat, kein OpenID Connect. */
|
|
18
|
+
branding_endpoint?: string;
|
|
17
19
|
scopes_supported?: string[];
|
|
18
20
|
code_challenge_methods_supported?: string[];
|
|
19
21
|
};
|
|
20
|
-
export declare function
|
|
22
|
+
export declare function fetchMetadata(issuer: string, fetchImpl: typeof globalThis.fetch): Promise<Metadata>;
|
package/dist/discovery.js
CHANGED
|
@@ -1,57 +1,57 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Where things live at Aplons — asked for, not guessed.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* The endpoints could be hard-coded; that is exactly what went wrong in this
|
|
5
|
+
* project once already, when a hand-written list of scopes drifted away from
|
|
6
|
+
* the real one and applications requested the wrong things. Whoever asks
|
|
7
|
+
* gets today's answer.
|
|
8
8
|
*/
|
|
9
|
-
import { AplonsError,
|
|
10
|
-
const
|
|
9
|
+
import { AplonsError, fromResponse } from "./errors.js";
|
|
10
|
+
const REQUIRED = [
|
|
11
11
|
"issuer",
|
|
12
12
|
"authorization_endpoint",
|
|
13
13
|
"token_endpoint",
|
|
14
14
|
"userinfo_endpoint",
|
|
15
15
|
"jwks_uri",
|
|
16
16
|
];
|
|
17
|
-
export async function
|
|
17
|
+
export async function fetchMetadata(issuer, fetchImpl) {
|
|
18
18
|
const url = `${issuer}/.well-known/openid-configuration`;
|
|
19
|
-
let
|
|
19
|
+
let response;
|
|
20
20
|
try {
|
|
21
|
-
|
|
21
|
+
response = await fetchImpl(url, { headers: { accept: "application/json" } });
|
|
22
22
|
}
|
|
23
23
|
catch (cause) {
|
|
24
24
|
throw new AplonsError({
|
|
25
25
|
code: "unreachable",
|
|
26
26
|
cause,
|
|
27
|
-
message: `${issuer}
|
|
28
|
-
"
|
|
27
|
+
message: `${issuer} is not reachable. Is the address right, and can this ` +
|
|
28
|
+
"server reach the network at all?",
|
|
29
29
|
});
|
|
30
30
|
}
|
|
31
|
-
if (!
|
|
32
|
-
throw await
|
|
33
|
-
const
|
|
34
|
-
for (const
|
|
35
|
-
if (!
|
|
31
|
+
if (!response.ok)
|
|
32
|
+
throw await fromResponse(response, `Loading ${url}`);
|
|
33
|
+
const metadata = (await response.json());
|
|
34
|
+
for (const field of REQUIRED) {
|
|
35
|
+
if (!metadata[field]) {
|
|
36
36
|
throw new AplonsError({
|
|
37
37
|
code: "invalid_metadata",
|
|
38
|
-
message: `${url}
|
|
38
|
+
message: `${url} contains no ${field}. Is this really an Aplons server?`,
|
|
39
39
|
});
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
/*
|
|
43
|
-
|
|
43
|
+
The issuer has to match the address we asked at.
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
Without this check a planted discovery URL could point at foreign
|
|
46
|
+
endpoints, and the application would send its logins there. RFC 8414
|
|
47
|
+
requires the check for exactly this reason.
|
|
48
48
|
*/
|
|
49
|
-
if (
|
|
49
|
+
if (metadata.issuer.replace(/\/+$/, "") !== issuer) {
|
|
50
50
|
throw new AplonsError({
|
|
51
51
|
code: "issuer_mismatch",
|
|
52
|
-
message: `
|
|
53
|
-
"
|
|
52
|
+
message: `The server at ${issuer} calls itself "${metadata.issuer}". That must ` +
|
|
53
|
+
"not happen — the login will not continue.",
|
|
54
54
|
});
|
|
55
55
|
}
|
|
56
|
-
return
|
|
56
|
+
return metadata;
|
|
57
57
|
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* An error that says what to do about it.
|
|
3
3
|
*
|
|
4
|
-
* OAuth
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* OAuth answers with identifiers like `invalid_grant` — right for a machine,
|
|
5
|
+
* useless for the person reading a log at three in the morning. Every error
|
|
6
|
+
* here carries both: the identifier for the code and a sentence for the
|
|
7
|
+
* human.
|
|
8
8
|
*/
|
|
9
9
|
export declare class AplonsError extends Error {
|
|
10
|
-
/**
|
|
10
|
+
/** The OAuth identifier, e.g. `invalid_grant`. */
|
|
11
11
|
readonly code: string;
|
|
12
|
-
/**
|
|
12
|
+
/** The HTTP status, when the error came from a response. */
|
|
13
13
|
readonly status?: number;
|
|
14
|
-
/**
|
|
14
|
+
/** What the server wrote about it. */
|
|
15
15
|
readonly description?: string;
|
|
16
16
|
constructor(options: {
|
|
17
17
|
code: string;
|
|
@@ -21,7 +21,7 @@ export declare class AplonsError extends Error {
|
|
|
21
21
|
cause?: unknown;
|
|
22
22
|
});
|
|
23
23
|
}
|
|
24
|
-
/**
|
|
25
|
-
export type
|
|
26
|
-
/**
|
|
27
|
-
export declare function
|
|
24
|
+
/** Which flow just failed. */
|
|
25
|
+
export type Flow = "code" | "refresh" | "generic";
|
|
26
|
+
/** Turn a failed response into something usable. */
|
|
27
|
+
export declare function fromResponse(response: Response, what: string, flow?: Flow): Promise<AplonsError>;
|