@aplons/auth 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/README.md ADDED
@@ -0,0 +1,164 @@
1
+ # @aplons/auth
2
+
3
+ Anmeldung über [Aplons](https://auth.aplons.com) in eigenen Anwendungen.
4
+
5
+ OAuth 2.1 mit PKCE, ohne den Ablauf selbst zu schreiben. Eine einzige
6
+ Abhängigkeit (`jose`, zum Prüfen von Unterschriften) — alles andere macht die
7
+ eingebaute Web-Crypto. Läuft in Node ab 18, im Browser, am Rand und in einem
8
+ Worker.
9
+
10
+ ```bash
11
+ npm i @aplons/auth
12
+ ```
13
+
14
+ ## Mit Next.js
15
+
16
+ Eine Datei, vier Adressen:
17
+
18
+ ```ts
19
+ // app/api/auth/[...aplons]/route.ts
20
+ import { handhabe } from "@aplons/auth/next";
21
+
22
+ export const { GET, POST } = handhabe({
23
+ issuer: process.env.APLONS_ISSUER!, // https://auth.aplons.com
24
+ clientId: process.env.APLONS_CLIENT_ID!,
25
+ clientSecret: process.env.APLONS_CLIENT_SECRET,
26
+ redirectUri: process.env.APLONS_REDIRECT_URI!,
27
+ nachAnmeldung: "/",
28
+ });
29
+ ```
30
+
31
+ Das ergibt:
32
+
33
+ | Adresse | wozu |
34
+ |---|---|
35
+ | `/api/auth/login` | Anmeldung starten. `?weiter=/pfad` merkt sich, wohin danach. |
36
+ | `/api/auth/callback` | Rückkehr von Aplons. Hier ist nichts zu tun. |
37
+ | `/api/auth/logout` | Abmelden — hier **und** bei Aplons. |
38
+ | `/api/auth/me` | Wer angemeldet ist; erneuert das Token still, wenn nötig. |
39
+
40
+ Ein Anmeldeknopf ist dann ein Link:
41
+
42
+ ```tsx
43
+ <a href="/api/auth/login?weiter=/rechnungen">Anmelden</a>
44
+ ```
45
+
46
+ Die Sitzung in einer eigenen Route lesen:
47
+
48
+ ```ts
49
+ const { sitzungAus, auth } = handhabe({ /* … */ });
50
+
51
+ export async function GET(request: Request) {
52
+ const sitzung = await sitzungAus(request);
53
+ if (!sitzung) return new Response("nicht angemeldet", { status: 401 });
54
+
55
+ const profil = await auth.profil(sitzung.accessToken);
56
+ return Response.json(profil);
57
+ }
58
+ ```
59
+
60
+ ## Ohne Rahmenwerk
61
+
62
+ ```ts
63
+ import { AplonsAuth } from "@aplons/auth";
64
+
65
+ const auth = new AplonsAuth({
66
+ issuer: "https://auth.aplons.com",
67
+ clientId: "cli_…",
68
+ clientSecret: "cs_…", // nur wenn die Anwendung einen Server hat
69
+ redirectUri: "https://app.example.de/callback",
70
+ });
71
+
72
+ // 1. Hinschicken
73
+ const { url, verifier, state, nonce } = await auth.start();
74
+ // verifier, state und nonce bis zur Rückkehr aufbewahren
75
+
76
+ // 2. Zurückkommen
77
+ const sitzung = await auth.rueckkehr({ url: anfrageUrl, verifier, state, nonce });
78
+ sitzung.accessToken;
79
+ sitzung.claims?.email;
80
+
81
+ // 3. Erneuern
82
+ const frisch = await auth.erneuern(sitzung.refreshToken!);
83
+
84
+ // 4. Abmelden
85
+ const abmelden = await auth.abmeldeUrl({ danach: "https://app.example.de/" });
86
+ ```
87
+
88
+ **`verifier`, `state` und `nonce` gehören in ein kurzlebiges, `httpOnly`
89
+ gesetztes Cookie — nicht in den `localStorage`.** Was dort liegt, liest jedes
90
+ Skript auf der Seite, und mit dem Verifier lässt sich ein abgefangener
91
+ Anmeldecode einlösen. Der Next-Teil oben macht das schon richtig.
92
+
93
+ ## Eine API dahinter absichern
94
+
95
+ ```ts
96
+ const claims = await auth.pruefeToken(token);
97
+ claims.sub; // das Konto
98
+ claims.tid; // der Mandant
99
+ claims.rls; // Rollen im Mandanten
100
+ ```
101
+
102
+ Geprüft werden Unterschrift, Aussteller und Ablauf — gegen die öffentlichen
103
+ Schlüssel von Aplons, ohne Rückfrage bei jedem Aufruf. Das ist der Unterschied
104
+ zwischen „das Token sieht echt aus" und „das Token ist echt": ein JWT lässt
105
+ sich mit `atob` lesen, ohne dass irgendetwas geprüft wurde.
106
+
107
+ Die Schlüssel werden zwischengespeichert und bei einem unbekannten `kid` neu
108
+ geholt, damit ein Schlüsselwechsel bei Aplons keinen Ausfall verursacht.
109
+
110
+ ## Öffentlich oder vertraulich
111
+
112
+ Eine Anwendung mit Server behält ihr `clientSecret`. Eine, die im Browser oder
113
+ auf einem Telefon läuft, **lässt es weg**: was dort mitgeliefert wird, ist
114
+ nicht geheim, und ein „Geheimnis", das jeder auslesen kann, macht die Sache
115
+ nicht sicherer, sondern nur unübersichtlich. Genau dafür gibt es PKCE.
116
+
117
+ ## Fehler
118
+
119
+ Jeder Fehler ist ein `AplonsError` mit `code` für den Programmablauf und einer
120
+ Meldung für den Menschen:
121
+
122
+ ```ts
123
+ import { AplonsError } from "@aplons/auth";
124
+
125
+ try {
126
+ await auth.rueckkehr({ /* … */ });
127
+ } catch (fehler) {
128
+ if (fehler instanceof AplonsError && fehler.code === "invalid_grant") {
129
+ // meistens: die Rückkehr wurde zweimal ausgeführt
130
+ }
131
+ }
132
+ ```
133
+
134
+ Häufig:
135
+
136
+ | `code` | heißt |
137
+ |---|---|
138
+ | `state_mismatch` | Der Vorgang ist zu alt, oder der Aufruf gehört nicht zu dieser Anmeldung. |
139
+ | `invalid_grant` | Beim Anmeldecode: schon eingelöst. Beim Refresh-Token: abgelaufen oder schon getauscht. |
140
+ | `invalid_client` | Client-ID oder -Secret stimmen nicht. |
141
+ | `issuer_mismatch` | Unter `issuer` meldet sich ein Server, der sich anders nennt. Wird nicht fortgesetzt. |
142
+
143
+ ## Bereiche
144
+
145
+ `openid`, `profile`, `email`, `phone`, `roles`, `app_profile`. Voreinstellung
146
+ sind die ersten drei. `roles` liefert die Rollen des Kontos **in dieser
147
+ Anwendung**, `app_profile` deren eigene Felder — beide über `auth.profil()`.
148
+
149
+ ## Sitzungen ablegen
150
+
151
+ Ohne eigene Angabe legt der Next-Teil die Sitzung im Cookie ab. Das kommt ohne
152
+ Speicher aus, hat aber zwei Grenzen: 4 KB, und ein Widerruf wirkt erst, wenn
153
+ das Zugriffstoken abläuft. Wer eine Datenbank hat, gibt seine eigene Ablage an:
154
+
155
+ ```ts
156
+ handhabe({
157
+ /* … */
158
+ speicher: {
159
+ async lies(id) { /* … */ },
160
+ async schreib(id, sitzung) { /* … */ },
161
+ async loesche(id) { /* … */ },
162
+ },
163
+ });
164
+ ```
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Die Anbindung an Aplons.
3
+ *
4
+ * Vier Schritte, mehr ist es nicht: Anmeldung starten, Rückkehr entgegen-
5
+ * nehmen, Token erneuern, abmelden. Dazu das Prüfen eines Tokens für den,
6
+ * der eine API dahinter absichert.
7
+ *
8
+ * Alles, was der Ablauf sonst noch braucht — welcher Endpunkt wo liegt,
9
+ * welche Schlüssel gerade gültig sind —, holt sich das Paket selbst. Wer nur
10
+ * `issuer` und `clientId` kennt, ist fertig.
11
+ */
12
+ import { type Metadaten } from "./discovery.js";
13
+ import type { AccessTokenClaims, Anmeldevorgang, AplonsOptions, Profil, Sitzung } from "./types.js";
14
+ export declare class AplonsAuth {
15
+ #private;
16
+ readonly issuer: string;
17
+ readonly clientId: string;
18
+ readonly redirectUri: string;
19
+ readonly scope: string[];
20
+ constructor(options: AplonsOptions);
21
+ /**
22
+ * Die Endpunkte, einmal geholt und dann behalten.
23
+ *
24
+ * Als Versprechen zwischengespeichert, nicht als Ergebnis: sonst holen
25
+ * zehn gleichzeitige Anfragen beim Start zehnmal dieselbe Datei.
26
+ */
27
+ metadaten(): Promise<Metadaten>;
28
+ /**
29
+ * Schritt 1: Wohin der Browser geschickt wird.
30
+ *
31
+ * `verifier` und `state` müssen bis zur Rückkehr aufbewahrt werden — in
32
+ * einem kurzlebigen, `httpOnly`-Cookie, nicht im localStorage: was dort
33
+ * liegt, liest jedes Skript auf der Seite.
34
+ */
35
+ start(options?: {
36
+ /** Überschreibt die Bereiche aus dem Konstruktor. */
37
+ scope?: string[];
38
+ /** Wird unverändert zurückgegeben — etwa die Seite, die aufgerufen war. */
39
+ zusatz?: Record<string, string>;
40
+ /** Erzwingt die Anmeldemaske, auch wenn schon eine Sitzung besteht. */
41
+ erneutAnmelden?: boolean;
42
+ /** Setzt die Anmeldeseite auf diesen Mandanten. */
43
+ tenant?: string;
44
+ }): Promise<Anmeldevorgang>;
45
+ /**
46
+ * Schritt 2: Die Rückkehr.
47
+ *
48
+ * Nimmt den vollständigen URL entgegen, mit dem der Browser zurückkam, und
49
+ * die beiden Werte aus Schritt 1.
50
+ */
51
+ rueckkehr(options: {
52
+ /** Der Aufruf-URL, komplett. */
53
+ url: string | URL;
54
+ verifier: string;
55
+ /** Der State aus Schritt 1 — wird gegen den im URL geprüft. */
56
+ state: string;
57
+ /** Der Nonce aus Schritt 1, wenn ein ID-Token erwartet wird. */
58
+ nonce?: string;
59
+ }): Promise<Sitzung>;
60
+ /** Schritt 3: Ein abgelaufenes Zugriffstoken gegen ein frisches tauschen. */
61
+ erneuern(refreshToken: string): Promise<Sitzung>;
62
+ /**
63
+ * Schritt 4: Abmelden — bei Aplons, nicht nur hier.
64
+ *
65
+ * Nur die eigene Sitzung zu löschen genügt nicht: die Sitzung bei Aplons
66
+ * bliebe bestehen, und die nächste Anmeldung liefe ohne Passwort durch.
67
+ * Auf einem geteilten Rechner ist das der Unterschied zwischen abgemeldet
68
+ * und scheinbar abgemeldet.
69
+ */
70
+ abmeldeUrl(options?: {
71
+ /** Wohin Aplons nach dem Abmelden zurückschickt. Muss hinterlegt sein. */
72
+ danach?: string;
73
+ /** Das ID-Token der Sitzung; damit weiß Aplons, wen es abmeldet. */
74
+ idToken?: string;
75
+ }): Promise<string>;
76
+ /**
77
+ * Ein Zugriffstoken prüfen — für den, der eine eigene API dahinter hat.
78
+ *
79
+ * Geprüft wird gegen die öffentlichen Schlüssel von Aplons, ohne Rückfrage
80
+ * bei jedem Aufruf: Unterschrift, Aussteller und Ablauf. Das ist der
81
+ * Unterschied zwischen „das Token sieht echt aus" und „das Token ist echt".
82
+ */
83
+ pruefeToken(token: string): Promise<AccessTokenClaims>;
84
+ /** Die Angaben zum angemeldeten Konto, so weit die Bereiche es hergeben. */
85
+ profil(accessToken: string): Promise<Profil>;
86
+ /**
87
+ * Ein Refresh-Token entwerten.
88
+ *
89
+ * Gehört zum Abmelden dazu: ein Token, das noch dreißig Tage gilt, wird
90
+ * vom Löschen des Cookies nicht ungültig.
91
+ */
92
+ widerrufen(refreshToken: string): Promise<void>;
93
+ }
package/dist/client.js ADDED
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Die Anbindung an Aplons.
3
+ *
4
+ * Vier Schritte, mehr ist es nicht: Anmeldung starten, Rückkehr entgegen-
5
+ * nehmen, Token erneuern, abmelden. Dazu das Prüfen eines Tokens für den,
6
+ * der eine API dahinter absichert.
7
+ *
8
+ * Alles, was der Ablauf sonst noch braucht — welcher Endpunkt wo liegt,
9
+ * welche Schlüssel gerade gültig sind —, holt sich das Paket selbst. Wer nur
10
+ * `issuer` und `clientId` kennt, ist fertig.
11
+ */
12
+ import { AplonsError, ausAntwort } from "./errors.js";
13
+ import { createChallenge, createState, createVerifier, gleich } from "./pkce.js";
14
+ import { holeMetadaten } from "./discovery.js";
15
+ import { pruefeIdToken, pruefeZugriffstoken, schluesselFuer } from "./verify.js";
16
+ const STANDARD_SCOPE = ["openid", "profile", "email"];
17
+ export class AplonsAuth {
18
+ issuer;
19
+ clientId;
20
+ redirectUri;
21
+ scope;
22
+ #clientSecret;
23
+ #fetch;
24
+ #metadaten;
25
+ constructor(options) {
26
+ if (!options.issuer)
27
+ throw new AplonsError({ code: "config", message: "issuer fehlt." });
28
+ if (!options.clientId)
29
+ throw new AplonsError({ code: "config", message: "clientId fehlt." });
30
+ if (!options.redirectUri) {
31
+ throw new AplonsError({ code: "config", message: "redirectUri fehlt." });
32
+ }
33
+ // Ein Schrägstrich am Ende erzeugt sonst `https://auth.example.com//oauth/…`.
34
+ this.issuer = options.issuer.replace(/\/+$/, "");
35
+ this.clientId = options.clientId;
36
+ this.redirectUri = options.redirectUri;
37
+ this.scope = options.scope ?? STANDARD_SCOPE;
38
+ this.#clientSecret = options.clientSecret;
39
+ this.#fetch = options.fetch ?? globalThis.fetch.bind(globalThis);
40
+ }
41
+ /**
42
+ * Die Endpunkte, einmal geholt und dann behalten.
43
+ *
44
+ * Als Versprechen zwischengespeichert, nicht als Ergebnis: sonst holen
45
+ * zehn gleichzeitige Anfragen beim Start zehnmal dieselbe Datei.
46
+ */
47
+ metadaten() {
48
+ this.#metadaten ??= holeMetadaten(this.issuer, this.#fetch);
49
+ return this.#metadaten;
50
+ }
51
+ /**
52
+ * Schritt 1: Wohin der Browser geschickt wird.
53
+ *
54
+ * `verifier` und `state` müssen bis zur Rückkehr aufbewahrt werden — in
55
+ * einem kurzlebigen, `httpOnly`-Cookie, nicht im localStorage: was dort
56
+ * liegt, liest jedes Skript auf der Seite.
57
+ */
58
+ async start(options) {
59
+ const metadaten = await this.metadaten();
60
+ const verifier = createVerifier();
61
+ const state = createState();
62
+ const nonce = createState();
63
+ const url = new URL(metadaten.authorization_endpoint);
64
+ url.searchParams.set("response_type", "code");
65
+ url.searchParams.set("client_id", this.clientId);
66
+ url.searchParams.set("redirect_uri", this.redirectUri);
67
+ url.searchParams.set("scope", (options?.scope ?? this.scope).join(" "));
68
+ url.searchParams.set("state", state);
69
+ url.searchParams.set("nonce", nonce);
70
+ url.searchParams.set("code_challenge", await createChallenge(verifier));
71
+ // S256 oder gar nicht: „plain" legt den Verifier in denselben URL wie den
72
+ // Code und schützt damit vor nichts.
73
+ url.searchParams.set("code_challenge_method", "S256");
74
+ if (options?.erneutAnmelden)
75
+ url.searchParams.set("prompt", "login");
76
+ if (options?.tenant)
77
+ url.searchParams.set("tenant", options.tenant);
78
+ for (const [name, wert] of Object.entries(options?.zusatz ?? {})) {
79
+ url.searchParams.set(name, wert);
80
+ }
81
+ return { url: url.toString(), verifier, state, nonce };
82
+ }
83
+ /**
84
+ * Schritt 2: Die Rückkehr.
85
+ *
86
+ * Nimmt den vollständigen URL entgegen, mit dem der Browser zurückkam, und
87
+ * die beiden Werte aus Schritt 1.
88
+ */
89
+ async rueckkehr(options) {
90
+ const url = typeof options.url === "string" ? new URL(options.url) : options.url;
91
+ const fehler = url.searchParams.get("error");
92
+ if (fehler) {
93
+ throw new AplonsError({
94
+ code: fehler,
95
+ message: `Die Anmeldung wurde abgebrochen (${fehler}).` +
96
+ (url.searchParams.get("error_description")
97
+ ? ` ${url.searchParams.get("error_description")}`
98
+ : ""),
99
+ });
100
+ }
101
+ const state = url.searchParams.get("state");
102
+ const code = url.searchParams.get("code");
103
+ /*
104
+ Der State wird geprüft, bevor irgendetwas mit dem Code geschieht.
105
+
106
+ Ohne diese Prüfung könnte jemand einen Anmeldevorgang mit seinem eigenen
107
+ Konto beginnen und dem Opfer den fertigen Rückkehr-URL unterschieben —
108
+ das Opfer wäre danach im fremden Konto angemeldet und legte dort seine
109
+ Daten ab.
110
+ */
111
+ if (!state || !gleich(state, options.state)) {
112
+ throw new AplonsError({
113
+ code: "state_mismatch",
114
+ message: "Der state stimmt nicht. Entweder ist der Anmeldevorgang zu alt, " +
115
+ "oder der Aufruf kam nicht von der Anmeldung, die diese Anwendung " +
116
+ "gestartet hat.",
117
+ });
118
+ }
119
+ if (!code) {
120
+ throw new AplonsError({
121
+ code: "missing_code",
122
+ message: "Im Rückkehr-URL steht kein code.",
123
+ });
124
+ }
125
+ const antwort = await this.#token({
126
+ grant_type: "authorization_code",
127
+ code,
128
+ redirect_uri: this.redirectUri,
129
+ code_verifier: options.verifier,
130
+ });
131
+ return this.#alsSitzung(antwort, options.nonce);
132
+ }
133
+ /** Schritt 3: Ein abgelaufenes Zugriffstoken gegen ein frisches tauschen. */
134
+ async erneuern(refreshToken) {
135
+ const antwort = await this.#token({
136
+ grant_type: "refresh_token",
137
+ refresh_token: refreshToken,
138
+ });
139
+ return this.#alsSitzung(antwort);
140
+ }
141
+ /**
142
+ * Schritt 4: Abmelden — bei Aplons, nicht nur hier.
143
+ *
144
+ * Nur die eigene Sitzung zu löschen genügt nicht: die Sitzung bei Aplons
145
+ * bliebe bestehen, und die nächste Anmeldung liefe ohne Passwort durch.
146
+ * Auf einem geteilten Rechner ist das der Unterschied zwischen abgemeldet
147
+ * und scheinbar abgemeldet.
148
+ */
149
+ async abmeldeUrl(options) {
150
+ const metadaten = await this.metadaten();
151
+ const url = new URL(metadaten.end_session_endpoint);
152
+ url.searchParams.set("client_id", this.clientId);
153
+ if (options?.danach)
154
+ url.searchParams.set("post_logout_redirect_uri", options.danach);
155
+ if (options?.idToken)
156
+ url.searchParams.set("id_token_hint", options.idToken);
157
+ return url.toString();
158
+ }
159
+ /**
160
+ * Ein Zugriffstoken prüfen — für den, der eine eigene API dahinter hat.
161
+ *
162
+ * Geprüft wird gegen die öffentlichen Schlüssel von Aplons, ohne Rückfrage
163
+ * bei jedem Aufruf: Unterschrift, Aussteller und Ablauf. Das ist der
164
+ * Unterschied zwischen „das Token sieht echt aus" und „das Token ist echt".
165
+ */
166
+ async pruefeToken(token) {
167
+ const metadaten = await this.metadaten();
168
+ return pruefeZugriffstoken(token, {
169
+ issuer: metadaten.issuer,
170
+ schluessel: schluesselFuer(metadaten.jwks_uri),
171
+ });
172
+ }
173
+ /** Die Angaben zum angemeldeten Konto, so weit die Bereiche es hergeben. */
174
+ async profil(accessToken) {
175
+ const metadaten = await this.metadaten();
176
+ const antwort = await this.#fetch(metadaten.userinfo_endpoint, {
177
+ headers: { authorization: `Bearer ${accessToken}` },
178
+ });
179
+ if (!antwort.ok)
180
+ throw await ausAntwort(antwort, "Das Profil zu laden");
181
+ return (await antwort.json());
182
+ }
183
+ /**
184
+ * Ein Refresh-Token entwerten.
185
+ *
186
+ * Gehört zum Abmelden dazu: ein Token, das noch dreißig Tage gilt, wird
187
+ * vom Löschen des Cookies nicht ungültig.
188
+ */
189
+ async widerrufen(refreshToken) {
190
+ const metadaten = await this.metadaten();
191
+ const koerper = new URLSearchParams({
192
+ token: refreshToken,
193
+ token_type_hint: "refresh_token",
194
+ client_id: this.clientId,
195
+ });
196
+ if (this.#clientSecret)
197
+ koerper.set("client_secret", this.#clientSecret);
198
+ const antwort = await this.#fetch(metadaten.revocation_endpoint, {
199
+ method: "POST",
200
+ headers: { "content-type": "application/x-www-form-urlencoded" },
201
+ body: koerper,
202
+ });
203
+ // RFC 7009 verlangt 200 auch für ein Token, das es nie gab — damit
204
+ // niemand über den Statuscode herausfindet, welche Token gültig sind.
205
+ if (!antwort.ok)
206
+ throw await ausAntwort(antwort, "Das Token zu widerrufen");
207
+ }
208
+ async #token(felder) {
209
+ const metadaten = await this.metadaten();
210
+ const koerper = new URLSearchParams({ ...felder, client_id: this.clientId });
211
+ if (this.#clientSecret)
212
+ koerper.set("client_secret", this.#clientSecret);
213
+ const antwort = await this.#fetch(metadaten.token_endpoint, {
214
+ method: "POST",
215
+ headers: {
216
+ "content-type": "application/x-www-form-urlencoded",
217
+ accept: "application/json",
218
+ },
219
+ body: koerper,
220
+ });
221
+ if (!antwort.ok) {
222
+ const istErneuerung = felder.grant_type === "refresh_token";
223
+ throw await ausAntwort(antwort, istErneuerung ? "Das Erneuern der Anmeldung" : "Der Tausch des Anmeldecodes", istErneuerung ? "refresh" : "code");
224
+ }
225
+ return (await antwort.json());
226
+ }
227
+ async #alsSitzung(antwort, nonce) {
228
+ const metadaten = await this.metadaten();
229
+ const claims = antwort.id_token
230
+ ? await pruefeIdToken(antwort.id_token, {
231
+ issuer: metadaten.issuer,
232
+ audience: this.clientId,
233
+ nonce,
234
+ schluessel: schluesselFuer(metadaten.jwks_uri),
235
+ })
236
+ : undefined;
237
+ return {
238
+ accessToken: antwort.access_token,
239
+ refreshToken: antwort.refresh_token,
240
+ // Aus der Dauer sofort einen Zeitpunkt: eine Dauer ist ab dem Moment
241
+ // falsch, in dem man sie irgendwo ablegt.
242
+ accessTokenExpiresAt: new Date(Date.now() + antwort.expires_in * 1000),
243
+ idToken: antwort.id_token,
244
+ scope: antwort.scope ? antwort.scope.split(" ").filter(Boolean) : [],
245
+ claims,
246
+ };
247
+ }
248
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Wo bei Aplons was liegt — gefragt, nicht geraten.
3
+ *
4
+ * Die Endpunkte ließen sich auch fest eintragen; genau das ist in diesem
5
+ * Projekt schon schiefgegangen, als eine handgeschriebene Liste von Bereichen
6
+ * neben der echten herlief und Anwendungen die falschen anforderten. Wer
7
+ * fragt, bekommt immer die Antwort von heute.
8
+ */
9
+ export type Metadaten = {
10
+ issuer: string;
11
+ authorization_endpoint: string;
12
+ token_endpoint: string;
13
+ userinfo_endpoint: string;
14
+ jwks_uri: string;
15
+ revocation_endpoint: string;
16
+ end_session_endpoint: string;
17
+ scopes_supported?: string[];
18
+ code_challenge_methods_supported?: string[];
19
+ };
20
+ export declare function holeMetadaten(issuer: string, fetchImpl: typeof globalThis.fetch): Promise<Metadaten>;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Wo bei Aplons was liegt — gefragt, nicht geraten.
3
+ *
4
+ * Die Endpunkte ließen sich auch fest eintragen; genau das ist in diesem
5
+ * Projekt schon schiefgegangen, als eine handgeschriebene Liste von Bereichen
6
+ * neben der echten herlief und Anwendungen die falschen anforderten. Wer
7
+ * fragt, bekommt immer die Antwort von heute.
8
+ */
9
+ import { AplonsError, ausAntwort } from "./errors.js";
10
+ const PFLICHT = [
11
+ "issuer",
12
+ "authorization_endpoint",
13
+ "token_endpoint",
14
+ "userinfo_endpoint",
15
+ "jwks_uri",
16
+ ];
17
+ export async function holeMetadaten(issuer, fetchImpl) {
18
+ const url = `${issuer}/.well-known/openid-configuration`;
19
+ let antwort;
20
+ try {
21
+ antwort = await fetchImpl(url, { headers: { accept: "application/json" } });
22
+ }
23
+ catch (cause) {
24
+ throw new AplonsError({
25
+ code: "unreachable",
26
+ cause,
27
+ message: `${issuer} ist nicht erreichbar. Stimmt die Adresse, und kommt dieser ` +
28
+ "Server überhaupt ins Netz?",
29
+ });
30
+ }
31
+ if (!antwort.ok)
32
+ throw await ausAntwort(antwort, `${url} zu laden`);
33
+ const metadaten = (await antwort.json());
34
+ for (const feld of PFLICHT) {
35
+ if (!metadaten[feld]) {
36
+ throw new AplonsError({
37
+ code: "invalid_metadata",
38
+ message: `${url} enthält kein ${feld}. Ist das wirklich ein Aplons-Server?`,
39
+ });
40
+ }
41
+ }
42
+ /*
43
+ Der Aussteller muss zu der Adresse passen, unter der wir gefragt haben.
44
+
45
+ Ohne diese Prüfung könnte ein untergeschobener Discovery-URL auf fremde
46
+ Endpunkte zeigen, und die Anwendung schickte ihre Anmeldungen dorthin.
47
+ RFC 8414 verlangt die Prüfung aus genau diesem Grund.
48
+ */
49
+ if (metadaten.issuer.replace(/\/+$/, "") !== issuer) {
50
+ throw new AplonsError({
51
+ code: "issuer_mismatch",
52
+ message: `Unter ${issuer} meldet sich ein Server, der sich „${metadaten.issuer}" ` +
53
+ "nennt. Das darf nicht sein — die Anmeldung wird nicht fortgesetzt.",
54
+ });
55
+ }
56
+ return metadaten;
57
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Ein Fehler, der sagt, was zu tun ist.
3
+ *
4
+ * OAuth antwortet mit Kennungen wie `invalid_grant` — richtig für eine
5
+ * Maschine, nutzlos für den, der um drei Uhr nachts in ein Log sieht. Jeder
6
+ * Fehler hier trägt deshalb beides: die Kennung für den Code und einen Satz
7
+ * für den Menschen.
8
+ */
9
+ export declare class AplonsError extends Error {
10
+ /** Die OAuth-Kennung, etwa `invalid_grant`. */
11
+ readonly code: string;
12
+ /** Der HTTP-Status, wenn der Fehler von einer Antwort kam. */
13
+ readonly status?: number;
14
+ /** Was der Server dazu geschrieben hat. */
15
+ readonly description?: string;
16
+ constructor(options: {
17
+ code: string;
18
+ message: string;
19
+ status?: number;
20
+ description?: string;
21
+ cause?: unknown;
22
+ });
23
+ }
24
+ /** Welcher Ablauf gerade fehlgeschlagen ist. */
25
+ export type Ablauf = "code" | "refresh" | "allgemein";
26
+ /** Aus einer fehlgeschlagenen Antwort einen brauchbaren Fehler machen. */
27
+ export declare function ausAntwort(antwort: Response, wobei: string, ablauf?: Ablauf): Promise<AplonsError>;