@manta-eu/auth 0.1.8 → 0.1.10

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.
Files changed (58) hide show
  1. package/README.md +67 -4
  2. package/dist/auth-commands.js +1 -0
  3. package/dist/auth-commands.js.map +1 -0
  4. package/dist/browser-login.js +1 -0
  5. package/dist/browser-login.js.map +1 -0
  6. package/dist/browser.js +1 -0
  7. package/dist/browser.js.map +1 -0
  8. package/dist/credential-store.js +1 -0
  9. package/dist/credential-store.js.map +1 -0
  10. package/dist/default-deployment.d.ts +8 -0
  11. package/dist/default-deployment.d.ts.map +1 -1
  12. package/dist/default-deployment.js +7 -0
  13. package/dist/default-deployment.js.map +1 -0
  14. package/dist/device-login.js +1 -0
  15. package/dist/device-login.js.map +1 -0
  16. package/dist/http.js +1 -0
  17. package/dist/http.js.map +1 -0
  18. package/dist/index.d.ts +3 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +4 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/oidc.d.ts +15 -0
  23. package/dist/oidc.d.ts.map +1 -0
  24. package/dist/oidc.js +27 -0
  25. package/dist/oidc.js.map +1 -0
  26. package/dist/stored-login.js +1 -0
  27. package/dist/stored-login.js.map +1 -0
  28. package/dist/token.d.ts +3 -4
  29. package/dist/token.d.ts.map +1 -1
  30. package/dist/token.js +32 -10
  31. package/dist/token.js.map +1 -0
  32. package/dist/web-login.d.ts +40 -0
  33. package/dist/web-login.d.ts.map +1 -0
  34. package/dist/web-login.js +82 -0
  35. package/dist/web-login.js.map +1 -0
  36. package/dist/web-routes.d.ts +42 -0
  37. package/dist/web-routes.d.ts.map +1 -0
  38. package/dist/web-routes.js +248 -0
  39. package/dist/web-routes.js.map +1 -0
  40. package/dist/web.d.ts +8 -0
  41. package/dist/web.d.ts.map +1 -0
  42. package/dist/web.js +7 -0
  43. package/dist/web.js.map +1 -0
  44. package/package.json +14 -3
  45. package/src/auth-commands.ts +239 -0
  46. package/src/browser-login.ts +203 -0
  47. package/src/browser.ts +32 -0
  48. package/src/credential-store.ts +225 -0
  49. package/src/default-deployment.ts +15 -0
  50. package/src/device-login.ts +181 -0
  51. package/src/http.ts +47 -0
  52. package/src/index.ts +60 -0
  53. package/src/oidc.ts +40 -0
  54. package/src/stored-login.ts +63 -0
  55. package/src/token.ts +151 -0
  56. package/src/web-login.ts +106 -0
  57. package/src/web-routes.ts +321 -0
  58. package/src/web.ts +25 -0
@@ -0,0 +1,321 @@
1
+ import { EncryptJWT, jwtDecrypt, type JWTPayload } from "jose";
2
+ import type { FetchLike } from "./http.js";
3
+ import type { OidcClient } from "./oidc.js";
4
+ import { accessTokenExpiresAt } from "./token.js";
5
+ import {
6
+ endWebLogin,
7
+ finishWebLogin,
8
+ refreshWebLogin,
9
+ startWebLogin,
10
+ webLogoutUrl,
11
+ type WebTokens,
12
+ } from "./web-login.js";
13
+
14
+ export type WebLoginRoutesOptions = {
15
+ /** A confidential client: a custom app's client ID and secret, plus `baseUrl` and `realm` off hiremanta.com. */
16
+ client: OidcClient & { clientSecret: string };
17
+ /** Where the app runs, e.g. `https://app.example.com`; `paths.callback` under it must be a registered redirect URL. */
18
+ appUrl: string;
19
+ /** At least 32 characters, kept on the server. Rotating it signs everyone out. */
20
+ sessionSecret: string;
21
+ paths?: { login?: string; callback?: string; logout?: string };
22
+ /** Where people land after signing in (unless `/login?returnTo=` names a page) and after signing out. */
23
+ afterLogin?: string;
24
+ afterLogout?: string;
25
+ offlineAccess?: boolean;
26
+ idpHint?: string;
27
+ /** Off signs people out of this app only; on (the default) ends their Manta session too. */
28
+ signOutOfManta?: boolean;
29
+ cookieName?: string;
30
+ fetchImpl?: FetchLike;
31
+ };
32
+
33
+ export type WebSession = {
34
+ /** Absent when no one is signed in, or the session could not be renewed. */
35
+ accessToken: string | undefined;
36
+ idToken?: string;
37
+ /** Set-Cookie values to add to the response: a renewed session, or one cleared after a refused renewal. */
38
+ setCookies: string[];
39
+ };
40
+
41
+ export type WebLoginRoutes = {
42
+ /** Answers the login, callback and logout paths; `undefined` for any other request. */
43
+ handle(request: Request): Promise<Response | undefined>;
44
+ /** The signed-in person's access token, renewed when it is about to expire. */
45
+ session(request: Request): Promise<WebSession>;
46
+ };
47
+
48
+ type StoredSession = { accessToken: string; refreshToken?: string; idToken?: string };
49
+
50
+ type PendingLogin = { state: string; verifier: string; returnTo: string };
51
+
52
+ type SharedRefresh = { renewed: Promise<WebTokens | undefined>; settledAt?: number; nextRefreshToken?: string };
53
+
54
+ type SealingKey = Awaited<ReturnType<typeof crypto.subtle.deriveKey>>;
55
+
56
+ const loginTtlMs = 10 * 60 * 1000;
57
+ const offlineCookieMaxAgeSeconds = 30 * 24 * 60 * 60;
58
+ const chunkSize = 3800;
59
+ const maxChunks = 10;
60
+ const refreshReuseMs = 10_000;
61
+
62
+ // One key per purpose, so a login cookie can never be replayed as a session cookie or the other way round.
63
+ async function purposeKey(secret: string, purpose: string): Promise<SealingKey> {
64
+ const material = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), "HKDF", false, ["deriveKey"]);
65
+ return crypto.subtle.deriveKey(
66
+ {
67
+ name: "HKDF",
68
+ hash: "SHA-256",
69
+ salt: new TextEncoder().encode("manta-web-login"),
70
+ info: new TextEncoder().encode(purpose),
71
+ },
72
+ material,
73
+ { name: "AES-GCM", length: 256 },
74
+ false,
75
+ ["encrypt", "decrypt"],
76
+ );
77
+ }
78
+
79
+ async function seal(key: SealingKey, value: StoredSession | PendingLogin, ttlMs?: number): Promise<string> {
80
+ const jwt = new EncryptJWT({ ...value }).setProtectedHeader({ alg: "dir", enc: "A256GCM" });
81
+ if (ttlMs !== undefined) jwt.setExpirationTime(new Date(Date.now() + ttlMs));
82
+ return jwt.encrypt(key);
83
+ }
84
+
85
+ async function unseal(key: SealingKey, value: string | undefined): Promise<JWTPayload | undefined> {
86
+ if (!value) return undefined;
87
+ try {
88
+ return (await jwtDecrypt(value, key)).payload;
89
+ } catch {
90
+ return undefined;
91
+ }
92
+ }
93
+
94
+ function cookiesOf(request: Request): Map<string, string> {
95
+ const cookies = new Map<string, string>();
96
+ for (const part of (request.headers.get("cookie") ?? "").split(";")) {
97
+ const separator = part.indexOf("=");
98
+ if (separator > 0) cookies.set(part.slice(0, separator).trim(), part.slice(separator + 1).trim());
99
+ }
100
+ return cookies;
101
+ }
102
+
103
+ // Only a path on the app itself, so `/login?returnTo=` can never send people to another site.
104
+ // A browser drops tabs and newlines from a Location, so `/\t/evil.example` would land on `//evil.example`.
105
+ function safeReturnTo(raw: string | null, fallback: string, appUrl: URL): string {
106
+ if (!raw?.startsWith("/") || /[\p{Cc}\\]/u.test(raw)) return fallback;
107
+ const resolved = new URL(raw, appUrl);
108
+ // Resolving drops dot segments, so `/.//evil.example` comes out as the protocol-relative `//evil.example`.
109
+ if (resolved.origin !== appUrl.origin || resolved.pathname.startsWith("//")) return fallback;
110
+ return `${resolved.pathname}${resolved.search}${resolved.hash}`;
111
+ }
112
+
113
+ function isStoredSession(value: unknown): value is StoredSession {
114
+ return typeof value === "object" && value !== null && typeof (value as StoredSession).accessToken === "string";
115
+ }
116
+
117
+ function isPendingLogin(value: unknown): value is PendingLogin {
118
+ if (typeof value !== "object" || value === null) return false;
119
+ const login = value as PendingLogin;
120
+ return typeof login.state === "string" && typeof login.verifier === "string" && typeof login.returnTo === "string";
121
+ }
122
+
123
+ function redirect(location: string, setCookies: string[] = []): Response {
124
+ const headers = new Headers({ location, "cache-control": "no-store" });
125
+ for (const cookie of setCookies) headers.append("set-cookie", cookie);
126
+ return new Response(null, { status: 302, headers });
127
+ }
128
+
129
+ /** Sign-in, callback and sign-out routes for a custom app's server, on standard Request and Response. */
130
+ export function webLoginRoutes(options: WebLoginRoutesOptions): WebLoginRoutes {
131
+ if (options.sessionSecret.length < 32) throw new Error("sessionSecret must be at least 32 characters");
132
+
133
+ const appUrl = new URL(options.appUrl);
134
+ const secure = appUrl.protocol === "https:";
135
+ const paths = { login: "/login", callback: "/callback", logout: "/logout", ...options.paths };
136
+ const afterLogin = options.afterLogin ?? "/";
137
+ const afterLogout = options.afterLogout ?? "/";
138
+ const redirectUri = new URL(paths.callback, appUrl).toString();
139
+ const sessionName = options.cookieName ?? "manta_session";
140
+ const loginName = `${sessionName}_login`;
141
+ const keys = {
142
+ session: purposeKey(options.sessionSecret, "session"),
143
+ login: purposeKey(options.sessionSecret, "login"),
144
+ };
145
+ const { client, fetchImpl } = options;
146
+ // Keycloak takes a refresh token at most twice, so requests racing one expired session share one refresh,
147
+ // and so do requests that arrive with the old cookie shortly after it.
148
+ const refreshes = new Map<string, SharedRefresh>();
149
+
150
+ function refreshOnce(refreshToken: string): Promise<WebTokens | undefined> {
151
+ const now = Date.now();
152
+ for (const [token, { settledAt }] of refreshes) {
153
+ if (settledAt !== undefined && now - settledAt > refreshReuseMs) refreshes.delete(token);
154
+ }
155
+ const known = refreshes.get(refreshToken);
156
+ if (known) return known.renewed;
157
+ const entry: SharedRefresh = { renewed: refreshWebLogin(client, refreshToken, fetchImpl) };
158
+ refreshes.set(refreshToken, entry);
159
+ void entry.renewed.then(
160
+ (tokens) => {
161
+ entry.settledAt = Date.now();
162
+ entry.nextRefreshToken = tokens?.refreshToken;
163
+ },
164
+ () => refreshes.delete(refreshToken),
165
+ );
166
+ return entry.renewed;
167
+ }
168
+
169
+ // A signed-out session must not come back through a refresh shared with a request that still holds an older cookie.
170
+ function forgetRefreshes(endedRefreshToken: string) {
171
+ const ended = new Set([endedRefreshToken]);
172
+ for (const [token, { nextRefreshToken }] of [...refreshes].reverse()) {
173
+ if (ended.has(token) || (nextRefreshToken !== undefined && ended.has(nextRefreshToken))) {
174
+ ended.add(token);
175
+ refreshes.delete(token);
176
+ }
177
+ }
178
+ }
179
+
180
+ function cookie(name: string, value: string, extra: { path?: string; maxAge?: number } = {}): string {
181
+ const attributes = [`${name}=${value}`, `Path=${extra.path ?? "/"}`, "HttpOnly", "SameSite=Lax"];
182
+ if (secure) attributes.push("Secure");
183
+ if (extra.maxAge !== undefined) attributes.push(`Max-Age=${extra.maxAge}`);
184
+ return attributes.join("; ");
185
+ }
186
+
187
+ function clearedSession(cookies: Map<string, string>): string[] {
188
+ return [...cookies.keys()]
189
+ .filter((name) => name.startsWith(`${sessionName}.`))
190
+ .map((name) => cookie(name, "", { maxAge: 0 }));
191
+ }
192
+
193
+ async function sessionCookies(stored: StoredSession, cookies: Map<string, string>): Promise<string[]> {
194
+ const value = await seal(await keys.session, stored);
195
+ const chunks = Math.ceil(value.length / chunkSize);
196
+ if (chunks > maxChunks) throw new Error("The session is too large to keep in cookies");
197
+ const maxAge = options.offlineAccess === true ? offlineCookieMaxAgeSeconds : undefined;
198
+ const written = Array.from({ length: chunks }, (_, index) =>
199
+ cookie(`${sessionName}.${index}`, value.slice(index * chunkSize, (index + 1) * chunkSize), {
200
+ ...(maxAge === undefined ? {} : { maxAge }),
201
+ }),
202
+ );
203
+ const stale = [...cookies.keys()]
204
+ .filter((name) => name.startsWith(`${sessionName}.`) && Number(name.slice(sessionName.length + 1)) >= chunks)
205
+ .map((name) => cookie(name, "", { maxAge: 0 }));
206
+ return [...written, ...stale];
207
+ }
208
+
209
+ async function storedSession(cookies: Map<string, string>): Promise<StoredSession | undefined> {
210
+ let value = "";
211
+ for (let index = 0; cookies.has(`${sessionName}.${index}`); index += 1) {
212
+ value += cookies.get(`${sessionName}.${index}`);
213
+ }
214
+ const stored = await unseal(await keys.session, value);
215
+ return isStoredSession(stored) ? stored : undefined;
216
+ }
217
+
218
+ async function login(request: Request): Promise<Response> {
219
+ const returnTo = safeReturnTo(new URL(request.url).searchParams.get("returnTo"), afterLogin, appUrl);
220
+ const { url, state, verifier } = await startWebLogin(client, {
221
+ redirectUri,
222
+ ...(options.offlineAccess === true ? { offlineAccess: true } : {}),
223
+ ...(options.idpHint ? { idpHint: options.idpHint } : {}),
224
+ });
225
+ const sealed = await seal(await keys.login, { state, verifier, returnTo } satisfies PendingLogin, loginTtlMs);
226
+ return redirect(url, [cookie(loginName, sealed, { path: paths.callback, maxAge: loginTtlMs / 1000 })]);
227
+ }
228
+
229
+ async function callback(request: Request): Promise<Response> {
230
+ const url = new URL(request.url);
231
+ const cookies = cookiesOf(request);
232
+ const clearLogin = cookie(loginName, "", { path: paths.callback, maxAge: 0 });
233
+ const pending = await unseal(await keys.login, cookies.get(loginName));
234
+ const failed = (reason: string) =>
235
+ new Response(`Sign-in failed: ${reason}`, { status: 400, headers: { "set-cookie": clearLogin } });
236
+
237
+ if (url.searchParams.has("error")) {
238
+ return failed(url.searchParams.get("error_description") ?? url.searchParams.get("error") ?? "refused");
239
+ }
240
+ if (!isPendingLogin(pending)) return failed("the sign-in expired, start again");
241
+ if (url.searchParams.get("state") !== pending.state) return failed("the sign-in does not match this browser");
242
+ const code = url.searchParams.get("code");
243
+ if (!code) return failed("no code");
244
+
245
+ const tokens = await finishWebLogin(
246
+ client,
247
+ { code, redirectUri, verifier: pending.verifier, callbackUrl: url.toString(), state: pending.state },
248
+ fetchImpl,
249
+ );
250
+ const stored: StoredSession = {
251
+ accessToken: tokens.accessToken,
252
+ ...(tokens.refreshToken ? { refreshToken: tokens.refreshToken } : {}),
253
+ ...(tokens.idToken ? { idToken: tokens.idToken } : {}),
254
+ };
255
+ return redirect(pending.returnTo, [...(await sessionCookies(stored, cookies)), clearLogin]);
256
+ }
257
+
258
+ async function logout(request: Request): Promise<Response> {
259
+ const cookies = cookiesOf(request);
260
+ const stored = await storedSession(cookies);
261
+ if (stored?.refreshToken) {
262
+ forgetRefreshes(stored.refreshToken);
263
+ await endWebLogin(client, stored.refreshToken, fetchImpl);
264
+ }
265
+ // Keycloak matches this exactly against the app's redirect URLs and home URL, which has no trailing slash.
266
+ const landing = afterLogout === "/" ? options.appUrl.replace(/\/+$/, "") : new URL(afterLogout, appUrl).toString();
267
+ const target =
268
+ options.signOutOfManta === false
269
+ ? afterLogout
270
+ : webLogoutUrl(client, {
271
+ ...(stored?.idToken ? { idToken: stored.idToken } : {}),
272
+ postLogoutRedirectUri: landing,
273
+ });
274
+ return redirect(target, clearedSession(cookies));
275
+ }
276
+
277
+ return {
278
+ async handle(request) {
279
+ const { pathname } = new URL(request.url);
280
+ if (pathname === paths.login && request.method === "GET") return login(request);
281
+ if (pathname === paths.callback && request.method === "GET") return callback(request);
282
+ if (pathname === paths.logout && (request.method === "GET" || request.method === "POST")) {
283
+ // The session cookies are SameSite=Lax, so a link from another site would otherwise sign the person out.
284
+ const site = request.headers.get("sec-fetch-site");
285
+ if (site === "cross-site" || site === "same-site")
286
+ return new Response("Sign out from the app", { status: 403 });
287
+ return logout(request);
288
+ }
289
+ return undefined;
290
+ },
291
+
292
+ async session(request) {
293
+ const cookies = cookiesOf(request);
294
+ const stored = await storedSession(cookies);
295
+ if (!stored) return { accessToken: undefined, setCookies: [] };
296
+
297
+ const now = Date.now();
298
+ if (accessTokenExpiresAt(stored.accessToken, now) > now) {
299
+ return {
300
+ accessToken: stored.accessToken,
301
+ ...(stored.idToken ? { idToken: stored.idToken } : {}),
302
+ setCookies: [],
303
+ };
304
+ }
305
+
306
+ const renewed = stored.refreshToken ? await refreshOnce(stored.refreshToken) : undefined;
307
+ if (!renewed) return { accessToken: undefined, setCookies: clearedSession(cookies) };
308
+
309
+ const next: StoredSession = {
310
+ accessToken: renewed.accessToken,
311
+ ...(renewed.refreshToken ? { refreshToken: renewed.refreshToken } : {}),
312
+ ...((renewed.idToken ?? stored.idToken) ? { idToken: renewed.idToken ?? stored.idToken } : {}),
313
+ };
314
+ return {
315
+ accessToken: next.accessToken,
316
+ ...(next.idToken ? { idToken: next.idToken } : {}),
317
+ setCookies: await sessionCookies(next, cookies),
318
+ };
319
+ },
320
+ };
321
+ }
package/src/web.ts ADDED
@@ -0,0 +1,25 @@
1
+ export { defaultDeployment } from "./default-deployment.js";
2
+ export type { PublicClient } from "./device-login.js";
3
+ export { describeError, isTransientStatus, RefusedRequestError, type FetchLike } from "./http.js";
4
+ export { discoveryUrl, issuerUrl, type OidcClient, type Realm } from "./oidc.js";
5
+ export {
6
+ accessTokenExpiresAt,
7
+ cachedToken,
8
+ mintWithClientCredentials,
9
+ withRenewal,
10
+ type ClientCredentials,
11
+ type RenewableTokenSource,
12
+ type TokenSource,
13
+ } from "./token.js";
14
+ export {
15
+ endWebLogin,
16
+ finishWebLogin,
17
+ refreshWebLogin,
18
+ startWebLogin,
19
+ webLogoutUrl,
20
+ type StartWebLoginOptions,
21
+ type WebLoginStart,
22
+ type WebLogoutOptions,
23
+ type WebTokens,
24
+ } from "./web-login.js";
25
+ export { webLoginRoutes, type WebLoginRoutes, type WebLoginRoutesOptions, type WebSession } from "./web-routes.js";