@manta-eu/auth 0.1.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +98 -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/custom-app-user-token.d.ts +33 -0
  11. package/dist/custom-app-user-token.d.ts.map +1 -0
  12. package/dist/custom-app-user-token.js +56 -0
  13. package/dist/custom-app-user-token.js.map +1 -0
  14. package/dist/default-deployment.d.ts +8 -0
  15. package/dist/default-deployment.d.ts.map +1 -1
  16. package/dist/default-deployment.js +7 -0
  17. package/dist/default-deployment.js.map +1 -0
  18. package/dist/device-login.js +1 -0
  19. package/dist/device-login.js.map +1 -0
  20. package/dist/http.js +1 -0
  21. package/dist/http.js.map +1 -0
  22. package/dist/index.d.ts +4 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +5 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/oidc.d.ts +15 -0
  27. package/dist/oidc.d.ts.map +1 -0
  28. package/dist/oidc.js +27 -0
  29. package/dist/oidc.js.map +1 -0
  30. package/dist/stored-login.js +1 -0
  31. package/dist/stored-login.js.map +1 -0
  32. package/dist/token.d.ts +3 -4
  33. package/dist/token.d.ts.map +1 -1
  34. package/dist/token.js +36 -11
  35. package/dist/token.js.map +1 -0
  36. package/dist/web-login.d.ts +40 -0
  37. package/dist/web-login.d.ts.map +1 -0
  38. package/dist/web-login.js +82 -0
  39. package/dist/web-login.js.map +1 -0
  40. package/dist/web-routes.d.ts +42 -0
  41. package/dist/web-routes.d.ts.map +1 -0
  42. package/dist/web-routes.js +248 -0
  43. package/dist/web-routes.js.map +1 -0
  44. package/dist/web.d.ts +9 -0
  45. package/dist/web.d.ts.map +1 -0
  46. package/dist/web.js +8 -0
  47. package/dist/web.js.map +1 -0
  48. package/package.json +15 -3
  49. package/src/auth-commands.ts +239 -0
  50. package/src/browser-login.ts +203 -0
  51. package/src/browser.ts +32 -0
  52. package/src/credential-store.ts +225 -0
  53. package/src/custom-app-user-token.ts +100 -0
  54. package/src/default-deployment.ts +15 -0
  55. package/src/device-login.ts +181 -0
  56. package/src/http.ts +47 -0
  57. package/src/index.ts +67 -0
  58. package/src/oidc.ts +40 -0
  59. package/src/stored-login.ts +63 -0
  60. package/src/token.ts +154 -0
  61. package/src/web-login.ts +106 -0
  62. package/src/web-routes.ts +321 -0
  63. package/src/web.ts +32 -0
@@ -0,0 +1,100 @@
1
+ import {
2
+ customAppClientIdProblem,
3
+ customAppUserDatastoresProblem,
4
+ customAppUserIdProblem,
5
+ customAppUserProfileProblem,
6
+ customAppUserTeamsProblem,
7
+ customAppUserTokenPath,
8
+ type CustomAppUserProfile,
9
+ type CustomAppUserTokenRequest,
10
+ } from "@manta-eu/lib/custom-app";
11
+ import { isPresent } from "@manta-eu/lib/present";
12
+ import { defaultDeployment } from "./default-deployment.js";
13
+ import { type FetchLike, postTo, readJson, RefusedRequestError } from "./http.js";
14
+
15
+ export type CustomAppUserGrantBase = {
16
+ /** The custom app's client ID and secret, held only by the app's server. */
17
+ clientId: string;
18
+ clientSecret: string;
19
+ /** Narrows the token to these datastores; leave out to reach every datastore the app reaches. */
20
+ datastores?: string[];
21
+ /**
22
+ * Places the person in these teams of the app's organization, by path like /Sales/EU, as a member of them. An empty
23
+ * list places them in no team; leaving it out places them in every team the app reaches.
24
+ */
25
+ teams?: string[];
26
+ /** Manta's address, https://app.hiremanta.com unless you name another deployment. */
27
+ apiUrl?: string;
28
+ };
29
+
30
+ /** A person your app knows: Manta keeps their chats under your id for them, and no memories. */
31
+ export type IdentifiedCustomAppUser = CustomAppUserGrantBase &
32
+ CustomAppUserProfile & { userId: string; anonymousId?: never };
33
+
34
+ /** A visitor your app has not identified, by your session id for them: they chat, and Manta keeps no memories. */
35
+ export type AnonymousCustomAppVisitor = CustomAppUserGrantBase & { anonymousId: string; userId?: never };
36
+
37
+ export type CustomAppUserAccessTokenOptions = IdentifiedCustomAppUser | AnonymousCustomAppVisitor;
38
+
39
+ function requestFor(options: CustomAppUserAccessTokenOptions): CustomAppUserTokenRequest {
40
+ const { clientId, clientSecret, datastores, teams } = options;
41
+ const narrowed = { ...(datastores === undefined ? {} : { datastores }), ...(teams === undefined ? {} : { teams }) };
42
+ if (options.userId === undefined) {
43
+ return { client_id: clientId, client_secret: clientSecret, anonymous_id: options.anonymousId, ...narrowed };
44
+ }
45
+ const { userId, name, email, fields } = options;
46
+ return {
47
+ client_id: clientId,
48
+ client_secret: clientSecret,
49
+ user_id: userId,
50
+ ...(name === undefined ? {} : { name }),
51
+ ...(email === undefined ? {} : { email }),
52
+ ...(fields === undefined ? {} : { fields }),
53
+ ...narrowed,
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Gets a Manta access token for one of the app's own users, or for an anonymous visitor. It sends the app's client
59
+ * secret, so call it on the app's server only; the token it returns may go to the browser.
60
+ */
61
+ export async function customAppUserAccessToken(
62
+ options: CustomAppUserAccessTokenOptions,
63
+ fetchImpl: FetchLike = fetch,
64
+ ): Promise<string> {
65
+ if (typeof (globalThis as { document?: unknown }).document !== "undefined") {
66
+ throw new Error("customAppUserAccessToken runs on your server only: it sends the app's client secret");
67
+ }
68
+ const clientIdProblem = customAppClientIdProblem(options.clientId);
69
+ if (clientIdProblem !== undefined) throw new Error(clientIdProblem);
70
+ if (!isPresent(options.clientSecret)) throw new Error("The app's client secret is required");
71
+ const problem =
72
+ customAppUserIdProblem(options.userId ?? options.anonymousId) ??
73
+ customAppUserDatastoresProblem(options.datastores) ??
74
+ customAppUserTeamsProblem(options.teams) ??
75
+ (options.userId === undefined ? undefined : customAppUserProfileProblem(options));
76
+ if (problem !== undefined) throw new Error(problem);
77
+
78
+ const endpoint = new URL(customAppUserTokenPath, options.apiUrl ?? defaultDeployment.apiUrl).toString();
79
+ const response = await postTo(
80
+ endpoint,
81
+ {
82
+ method: "POST",
83
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
84
+ body: JSON.stringify(requestFor(options)),
85
+ },
86
+ fetchImpl,
87
+ );
88
+ if (!response.ok) {
89
+ const refusal = await readJson<{ error_description?: string }>(response, endpoint).catch(
90
+ (): { error_description?: string } => ({}),
91
+ );
92
+ throw new RefusedRequestError(
93
+ `Manta refused the custom app user token (${response.status}). ${refusal.error_description ?? ""}`.trim(),
94
+ response.status,
95
+ );
96
+ }
97
+ const body = await readJson<{ access_token?: string }>(response, endpoint);
98
+ if (typeof body.access_token !== "string") throw new Error("Manta answered without an access token");
99
+ return body.access_token;
100
+ }
@@ -0,0 +1,15 @@
1
+ // hiremanta.com production, which every SDK entry point targets unless its caller names another deployment.
2
+ export const defaultDeployment = {
3
+ apiUrl: "https://app.hiremanta.com",
4
+ keycloakUrl: "https://auth.hiremanta.com",
5
+ keycloakRealm: "hiremanta-com",
6
+ } as const;
7
+
8
+ export type Realm = { baseUrl?: string; realm?: string };
9
+
10
+ export function realmOrDefault(target: Realm = {}): { baseUrl: string; realm: string } {
11
+ return {
12
+ baseUrl: (target.baseUrl ?? defaultDeployment.keycloakUrl).replace(/\/+$/, ""),
13
+ realm: target.realm ?? defaultDeployment.keycloakRealm,
14
+ };
15
+ }
@@ -0,0 +1,181 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { type FetchLike, formRequest, isTransientStatus, postTo, readJson } from "./http.js";
3
+ import { tokenEndpoint } from "./token.js";
4
+
5
+ export type PublicClient = {
6
+ baseUrl: string;
7
+ realm: string;
8
+ clientId: string;
9
+ };
10
+
11
+ export type DeviceLogin = {
12
+ deviceCode: string;
13
+ userCode: string;
14
+ verificationUri: string;
15
+ intervalMs: number;
16
+ expiresInMs: number;
17
+ codeVerifier: string;
18
+ };
19
+
20
+ export type PkcePair = { verifier: string; challenge: string };
21
+
22
+ export type Sleep = (ms: number) => Promise<void>;
23
+
24
+ const deviceCodeGrant = "urn:ietf:params:oauth:grant-type:device_code";
25
+
26
+ // RFC 8628 says to add this much whenever the server answers slow_down.
27
+ const slowDownStepMs = 5_000;
28
+
29
+ const defaultIntervalMs = 5_000;
30
+
31
+ // RFC 8628 makes expires_in optional, and Keycloak sends ten minutes.
32
+ const defaultExpiresInMs = 600_000;
33
+
34
+ const sleepFor: Sleep = (ms) => new Promise((done) => setTimeout(done, ms));
35
+
36
+ export function deviceAuthEndpoint(baseUrl: string, realm: string): string {
37
+ return `${baseUrl}/realms/${realm}/protocol/openid-connect/auth/device`;
38
+ }
39
+
40
+ // A public client holds no secret, so PKCE is what ties the token request back to this process.
41
+ export function createPkcePair(): PkcePair {
42
+ const verifier = randomBytes(32).toString("base64url");
43
+
44
+ return { verifier, challenge: createHash("sha256").update(verifier).digest("base64url") };
45
+ }
46
+
47
+ type DeviceLoginResponse = {
48
+ device_code?: string;
49
+ user_code?: string;
50
+ verification_uri?: string;
51
+ verification_uri_complete?: string;
52
+ interval?: number;
53
+ expires_in?: number;
54
+ };
55
+
56
+ function readDeviceLogin(body: DeviceLoginResponse, endpoint: string, codeVerifier: string): DeviceLogin {
57
+ const deviceCode = body.device_code;
58
+ const userCode = body.user_code;
59
+ const verificationUri = body.verification_uri_complete ?? body.verification_uri;
60
+
61
+ if (!deviceCode || !userCode || !verificationUri) {
62
+ throw new Error(`the device login response from ${endpoint} is incomplete.`);
63
+ }
64
+
65
+ return {
66
+ deviceCode,
67
+ userCode,
68
+ verificationUri,
69
+ intervalMs: body.interval ? body.interval * 1000 : defaultIntervalMs,
70
+ expiresInMs: body.expires_in ? body.expires_in * 1000 : defaultExpiresInMs,
71
+ codeVerifier,
72
+ };
73
+ }
74
+
75
+ // No secret lives on the machine: a developer needs nothing beyond a browser login.
76
+ export async function startDeviceLogin(
77
+ client: PublicClient,
78
+ fetchImpl: FetchLike = fetch,
79
+ pkce: PkcePair = createPkcePair(),
80
+ scope?: string,
81
+ ): Promise<DeviceLogin> {
82
+ const endpoint = deviceAuthEndpoint(client.baseUrl, client.realm);
83
+ const request = formRequest({
84
+ client_id: client.clientId,
85
+ code_challenge: pkce.challenge,
86
+ code_challenge_method: "S256",
87
+ ...(scope ? { scope } : {}),
88
+ });
89
+ const response = await postTo(endpoint, request, fetchImpl);
90
+
91
+ if (!response.ok) {
92
+ throw new Error(
93
+ `${endpoint} refused the device login with status ${response.status}. ` +
94
+ `Check that the client ${client.clientId} is public and allows the OAuth device flow.`,
95
+ );
96
+ }
97
+
98
+ return readDeviceLogin(await readJson<DeviceLoginResponse>(response, endpoint), endpoint, pkce.verifier);
99
+ }
100
+
101
+ function deviceLoginFailure(error: string | undefined, status: number): Error | undefined {
102
+ if (error === "authorization_pending" || error === "slow_down") return undefined;
103
+ if (error === "access_denied") return new Error("the login was refused in the browser.");
104
+ if (error === "expired_token") return new Error("the login code expired. Run the command again.");
105
+
106
+ return new Error(`the device login failed with "${error ?? status}".`);
107
+ }
108
+
109
+ function deviceTokenRequest(client: PublicClient, deviceCode: string, codeVerifier: string): RequestInit {
110
+ return formRequest({
111
+ grant_type: deviceCodeGrant,
112
+ device_code: deviceCode,
113
+ client_id: client.clientId,
114
+ code_verifier: codeVerifier,
115
+ });
116
+ }
117
+
118
+ export type DeviceTokens = { accessToken: string; refreshToken?: string };
119
+
120
+ export async function pollForDeviceTokens(
121
+ client: PublicClient,
122
+ {
123
+ deviceCode,
124
+ codeVerifier,
125
+ intervalMs = defaultIntervalMs,
126
+ expiresInMs = defaultExpiresInMs,
127
+ }: Pick<DeviceLogin, "deviceCode" | "codeVerifier"> & { intervalMs?: number; expiresInMs?: number },
128
+ {
129
+ fetchImpl = fetch,
130
+ sleep = sleepFor,
131
+ clock = Date.now,
132
+ }: { fetchImpl?: FetchLike; sleep?: Sleep; clock?: () => number } = {},
133
+ ): Promise<DeviceTokens> {
134
+ const endpoint = tokenEndpoint(client.baseUrl, client.realm);
135
+ const request = deviceTokenRequest(client, deviceCode, codeVerifier);
136
+ const deadline = clock() + expiresInMs;
137
+ let wait = intervalMs;
138
+
139
+ for (;;) {
140
+ // Keycloak answers authorization_pending for ever if nobody opens the link.
141
+ if (clock() >= deadline) throw new Error("nobody approved the login in time. Run it again.");
142
+
143
+ const response = await postTo(endpoint, request, fetchImpl);
144
+ const body = await readJson<{ access_token?: string; refresh_token?: string; error?: string }>(response, endpoint);
145
+
146
+ if (response.ok && body.access_token) {
147
+ return { accessToken: body.access_token, ...(body.refresh_token ? { refreshToken: body.refresh_token } : {}) };
148
+ }
149
+
150
+ const failure = deviceLoginFailure(body.error, response.status);
151
+ if (failure) throw failure;
152
+
153
+ if (body.error === "slow_down") wait += slowDownStepMs;
154
+
155
+ await sleep(wait);
156
+ }
157
+ }
158
+
159
+ // A refresh token outlives many access tokens, so a long run renews without a second browser login.
160
+ export async function refreshAccessToken(
161
+ client: PublicClient,
162
+ refreshToken: string,
163
+ fetchImpl: FetchLike = fetch,
164
+ ): Promise<DeviceTokens | undefined> {
165
+ const endpoint = tokenEndpoint(client.baseUrl, client.realm);
166
+ const request = formRequest({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: client.clientId });
167
+ const response = await postTo(endpoint, request, fetchImpl);
168
+
169
+ // A refused refresh token is terminal and earns a new login; an overloaded Keycloak must not cost one.
170
+ if (isTransientStatus(response.status)) {
171
+ throw new Error(`${endpoint} could not renew the token (status ${response.status}).`);
172
+ }
173
+
174
+ if (!response.ok) return undefined;
175
+
176
+ const body = await readJson<{ access_token?: string; refresh_token?: string }>(response, endpoint);
177
+
178
+ if (!body.access_token) return undefined;
179
+
180
+ return { accessToken: body.access_token, refreshToken: body.refresh_token ?? refreshToken };
181
+ }
package/src/http.ts ADDED
@@ -0,0 +1,47 @@
1
+ export type FetchLike = (url: string, init: RequestInit) => Promise<Response>;
2
+
3
+ export function formRequest(fields: Record<string, string>): RequestInit {
4
+ return {
5
+ method: "POST",
6
+ headers: { "content-type": "application/x-www-form-urlencoded" },
7
+ body: new URLSearchParams(fields).toString(),
8
+ };
9
+ }
10
+
11
+ export class RefusedRequestError extends Error {
12
+ constructor(
13
+ message: string,
14
+ readonly status: number,
15
+ ) {
16
+ super(message);
17
+ }
18
+ }
19
+
20
+ // Overload and timeout, which are worth repeating; every other refusal is the server's final word.
21
+ export function isTransientStatus(status: number): boolean {
22
+ return status >= 500 || status === 429 || status === 408;
23
+ }
24
+
25
+ export async function readJson<T>(response: Response, endpoint: string): Promise<T> {
26
+ const body = await response.text();
27
+
28
+ try {
29
+ return JSON.parse(body) as T;
30
+ } catch {
31
+ throw new Error(`${endpoint} answered status ${response.status} with text, not JSON: ${body}`);
32
+ }
33
+ }
34
+
35
+ export async function postTo(endpoint: string, init: RequestInit, fetchImpl: FetchLike = fetch): Promise<Response> {
36
+ try {
37
+ return await fetchImpl(endpoint, init);
38
+ } catch (cause) {
39
+ throw new Error(`cannot reach ${endpoint}.`, { cause });
40
+ }
41
+ }
42
+
43
+ export function describeError(error: unknown): string {
44
+ if (!(error instanceof Error)) return String(error);
45
+
46
+ return error.cause instanceof Error ? `${error.message}\n caused by: ${error.cause.message}` : error.message;
47
+ }
package/src/index.ts ADDED
@@ -0,0 +1,67 @@
1
+ export {
2
+ ciClientId,
3
+ cliClientId,
4
+ clientIdHelp,
5
+ interactiveLogin,
6
+ logIn,
7
+ loginStatus,
8
+ logOut,
9
+ publicClientFrom,
10
+ registerAuthCommands,
11
+ revokeRefreshToken,
12
+ userTokenSource,
13
+ type ClientOptions,
14
+ type LoginOptions,
15
+ } from "./auth-commands.js";
16
+ export { browserLogin, loginScope } from "./browser-login.js";
17
+ export {
18
+ defaultCredentialStore,
19
+ fileCredentialStore,
20
+ keychainCredentialStore,
21
+ keychainWithFileFallback,
22
+ type CredentialStore,
23
+ type RunSecurity,
24
+ type SecurityResult,
25
+ type StoredLogin,
26
+ } from "./credential-store.js";
27
+ export { defaultDeployment } from "./default-deployment.js";
28
+ export {
29
+ pollForDeviceTokens,
30
+ refreshAccessToken,
31
+ startDeviceLogin,
32
+ type DeviceLogin,
33
+ type DeviceTokens,
34
+ type PkcePair,
35
+ type PublicClient,
36
+ type Sleep,
37
+ } from "./device-login.js";
38
+ export { describeError, isTransientStatus, postTo, readJson, RefusedRequestError, type FetchLike } from "./http.js";
39
+ export { saveLogin, storedLoginTokenSource } from "./stored-login.js";
40
+ export {
41
+ cachedToken,
42
+ mintWithClientCredentials,
43
+ withRenewal,
44
+ type ClientCredentials,
45
+ type RenewableTokenSource,
46
+ type TokenSource,
47
+ } from "./token.js";
48
+ export { discoveryUrl, issuerUrl, type OidcClient, type Realm } from "./oidc.js";
49
+ export {
50
+ endWebLogin,
51
+ finishWebLogin,
52
+ refreshWebLogin,
53
+ startWebLogin,
54
+ webLogoutUrl,
55
+ type StartWebLoginOptions,
56
+ type WebLoginStart,
57
+ type WebLogoutOptions,
58
+ type WebTokens,
59
+ } from "./web-login.js";
60
+ export { webLoginRoutes, type WebLoginRoutes, type WebLoginRoutesOptions, type WebSession } from "./web-routes.js";
61
+ export {
62
+ customAppUserAccessToken,
63
+ type AnonymousCustomAppVisitor,
64
+ type CustomAppUserAccessTokenOptions,
65
+ type CustomAppUserGrantBase,
66
+ type IdentifiedCustomAppUser,
67
+ } from "./custom-app-user-token.js";
package/src/oidc.ts ADDED
@@ -0,0 +1,40 @@
1
+ import * as oidc from "openid-client";
2
+ import { type Realm, realmOrDefault } from "./default-deployment.js";
3
+ import type { PublicClient } from "./device-login.js";
4
+ import type { FetchLike } from "./http.js";
5
+
6
+ export type { Realm };
7
+
8
+ /** A client with a secret is an app's own server; without one it is a public client, like the CLIs. */
9
+ export type OidcClient = Omit<PublicClient, "baseUrl" | "realm"> & Realm & { clientSecret?: string };
10
+
11
+ /** The realm's issuer, the `iss` of every token it signs. */
12
+ export function issuerUrl(target?: Realm): string {
13
+ const { baseUrl, realm } = realmOrDefault(target);
14
+ return `${baseUrl}/realms/${realm}`;
15
+ }
16
+
17
+ /** The OpenID Connect discovery document most libraries configure themselves from. */
18
+ export function discoveryUrl(target?: Realm): string {
19
+ return `${issuerUrl(target)}/.well-known/openid-configuration`;
20
+ }
21
+
22
+ // Keycloak's endpoints are fixed under the issuer, so no discovery request precedes each call.
23
+ export function oidcConfiguration(client: OidcClient, fetchImpl?: FetchLike): oidc.Configuration {
24
+ const issuer = issuerUrl(client);
25
+ const endpoint = (name: string) => `${issuer}/protocol/openid-connect/${name}`;
26
+ const config = new oidc.Configuration(
27
+ {
28
+ issuer,
29
+ authorization_endpoint: endpoint("auth"),
30
+ token_endpoint: endpoint("token"),
31
+ end_session_endpoint: endpoint("logout"),
32
+ revocation_endpoint: endpoint("revoke"),
33
+ },
34
+ client.clientId,
35
+ undefined,
36
+ client.clientSecret ? oidc.ClientSecretPost(client.clientSecret) : oidc.None(),
37
+ );
38
+ if (fetchImpl) config[oidc.customFetch] = (url, options) => fetchImpl(url, options as RequestInit);
39
+ return config;
40
+ }
@@ -0,0 +1,63 @@
1
+ import type { CredentialStore } from "./credential-store.js";
2
+ import { type DeviceTokens, type PublicClient, refreshAccessToken } from "./device-login.js";
3
+ import { describeError, type FetchLike } from "./http.js";
4
+ import { accessTokenUsername, cachedToken, type RenewableTokenSource } from "./token.js";
5
+
6
+ // Keycloak may rotate the refresh token on every renewal, so each new one replaces the stored one.
7
+ export async function saveLogin(store: CredentialStore, client: PublicClient, tokens: DeviceTokens): Promise<boolean> {
8
+ if (!tokens.refreshToken) return false;
9
+
10
+ const username = accessTokenUsername(tokens.accessToken);
11
+
12
+ await store.write(client, { refreshToken: tokens.refreshToken, ...(username ? { username } : {}) });
13
+
14
+ return true;
15
+ }
16
+
17
+ // Renews from the stored login, and only asks for a new one when Keycloak refuses it; a network error keeps it.
18
+ export function storedLoginTokenSource({
19
+ client,
20
+ store,
21
+ login,
22
+ warn = console.error,
23
+ fetchImpl = fetch,
24
+ clock = Date.now,
25
+ }: {
26
+ client: PublicClient;
27
+ store: CredentialStore;
28
+ login: () => Promise<DeviceTokens>;
29
+ warn?: (message: string) => void;
30
+ fetchImpl?: FetchLike;
31
+ clock?: () => number;
32
+ }): RenewableTokenSource {
33
+ // A store it cannot write, e.g. a read-only $HOME, costs the next run a login instead of failing this one.
34
+ async function keep(tokens: DeviceTokens): Promise<void> {
35
+ await saveLogin(store, client, tokens).catch((error: unknown) =>
36
+ warn(`could not store the login, so the next run may ask for another: ${describeError(error)}`),
37
+ );
38
+ }
39
+
40
+ async function renewOrLogIn(): Promise<string> {
41
+ const stored = await store.read(client);
42
+
43
+ if (stored) {
44
+ const renewed = await refreshAccessToken(client, stored.refreshToken, fetchImpl);
45
+
46
+ if (renewed) {
47
+ await keep(renewed);
48
+ return renewed.accessToken;
49
+ }
50
+
51
+ await store
52
+ .remove(client)
53
+ .catch((error: unknown) => warn(`could not delete the refused login: ${describeError(error)}`));
54
+ }
55
+
56
+ const tokens = await login();
57
+ await keep(tokens);
58
+
59
+ return tokens.accessToken;
60
+ }
61
+
62
+ return cachedToken(renewOrLogIn, clock);
63
+ }
package/src/token.ts ADDED
@@ -0,0 +1,154 @@
1
+ import { type Realm, realmOrDefault } from "./default-deployment.js";
2
+ import { type FetchLike, formRequest, postTo, RefusedRequestError } from "./http.js";
3
+
4
+ export type ClientCredentials = Realm & {
5
+ clientId: string;
6
+ clientSecret: string;
7
+ scope?: string;
8
+ };
9
+
10
+ export type TokenSource = () => Promise<string>;
11
+
12
+ // A source that can drop its cached token, so a request the API rejected is retried with a fresh one.
13
+ export type RenewableTokenSource = TokenSource & { renew: (rejected?: string) => void };
14
+
15
+ function isRenewable(token: TokenSource): token is RenewableTokenSource {
16
+ return typeof (token as Partial<RenewableTokenSource>).renew === "function";
17
+ }
18
+
19
+ // A token revoked or expired early answers 401 once; renewing and retrying once is what Claude Code does for MCP.
20
+ export async function withRenewal<T>(token: TokenSource, call: (accessToken: string) => Promise<T>): Promise<T> {
21
+ let accessToken: string | undefined;
22
+ try {
23
+ accessToken = await token();
24
+ return await call(accessToken);
25
+ } catch (error) {
26
+ if (!(error instanceof RefusedRequestError && error.status === 401) || !isRenewable(token)) throw error;
27
+
28
+ token.renew(accessToken);
29
+
30
+ return call(await token());
31
+ }
32
+ }
33
+
34
+ // Re-mint before Keycloak's own exp, so an in-flight request never races an expired token.
35
+ export const tokenExpiryBufferMs = 60_000;
36
+
37
+ // The exp claim did not parse, so the token gets a short life rather than none.
38
+ export const unparsableTokenTtlMs = 30_000;
39
+
40
+ export function tokenEndpoint(baseUrl: string, realm: string): string {
41
+ return `${baseUrl}/realms/${realm}/protocol/openid-connect/token`;
42
+ }
43
+
44
+ // atob and TextDecoder, not Buffer, so the token helpers also run in a browser.
45
+ function decodeBase64Url(value: string): string {
46
+ const base64 = value
47
+ .replace(/-/g, "+")
48
+ .replace(/_/g, "/")
49
+ .padEnd(Math.ceil(value.length / 4) * 4, "=");
50
+ return new TextDecoder().decode(Uint8Array.from(atob(base64), (char) => char.charCodeAt(0)));
51
+ }
52
+
53
+ // Reads the claims without checking the signature: callers only use them to schedule and to label.
54
+ function unverifiedClaims(accessToken: string): Record<string, unknown> {
55
+ const payload = accessToken.split(".")[1];
56
+
57
+ if (!payload) return {};
58
+
59
+ try {
60
+ const claims: unknown = JSON.parse(decodeBase64Url(payload));
61
+
62
+ return typeof claims === "object" && claims !== null ? (claims as Record<string, unknown>) : {};
63
+ } catch {
64
+ return {};
65
+ }
66
+ }
67
+
68
+ export function accessTokenExpiresAt(accessToken: string, now: number): number {
69
+ const exp = unverifiedClaims(accessToken).exp;
70
+
71
+ return typeof exp === "number" ? exp * 1000 - tokenExpiryBufferMs : now + unparsableTokenTtlMs;
72
+ }
73
+
74
+ export function accessTokenUsername(accessToken: string): string | undefined {
75
+ const claims = unverifiedClaims(accessToken);
76
+ const name = claims.preferred_username ?? claims.email;
77
+
78
+ return typeof name === "string" ? name : undefined;
79
+ }
80
+
81
+ export function isFresh(expiresAt: number, now: number): boolean {
82
+ return expiresAt > now;
83
+ }
84
+
85
+ function clientCredentialsRequest({ clientId, clientSecret, scope }: ClientCredentials): RequestInit {
86
+ return formRequest({
87
+ grant_type: "client_credentials",
88
+ client_id: clientId,
89
+ client_secret: clientSecret,
90
+ ...(scope ? { scope } : {}),
91
+ });
92
+ }
93
+
94
+ function readAccessToken(body: unknown, realm: string): string {
95
+ const accessToken = (body as { access_token?: unknown }).access_token;
96
+
97
+ if (typeof accessToken !== "string") {
98
+ throw new Error(`the Keycloak response for realm ${realm} carries no access_token.`);
99
+ }
100
+
101
+ return accessToken;
102
+ }
103
+
104
+ export async function mintWithClientCredentials(
105
+ credentials: ClientCredentials,
106
+ fetchImpl: FetchLike = fetch,
107
+ ): Promise<string> {
108
+ const { baseUrl, realm } = realmOrDefault(credentials);
109
+ const endpoint = tokenEndpoint(baseUrl, realm);
110
+ const response = await postTo(endpoint, clientCredentialsRequest(credentials), fetchImpl);
111
+
112
+ if (!response.ok) {
113
+ const { error } = (await response.json().catch(() => ({}))) as { error?: unknown };
114
+ throw new Error(
115
+ `Keycloak refused the client credentials for realm ${realm} with status ${response.status}. ` +
116
+ (error === "unauthorized_client"
117
+ ? "This client may not mint tokens on its own: for a custom app, turn on Machine access."
118
+ : `Check the client id and the secret against ${endpoint}.`),
119
+ );
120
+ }
121
+
122
+ return readAccessToken(await response.json(), realm);
123
+ }
124
+
125
+ export function cachedToken(mint: TokenSource, clock: () => number = Date.now): RenewableTokenSource {
126
+ let held: { token: string; expiresAt: number } | undefined;
127
+ // Shared, so callers racing an expired token start one mint: parallel refreshes would spend one refresh token.
128
+ let minting: Promise<string> | undefined;
129
+
130
+ const mintOnce = async () => {
131
+ const token = await mint();
132
+ // A device login blocks until the browser answers, so `now` is already stale here.
133
+ held = { token, expiresAt: accessTokenExpiresAt(token, clock()) };
134
+
135
+ return token;
136
+ };
137
+
138
+ const source = async () => {
139
+ if (held && isFresh(held.expiresAt, clock())) return held.token;
140
+
141
+ const current = (minting ??= mintOnce().finally(() => {
142
+ if (minting === current) minting = undefined;
143
+ }));
144
+
145
+ return current;
146
+ };
147
+
148
+ return Object.assign(source, {
149
+ // Keeps an in-flight mint, so racing 401s join it, and a newer token than the one refused.
150
+ renew: (rejected?: string) => {
151
+ if (rejected === undefined || held?.token === rejected) held = undefined;
152
+ },
153
+ });
154
+ }