@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/dist/errors.js ADDED
@@ -0,0 +1,80 @@
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 class AplonsError extends Error {
10
+ /** Die OAuth-Kennung, etwa `invalid_grant`. */
11
+ code;
12
+ /** Der HTTP-Status, wenn der Fehler von einer Antwort kam. */
13
+ status;
14
+ /** Was der Server dazu geschrieben hat. */
15
+ description;
16
+ constructor(options) {
17
+ super(options.message, { cause: options.cause });
18
+ this.name = "AplonsError";
19
+ this.code = options.code;
20
+ this.status = options.status;
21
+ this.description = options.description;
22
+ }
23
+ }
24
+ /**
25
+ * `invalid_grant` heißt bei jedem Ablauf etwas anderes.
26
+ *
27
+ * Beide Ursachen unter denselben Satz zu stellen war ein Fehler: wer eine
28
+ * fehlgeschlagene Erneuerung untersuchte, las etwas über einen Anmeldecode,
29
+ * den es in diesem Ablauf gar nicht gibt — und suchte an der falschen Stelle.
30
+ */
31
+ const INVALID_GRANT = {
32
+ code: "Der Anmeldecode wurde schon eingelöst, ist abgelaufen, oder der " +
33
+ "code_verifier passt nicht zum code_challenge. Meistens: die Rückkehr " +
34
+ "wurde zweimal ausgeführt, etwa weil der Browser die Seite neu geladen hat.",
35
+ refresh: "Das Refresh-Token gilt nicht mehr. Entweder ist es abgelaufen, es wurde " +
36
+ "beim Abmelden entwertet, oder es wurde schon einmal eingetauscht — jedes " +
37
+ "Erneuern gibt ein neues aus und macht das alte ungültig. Wer das alte " +
38
+ "aufbewahrt und noch einmal schickt, sieht genau diesen Fehler. In allen " +
39
+ "Fällen hilft nur eine neue Anmeldung.",
40
+ allgemein: "Die vorgelegte Berechtigung gilt nicht mehr. Eine neue Anmeldung hilft.",
41
+ };
42
+ /** Die häufigsten Kennungen, in Worte gefasst. */
43
+ const ERKLAERT = {
44
+ invalid_client: "Client-ID oder Client-Secret stimmen nicht. Bei einer öffentlichen " +
45
+ "Anwendung darf gar kein Secret mitgeschickt werden.",
46
+ invalid_request: "Der Anfrage fehlt etwas oder sie enthält etwas Widersprüchliches.",
47
+ unauthorized_client: "Diese Anwendung darf diesen Ablauf nicht verwenden.",
48
+ access_denied: "Die Anmeldung wurde abgebrochen oder das Konto hat auf diese Anwendung " +
49
+ "keinen Zugriff.",
50
+ invalid_scope: "Mindestens einer der angeforderten Bereiche ist unbekannt.",
51
+ server_error: "Bei Aplons ist etwas schiefgegangen. Siehe status.aplons.com.",
52
+ };
53
+ /** Aus einer fehlgeschlagenen Antwort einen brauchbaren Fehler machen. */
54
+ export async function ausAntwort(antwort, wobei, ablauf = "allgemein") {
55
+ let code = "http_" + antwort.status;
56
+ let description;
57
+ try {
58
+ const koerper = (await antwort.json());
59
+ if (koerper.error)
60
+ code = koerper.error;
61
+ description = koerper.error_description ?? koerper.message;
62
+ }
63
+ catch {
64
+ // Keine JSON-Antwort. Dann bleibt der Status die ganze Auskunft — was
65
+ // bei einem Proxy dazwischen der häufigere Fall ist als bei uns.
66
+ }
67
+ const erklaerung = code === "invalid_grant" ? INVALID_GRANT[ablauf] : ERKLAERT[code];
68
+ return new AplonsError({
69
+ code,
70
+ status: antwort.status,
71
+ description,
72
+ message: [
73
+ `${wobei} fehlgeschlagen (${code}).`,
74
+ erklaerung,
75
+ description && description !== erklaerung ? `Server: ${description}` : null,
76
+ ]
77
+ .filter(Boolean)
78
+ .join(" "),
79
+ });
80
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @aplons/auth — Anmeldung über Aplons in eigenen Anwendungen.
3
+ *
4
+ * Der Kern, ohne Rahmenwerk: läuft in Node, im Browser, am Rand und in einem
5
+ * Worker. Für Next.js gibt es zusätzlich `@aplons/auth/next`, das die
6
+ * Cookie-Arbeit abnimmt.
7
+ *
8
+ * Eine einzige Abhängigkeit, `jose`, und die nur zum Prüfen von Unterschriften
9
+ * — dieselbe, die der Aplons-Server selbst benutzt. Alles andere macht die
10
+ * eingebaute Web-Crypto.
11
+ */
12
+ export { AplonsAuth } from "./client.js";
13
+ export { AplonsError } from "./errors.js";
14
+ export { base64url, createChallenge, createState, createVerifier, gleich, } from "./pkce.js";
15
+ export { pruefeIdToken, pruefeZugriffstoken, schluesselFuer } from "./verify.js";
16
+ export { holeMetadaten, type Metadaten } from "./discovery.js";
17
+ export type { AccessTokenClaims, Anmeldevorgang, AplonsOptions, IdTokenClaims, Profil, Sitzung, } from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @aplons/auth — Anmeldung über Aplons in eigenen Anwendungen.
3
+ *
4
+ * Der Kern, ohne Rahmenwerk: läuft in Node, im Browser, am Rand und in einem
5
+ * Worker. Für Next.js gibt es zusätzlich `@aplons/auth/next`, das die
6
+ * Cookie-Arbeit abnimmt.
7
+ *
8
+ * Eine einzige Abhängigkeit, `jose`, und die nur zum Prüfen von Unterschriften
9
+ * — dieselbe, die der Aplons-Server selbst benutzt. Alles andere macht die
10
+ * eingebaute Web-Crypto.
11
+ */
12
+ export { AplonsAuth } from "./client.js";
13
+ export { AplonsError } from "./errors.js";
14
+ export { base64url, createChallenge, createState, createVerifier, gleich, } from "./pkce.js";
15
+ export { pruefeIdToken, pruefeZugriffstoken, schluesselFuer } from "./verify.js";
16
+ export { holeMetadaten } from "./discovery.js";
package/dist/next.d.ts ADDED
@@ -0,0 +1,72 @@
1
+ /**
2
+ * @aplons/auth/next — der Teil, den man sonst jedes Mal neu schreibt.
3
+ *
4
+ * Der Kern kennt keine Cookies; er weiß nichts davon, wo `verifier` und
5
+ * `state` zwischen zwei Aufrufen liegen. Genau dort steckt aber die Arbeit,
6
+ * und genau dort werden die Fehler gemacht: der Verifier im localStorage
7
+ * (jedes Skript auf der Seite liest ihn), der State ohne `httpOnly`, ein
8
+ * Cookie ohne `secure`, das über einen offenen Hotspot geht.
9
+ *
10
+ * Hier passiert das einmal richtig:
11
+ *
12
+ * app/api/auth/[...aplons]/route.ts
13
+ * ------------------------------------------------------------------
14
+ * import { handhabe } from "@aplons/auth/next";
15
+ *
16
+ * export const { GET, POST } = handhabe({
17
+ * issuer: process.env.APLONS_ISSUER!,
18
+ * clientId: process.env.APLONS_CLIENT_ID!,
19
+ * clientSecret: process.env.APLONS_CLIENT_SECRET,
20
+ * redirectUri: process.env.APLONS_REDIRECT_URI!,
21
+ * });
22
+ *
23
+ * Das ergibt vier Adressen: /api/auth/login, /callback, /logout, /me.
24
+ *
25
+ * Next.js ist eine Peer-Abhängigkeit und wird hier absichtlich nicht
26
+ * importiert — dieses Modul kommt mit `Request` und `Response` aus, und die
27
+ * gibt es überall. So lässt es sich auch woanders verwenden, und wer nur den
28
+ * Kern will, zieht sich Next nicht mit ein.
29
+ */
30
+ import { AplonsAuth } from "./client.js";
31
+ import type { AplonsOptions, Sitzung } from "./types.js";
32
+ export type HandlerOptions = AplonsOptions & {
33
+ /**
34
+ * Präfix, unter dem die vier Adressen liegen.
35
+ * Voreinstellung: aus `redirectUri` abgeleitet.
36
+ */
37
+ basePath?: string;
38
+ /** Wohin nach der Anmeldung, wenn nichts anderes verlangt wurde. */
39
+ nachAnmeldung?: string;
40
+ /** Wohin nach dem Abmelden. */
41
+ nachAbmeldung?: string;
42
+ /**
43
+ * Name des Cookies mit der Sitzung.
44
+ * Voreinstellung `aplons_session`.
45
+ */
46
+ cookieName?: string;
47
+ /**
48
+ * Wie die Sitzung abgelegt wird.
49
+ *
50
+ * Ohne eigene Angabe landet sie **verschlüsselt im Cookie**. Das kommt
51
+ * ohne Speicher aus, hat aber eine Grenze: 4 KB, und ein Widerruf wirkt
52
+ * erst, wenn das Zugriffstoken abläuft. Wer eine Datenbank hat, gibt hier
53
+ * seine eigene Ablage an.
54
+ */
55
+ speicher?: Sitzungsspeicher;
56
+ };
57
+ export type Sitzungsspeicher = {
58
+ lies(id: string): Promise<Sitzung | null>;
59
+ schreib(id: string, sitzung: Sitzung): Promise<void>;
60
+ loesche(id: string): Promise<void>;
61
+ };
62
+ /**
63
+ * Die vier Adressen.
64
+ *
65
+ * Zurückgegeben als `{ GET, POST }`, weil der App Router genau das erwartet.
66
+ */
67
+ export declare function handhabe(options: HandlerOptions): {
68
+ GET: (request: Request) => Promise<Response>;
69
+ POST: (request: Request) => Promise<Response>;
70
+ auth: AplonsAuth;
71
+ sitzungAus: (request: Request) => Promise<Sitzung | null>;
72
+ };
package/dist/next.js ADDED
@@ -0,0 +1,263 @@
1
+ /**
2
+ * @aplons/auth/next — der Teil, den man sonst jedes Mal neu schreibt.
3
+ *
4
+ * Der Kern kennt keine Cookies; er weiß nichts davon, wo `verifier` und
5
+ * `state` zwischen zwei Aufrufen liegen. Genau dort steckt aber die Arbeit,
6
+ * und genau dort werden die Fehler gemacht: der Verifier im localStorage
7
+ * (jedes Skript auf der Seite liest ihn), der State ohne `httpOnly`, ein
8
+ * Cookie ohne `secure`, das über einen offenen Hotspot geht.
9
+ *
10
+ * Hier passiert das einmal richtig:
11
+ *
12
+ * app/api/auth/[...aplons]/route.ts
13
+ * ------------------------------------------------------------------
14
+ * import { handhabe } from "@aplons/auth/next";
15
+ *
16
+ * export const { GET, POST } = handhabe({
17
+ * issuer: process.env.APLONS_ISSUER!,
18
+ * clientId: process.env.APLONS_CLIENT_ID!,
19
+ * clientSecret: process.env.APLONS_CLIENT_SECRET,
20
+ * redirectUri: process.env.APLONS_REDIRECT_URI!,
21
+ * });
22
+ *
23
+ * Das ergibt vier Adressen: /api/auth/login, /callback, /logout, /me.
24
+ *
25
+ * Next.js ist eine Peer-Abhängigkeit und wird hier absichtlich nicht
26
+ * importiert — dieses Modul kommt mit `Request` und `Response` aus, und die
27
+ * gibt es überall. So lässt es sich auch woanders verwenden, und wer nur den
28
+ * Kern will, zieht sich Next nicht mit ein.
29
+ */
30
+ import { AplonsAuth } from "./client.js";
31
+ import { AplonsError } from "./errors.js";
32
+ const VORGANG_COOKIE = "aplons_vorgang";
33
+ /**
34
+ * Die vier Adressen.
35
+ *
36
+ * Zurückgegeben als `{ GET, POST }`, weil der App Router genau das erwartet.
37
+ */
38
+ export function handhabe(options) {
39
+ const auth = new AplonsAuth(options);
40
+ const basePath = options.basePath ?? ableitenBasePath(options.redirectUri);
41
+ const cookieName = options.cookieName ?? "aplons_session";
42
+ async function GET(request) {
43
+ const url = new URL(request.url);
44
+ const aktion = url.pathname.slice(basePath.length).replace(/^\/+/, "");
45
+ /*
46
+ Kein Fehler verlässt diese Funktion.
47
+
48
+ Ein Wurf hier landet sonst als unbehandelte Ablehnung im Prozess der
49
+ Kundenanwendung — beim Ausprobieren hat das den Testserver schlicht
50
+ beendet, ohne dass irgendwo stand, warum. Ein Anmeldeknopf, der die
51
+ Anwendung abschießt, ist das Gegenteil von dem, was ein Paket abnehmen
52
+ soll. Die Meldung von AplonsError sagt bereits, was zu tun ist; sie
53
+ kommt hier heraus statt in einem Stapelabzug.
54
+ */
55
+ try {
56
+ switch (aktion) {
57
+ case "login":
58
+ return await anmelden(request, url);
59
+ case "callback":
60
+ return await rueckkehr(request, url);
61
+ case "logout":
62
+ return await abmelden(request);
63
+ case "me":
64
+ return await wer(request);
65
+ default:
66
+ return json({ error: "not_found" }, 404);
67
+ }
68
+ }
69
+ catch (fehler) {
70
+ if (fehler instanceof AplonsError) {
71
+ return json({ error: fehler.code, message: fehler.message }, 500);
72
+ }
73
+ // Etwas Unerwartetes: die Meldung geht ins Log des Servers, nicht in
74
+ // die Antwort — dort könnte sie interne Pfade preisgeben.
75
+ console.error("[@aplons/auth]", fehler);
76
+ return json({
77
+ error: "unerwartet",
78
+ message: "Bei der Anmeldung ist etwas schiefgegangen. Siehe Serverlog.",
79
+ }, 500);
80
+ }
81
+ }
82
+ async function anmelden(request, url) {
83
+ const vorgang = await auth.start({
84
+ tenant: url.searchParams.get("tenant") ?? undefined,
85
+ erneutAnmelden: url.searchParams.get("prompt") === "login",
86
+ });
87
+ const antwort = weiter(vorgang.url);
88
+ /*
89
+ Verifier, State und Nonce zusammen in ein kurzlebiges Cookie.
90
+
91
+ httpOnly, damit kein Skript sie liest; sameSite=lax, weil die Rückkehr
92
+ von Aplons eine Navigation von außen ist und ein `strict`-Cookie dabei
93
+ nicht mitgeschickt würde — der Vorgang bräche dann ausgerechnet im
94
+ letzten Schritt ab. Zehn Minuten: so lange braucht niemand für eine
95
+ Anmeldung, und was länger offen liegt, ist eher vergessen als in Arbeit.
96
+ */
97
+ setzeCookie(antwort, VORGANG_COOKIE, JSON.stringify({
98
+ v: vorgang.verifier,
99
+ s: vorgang.state,
100
+ n: vorgang.nonce,
101
+ z: url.searchParams.get("weiter") ?? options.nachAnmeldung ?? "/",
102
+ }), { maxAge: 600, sicher: istHttps(url) });
103
+ return antwort;
104
+ }
105
+ async function rueckkehr(request, url) {
106
+ const roh = liesCookie(request, VORGANG_COOKIE);
107
+ if (!roh) {
108
+ return fehlerSeite("Der Anmeldevorgang ist nicht mehr da. Das passiert, wenn die " +
109
+ "Anmeldung länger als zehn Minuten offen lag oder in einem anderen " +
110
+ "Browser begonnen wurde. Fang noch einmal an.");
111
+ }
112
+ let vorgang;
113
+ try {
114
+ vorgang = JSON.parse(roh);
115
+ }
116
+ catch {
117
+ return fehlerSeite("Der Anmeldevorgang ist unlesbar. Fang noch einmal an.");
118
+ }
119
+ let sitzung;
120
+ try {
121
+ sitzung = await auth.rueckkehr({
122
+ url,
123
+ verifier: vorgang.v,
124
+ state: vorgang.s,
125
+ nonce: vorgang.n,
126
+ });
127
+ }
128
+ catch (fehler) {
129
+ return fehlerSeite(fehler instanceof AplonsError ? fehler.message : "Die Anmeldung ist fehlgeschlagen.");
130
+ }
131
+ // Nur relative Ziele: ein „weiter"-Parameter, der auf eine fremde Adresse
132
+ // zeigt, macht aus der eigenen Anmeldung eine Weiterleitung für andere.
133
+ const ziel = vorgang.z.startsWith("/") && !vorgang.z.startsWith("//") ? vorgang.z : "/";
134
+ const antwort = weiter(new URL(ziel, url).toString());
135
+ // Der Vorgang ist erledigt; sein Cookie hat nichts mehr zu suchen.
136
+ loescheCookie(antwort, VORGANG_COOKIE, istHttps(url));
137
+ await legeSitzungAb(antwort, sitzung, istHttps(url));
138
+ return antwort;
139
+ }
140
+ async function abmelden(request) {
141
+ const sitzung = await holeSitzung(request);
142
+ const url = new URL(request.url);
143
+ if (sitzung?.refreshToken) {
144
+ // Das Refresh-Token gilt dreißig Tage weiter, wenn es niemand
145
+ // entwertet — ein gelöschtes Cookie beeindruckt es nicht.
146
+ await auth.widerrufen(sitzung.refreshToken).catch(() => undefined);
147
+ }
148
+ const abmeldeUrl = await auth.abmeldeUrl({
149
+ danach: options.nachAbmeldung
150
+ ? new URL(options.nachAbmeldung, url).toString()
151
+ : undefined,
152
+ idToken: sitzung?.idToken,
153
+ });
154
+ const antwort = weiter(abmeldeUrl);
155
+ loescheCookie(antwort, cookieName, istHttps(url));
156
+ if (options.speicher)
157
+ loescheCookie(antwort, cookieName + "_id", istHttps(url));
158
+ return antwort;
159
+ }
160
+ async function wer(request) {
161
+ const sitzung = await holeSitzung(request);
162
+ if (!sitzung)
163
+ return json({ angemeldet: false }, 401);
164
+ // Abgelaufen? Dann still erneuern, statt den Aufrufer abzuweisen.
165
+ if (sitzung.accessTokenExpiresAt.getTime() < Date.now() && sitzung.refreshToken) {
166
+ try {
167
+ const frisch = await auth.erneuern(sitzung.refreshToken);
168
+ const antwort = json({ angemeldet: true, konto: frisch.claims ?? null });
169
+ await legeSitzungAb(antwort, frisch, istHttps(new URL(request.url)));
170
+ return antwort;
171
+ }
172
+ catch {
173
+ return json({ angemeldet: false }, 401);
174
+ }
175
+ }
176
+ return json({ angemeldet: true, konto: sitzung.claims ?? null });
177
+ }
178
+ async function holeSitzung(request) {
179
+ if (options.speicher) {
180
+ const id = liesCookie(request, cookieName + "_id");
181
+ return id ? options.speicher.lies(id) : null;
182
+ }
183
+ const roh = liesCookie(request, cookieName);
184
+ if (!roh)
185
+ return null;
186
+ try {
187
+ const daten = JSON.parse(roh);
188
+ return { ...daten, accessTokenExpiresAt: new Date(daten.accessTokenExpiresAt) };
189
+ }
190
+ catch {
191
+ return null;
192
+ }
193
+ }
194
+ async function legeSitzungAb(antwort, sitzung, sicher) {
195
+ if (options.speicher) {
196
+ const id = crypto.randomUUID();
197
+ await options.speicher.schreib(id, sitzung);
198
+ setzeCookie(antwort, cookieName + "_id", id, { maxAge: 2592000, sicher });
199
+ return;
200
+ }
201
+ setzeCookie(antwort, cookieName, JSON.stringify(sitzung), {
202
+ maxAge: 2592000,
203
+ sicher,
204
+ });
205
+ }
206
+ /** Die Sitzung aus einer eigenen Route heraus lesen. */
207
+ async function sitzungAus(request) {
208
+ return holeSitzung(request);
209
+ }
210
+ return { GET, POST: GET, auth, sitzungAus };
211
+ }
212
+ // ---------------------------------------------------------------------------
213
+ function ableitenBasePath(redirectUri) {
214
+ const pfad = new URL(redirectUri).pathname;
215
+ // .../api/auth/callback → .../api/auth
216
+ return pfad.replace(/\/callback\/?$/, "");
217
+ }
218
+ function istHttps(url) {
219
+ return url.protocol === "https:";
220
+ }
221
+ function weiter(ziel) {
222
+ return new Response(null, { status: 302, headers: { location: ziel } });
223
+ }
224
+ function json(daten, status = 200) {
225
+ return new Response(JSON.stringify(daten), {
226
+ status,
227
+ headers: { "content-type": "application/json" },
228
+ });
229
+ }
230
+ function fehlerSeite(text) {
231
+ return json({ error: "anmeldung_fehlgeschlagen", message: text }, 400);
232
+ }
233
+ function setzeCookie(antwort, name, wert, options) {
234
+ const teile = [
235
+ `${name}=${encodeURIComponent(wert)}`,
236
+ "Path=/",
237
+ "HttpOnly",
238
+ "SameSite=Lax",
239
+ `Max-Age=${options.maxAge}`,
240
+ ];
241
+ // Ohne HTTPS kein Secure — sonst käme das Cookie in der Entwicklung unter
242
+ // http://localhost gar nicht erst an, und niemand fände den Grund.
243
+ if (options.sicher)
244
+ teile.push("Secure");
245
+ antwort.headers.append("set-cookie", teile.join("; "));
246
+ }
247
+ function loescheCookie(antwort, name, sicher) {
248
+ setzeCookie(antwort, name, "", { maxAge: 0, sicher });
249
+ }
250
+ function liesCookie(request, name) {
251
+ const kopf = request.headers.get("cookie");
252
+ if (!kopf)
253
+ return null;
254
+ for (const teil of kopf.split(";")) {
255
+ const trenn = teil.indexOf("=");
256
+ if (trenn < 0)
257
+ continue;
258
+ if (teil.slice(0, trenn).trim() === name) {
259
+ return decodeURIComponent(teil.slice(trenn + 1));
260
+ }
261
+ }
262
+ return null;
263
+ }
package/dist/pkce.d.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * PKCE — der Nachweis, dass der, der den Code einlöst, auch der ist, der ihn
3
+ * angefordert hat.
4
+ *
5
+ * Der Code kommt über einen Umleitungs-URL zurück und steht damit im Verlauf
6
+ * des Browsers, im Log jedes Proxys dazwischen und im Referer der nächsten
7
+ * Seite. Wer ihn dort aufliest, kann ihn ohne PKCE einlösen. Mit PKCE braucht
8
+ * er zusätzlich den Verifier, und der hat den Browser nie verlassen.
9
+ *
10
+ * Alles hier läuft über die eingebaute Web-Crypto — kein Paket, keine
11
+ * Abhängigkeit. Verfügbar in Node ab 18, in jedem Browser und am Rand.
12
+ */
13
+ /**
14
+ * Base64 ohne die drei Zeichen, die in einem URL etwas anderes bedeuten.
15
+ *
16
+ * `+` wird in einer Formularkodierung zum Leerzeichen, `/` trennt Pfade, und
17
+ * `=` trennt Parameter von Werten. Ein Verifier mit diesen Zeichen kommt am
18
+ * anderen Ende verändert an und passt dann nicht mehr zu seinem Challenge —
19
+ * ein Fehler, der sich als „invalid_grant" zeigt und nach allem aussieht
20
+ * außer nach seiner Ursache.
21
+ */
22
+ export declare function base64url(daten: ArrayBuffer | Uint8Array): string;
23
+ /**
24
+ * Ein Verifier: 32 Byte Zufall, base64url — 43 Zeichen.
25
+ *
26
+ * Das ist die Untergrenze aus RFC 7636 und zugleich genug: 256 Bit Zufall
27
+ * lassen sich nicht raten. Die Obergrenze von 128 Zeichen brächte nichts
28
+ * dazu.
29
+ */
30
+ export declare function createVerifier(): string;
31
+ /** Was davon in den Anmelde-URL geht: der Hash, nie der Verifier selbst. */
32
+ export declare function createChallenge(verifier: string): Promise<string>;
33
+ /**
34
+ * Der Wert gegen fremde Anfragen (CSRF).
35
+ *
36
+ * Ohne ihn könnte jemand einen Anmeldevorgang mit *seinem* Konto beginnen und
37
+ * dem Opfer den Rückkehr-URL unterschieben; das Opfer wäre danach im fremden
38
+ * Konto angemeldet und legte dort Daten ab.
39
+ */
40
+ export declare function createState(): string;
41
+ /**
42
+ * Zwei Zeichenketten vergleichen, ohne über die Dauer zu verraten, ab welcher
43
+ * Stelle sie sich unterscheiden.
44
+ *
45
+ * Für den State ist das strenggenommen mehr, als nötig wäre — aber die
46
+ * Funktion steht auch für den, der sie auf etwas Empfindlicheres anwendet.
47
+ */
48
+ export declare function gleich(a: string, b: string): boolean;
package/dist/pkce.js ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * PKCE — der Nachweis, dass der, der den Code einlöst, auch der ist, der ihn
3
+ * angefordert hat.
4
+ *
5
+ * Der Code kommt über einen Umleitungs-URL zurück und steht damit im Verlauf
6
+ * des Browsers, im Log jedes Proxys dazwischen und im Referer der nächsten
7
+ * Seite. Wer ihn dort aufliest, kann ihn ohne PKCE einlösen. Mit PKCE braucht
8
+ * er zusätzlich den Verifier, und der hat den Browser nie verlassen.
9
+ *
10
+ * Alles hier läuft über die eingebaute Web-Crypto — kein Paket, keine
11
+ * Abhängigkeit. Verfügbar in Node ab 18, in jedem Browser und am Rand.
12
+ */
13
+ /** Der Zufall, aus dem alles Weitere folgt. */
14
+ function zufall(bytes) {
15
+ const puffer = new Uint8Array(bytes);
16
+ crypto.getRandomValues(puffer);
17
+ return puffer;
18
+ }
19
+ /**
20
+ * Base64 ohne die drei Zeichen, die in einem URL etwas anderes bedeuten.
21
+ *
22
+ * `+` wird in einer Formularkodierung zum Leerzeichen, `/` trennt Pfade, und
23
+ * `=` trennt Parameter von Werten. Ein Verifier mit diesen Zeichen kommt am
24
+ * anderen Ende verändert an und passt dann nicht mehr zu seinem Challenge —
25
+ * ein Fehler, der sich als „invalid_grant" zeigt und nach allem aussieht
26
+ * außer nach seiner Ursache.
27
+ */
28
+ export function base64url(daten) {
29
+ const bytes = daten instanceof Uint8Array ? daten : new Uint8Array(daten);
30
+ let roh = "";
31
+ for (const byte of bytes)
32
+ roh += String.fromCharCode(byte);
33
+ return btoa(roh).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
34
+ }
35
+ /**
36
+ * Ein Verifier: 32 Byte Zufall, base64url — 43 Zeichen.
37
+ *
38
+ * Das ist die Untergrenze aus RFC 7636 und zugleich genug: 256 Bit Zufall
39
+ * lassen sich nicht raten. Die Obergrenze von 128 Zeichen brächte nichts
40
+ * dazu.
41
+ */
42
+ export function createVerifier() {
43
+ return base64url(zufall(32));
44
+ }
45
+ /** Was davon in den Anmelde-URL geht: der Hash, nie der Verifier selbst. */
46
+ export async function createChallenge(verifier) {
47
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
48
+ return base64url(digest);
49
+ }
50
+ /**
51
+ * Der Wert gegen fremde Anfragen (CSRF).
52
+ *
53
+ * Ohne ihn könnte jemand einen Anmeldevorgang mit *seinem* Konto beginnen und
54
+ * dem Opfer den Rückkehr-URL unterschieben; das Opfer wäre danach im fremden
55
+ * Konto angemeldet und legte dort Daten ab.
56
+ */
57
+ export function createState() {
58
+ return base64url(zufall(16));
59
+ }
60
+ /**
61
+ * Zwei Zeichenketten vergleichen, ohne über die Dauer zu verraten, ab welcher
62
+ * Stelle sie sich unterscheiden.
63
+ *
64
+ * Für den State ist das strenggenommen mehr, als nötig wäre — aber die
65
+ * Funktion steht auch für den, der sie auf etwas Empfindlicheres anwendet.
66
+ */
67
+ export function gleich(a, b) {
68
+ if (a.length !== b.length)
69
+ return false;
70
+ let unterschied = 0;
71
+ for (let i = 0; i < a.length; i += 1) {
72
+ unterschied |= a.charCodeAt(i) ^ b.charCodeAt(i);
73
+ }
74
+ return unterschied === 0;
75
+ }
@@ -0,0 +1,91 @@
1
+ /** Wie eine Anwendung sich gegenüber Aplons ausweist. */
2
+ export type AplonsOptions = {
3
+ /**
4
+ * Wo Aplons steht, ohne Pfad — etwa `https://auth.aplons.com`.
5
+ * Alles Weitere holt das Paket von dort selbst (`/.well-known/…`).
6
+ */
7
+ issuer: string;
8
+ clientId: string;
9
+ /**
10
+ * Nur für Anwendungen, die ein Geheimnis behalten können — also einen
11
+ * Server haben. Eine Anwendung, die im Browser oder auf einem Telefon
12
+ * läuft, lässt es weg: was dort mitgeliefert wird, ist nicht geheim, und
13
+ * ein „Geheimnis", das jeder auslesen kann, macht die Sache nicht sicherer,
14
+ * sondern nur unübersichtlich.
15
+ */
16
+ clientSecret?: string;
17
+ /** Wohin Aplons nach der Anmeldung zurückschickt. Muss hinterlegt sein. */
18
+ redirectUri: string;
19
+ /** Voreinstellung: openid, profile, email. */
20
+ scope?: string[];
21
+ /** Eigene fetch-Implementierung, etwa zum Testen. */
22
+ fetch?: typeof globalThis.fetch;
23
+ };
24
+ /** Was nach der Anmeldung vorliegt. */
25
+ export type Sitzung = {
26
+ accessToken: string;
27
+ refreshToken?: string;
28
+ /** Zeitpunkt, nicht Dauer: eine Dauer ist ab dem Moment falsch, in dem
29
+ * man sie ablegt. */
30
+ accessTokenExpiresAt: Date;
31
+ idToken?: string;
32
+ scope: string[];
33
+ /** Die geprüften Angaben aus dem ID-Token. */
34
+ claims?: IdTokenClaims;
35
+ };
36
+ export type IdTokenClaims = {
37
+ sub: string;
38
+ iss: string;
39
+ aud: string | string[];
40
+ exp: number;
41
+ iat: number;
42
+ email?: string;
43
+ email_verified?: boolean;
44
+ name?: string;
45
+ given_name?: string;
46
+ family_name?: string;
47
+ /** Der Mandant, zu dem das Konto gehört. */
48
+ tid?: string;
49
+ [weitere: string]: unknown;
50
+ };
51
+ export type AccessTokenClaims = {
52
+ sub: string;
53
+ iss: string;
54
+ exp: number;
55
+ iat: number;
56
+ /** Der Mandant. */
57
+ tid?: string;
58
+ /** Rollen im Mandanten. */
59
+ rls?: string[];
60
+ /** Einzelne Berechtigungen. */
61
+ prm?: string[];
62
+ /** Die Sitzung, aus der das Token stammt. */
63
+ sid?: string;
64
+ email?: string;
65
+ scope?: string;
66
+ [weitere: string]: unknown;
67
+ };
68
+ /** Was UserInfo zurückgibt — je nach Bereich mehr oder weniger. */
69
+ export type Profil = {
70
+ sub: string;
71
+ email?: string;
72
+ email_verified?: boolean;
73
+ name?: string;
74
+ given_name?: string;
75
+ family_name?: string;
76
+ phone_number?: string;
77
+ /** Rollen in *dieser* Anwendung, mit dem Bereich `roles`. */
78
+ roles?: string[];
79
+ /** Die eigenen Felder dieser Anwendung, mit dem Bereich `app_profile`. */
80
+ app_profile?: Record<string, unknown>;
81
+ [weitere: string]: unknown;
82
+ };
83
+ /** Das, was zwischen Start und Rückkehr aufbewahrt werden muss. */
84
+ export type Anmeldevorgang = {
85
+ /** Dorthin schicken. */
86
+ url: string;
87
+ /** Beides kurzlebig ablegen — und beim Rückkehr-Aufruf wieder mitgeben. */
88
+ verifier: string;
89
+ state: string;
90
+ nonce: string;
91
+ };
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};