@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 +164 -0
- package/dist/client.d.ts +93 -0
- package/dist/client.js +248 -0
- package/dist/discovery.d.ts +20 -0
- package/dist/discovery.js +57 -0
- package/dist/errors.d.ts +27 -0
- package/dist/errors.js +80 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +16 -0
- package/dist/next.d.ts +72 -0
- package/dist/next.js +263 -0
- package/dist/pkce.d.ts +48 -0
- package/dist/pkce.js +75 -0
- package/dist/types.d.ts +91 -0
- package/dist/types.js +1 -0
- package/dist/verify.d.ts +26 -0
- package/dist/verify.js +94 -0
- package/package.json +43 -0
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
|
+
```
|
package/dist/client.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -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>;
|