@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.
- package/README.md +67 -4
- package/dist/auth-commands.js +1 -0
- package/dist/auth-commands.js.map +1 -0
- package/dist/browser-login.js +1 -0
- package/dist/browser-login.js.map +1 -0
- package/dist/browser.js +1 -0
- package/dist/browser.js.map +1 -0
- package/dist/credential-store.js +1 -0
- package/dist/credential-store.js.map +1 -0
- package/dist/default-deployment.d.ts +8 -0
- package/dist/default-deployment.d.ts.map +1 -1
- package/dist/default-deployment.js +7 -0
- package/dist/default-deployment.js.map +1 -0
- package/dist/device-login.js +1 -0
- package/dist/device-login.js.map +1 -0
- package/dist/http.js +1 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/oidc.d.ts +15 -0
- package/dist/oidc.d.ts.map +1 -0
- package/dist/oidc.js +27 -0
- package/dist/oidc.js.map +1 -0
- package/dist/stored-login.js +1 -0
- package/dist/stored-login.js.map +1 -0
- package/dist/token.d.ts +3 -4
- package/dist/token.d.ts.map +1 -1
- package/dist/token.js +32 -10
- package/dist/token.js.map +1 -0
- package/dist/web-login.d.ts +40 -0
- package/dist/web-login.d.ts.map +1 -0
- package/dist/web-login.js +82 -0
- package/dist/web-login.js.map +1 -0
- package/dist/web-routes.d.ts +42 -0
- package/dist/web-routes.d.ts.map +1 -0
- package/dist/web-routes.js +248 -0
- package/dist/web-routes.js.map +1 -0
- package/dist/web.d.ts +8 -0
- package/dist/web.d.ts.map +1 -0
- package/dist/web.js +7 -0
- package/dist/web.js.map +1 -0
- package/package.json +14 -3
- package/src/auth-commands.ts +239 -0
- package/src/browser-login.ts +203 -0
- package/src/browser.ts +32 -0
- package/src/credential-store.ts +225 -0
- package/src/default-deployment.ts +15 -0
- package/src/device-login.ts +181 -0
- package/src/http.ts +47 -0
- package/src/index.ts +60 -0
- package/src/oidc.ts +40 -0
- package/src/stored-login.ts +63 -0
- package/src/token.ts +151 -0
- package/src/web-login.ts +106 -0
- package/src/web-routes.ts +321 -0
- package/src/web.ts +25 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { createServer, type Server } from "node:http";
|
|
3
|
+
import type { AddressInfo } from "node:net";
|
|
4
|
+
import { createPkcePair, type DeviceTokens, type PkcePair, type PublicClient } from "./device-login.js";
|
|
5
|
+
import { type FetchLike, formRequest, postTo, readJson } from "./http.js";
|
|
6
|
+
import { tokenEndpoint } from "./token.js";
|
|
7
|
+
|
|
8
|
+
// offline_access makes the refresh token outlive the browser session: it lasts while it is used.
|
|
9
|
+
export const loginScope = "openid offline_access";
|
|
10
|
+
|
|
11
|
+
const callbackPath = "/callback";
|
|
12
|
+
|
|
13
|
+
const defaultTimeoutMs = 300_000;
|
|
14
|
+
|
|
15
|
+
export function authorizationEndpoint(baseUrl: string, realm: string): string {
|
|
16
|
+
return `${baseUrl}/realms/${realm}/protocol/openid-connect/auth`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function authorizationUrl(
|
|
20
|
+
client: PublicClient,
|
|
21
|
+
{ redirectUri, state, challenge, scope }: { redirectUri: string; state: string; challenge: string; scope: string },
|
|
22
|
+
): string {
|
|
23
|
+
const url = new URL(authorizationEndpoint(client.baseUrl, client.realm));
|
|
24
|
+
|
|
25
|
+
url.search = new URLSearchParams({
|
|
26
|
+
client_id: client.clientId,
|
|
27
|
+
response_type: "code",
|
|
28
|
+
redirect_uri: redirectUri,
|
|
29
|
+
scope,
|
|
30
|
+
state,
|
|
31
|
+
code_challenge: challenge,
|
|
32
|
+
code_challenge_method: "S256",
|
|
33
|
+
}).toString();
|
|
34
|
+
|
|
35
|
+
return url.toString();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function page(title: string, message: string): string {
|
|
39
|
+
return (
|
|
40
|
+
`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${title}</title></head>` +
|
|
41
|
+
`<body style="font-family: system-ui, sans-serif; max-width: 32rem; margin: 20vh auto; text-align: center">` +
|
|
42
|
+
`<h1 style="font-size: 1.25rem">${title}</h1><p>${message}</p></body></html>`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function callbackFailure(error: string, description: string | null): Error {
|
|
47
|
+
return error === "access_denied"
|
|
48
|
+
? new Error("the login was refused in the browser.")
|
|
49
|
+
: new Error(`the browser login failed with "${description ?? error}".`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type PendingCallback = { redirectUri: string; code: Promise<string>; close: () => void };
|
|
53
|
+
|
|
54
|
+
// Keycloak matches a 127.0.0.1 redirect URI whatever its port, so the OS picks a free one.
|
|
55
|
+
export async function listenForCallback(state: string, timeoutMs: number = defaultTimeoutMs): Promise<PendingCallback> {
|
|
56
|
+
let settle: { resolve: (code: string) => void; reject: (error: Error) => void } | undefined;
|
|
57
|
+
const code = new Promise<string>((resolve, reject) => (settle = { resolve, reject }));
|
|
58
|
+
|
|
59
|
+
// A timeout can fire after the caller stopped waiting; that must not surface as an unhandled rejection.
|
|
60
|
+
code.catch(() => undefined);
|
|
61
|
+
|
|
62
|
+
const server: Server = createServer((request, response) => {
|
|
63
|
+
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
64
|
+
|
|
65
|
+
if (url.pathname !== callbackPath) {
|
|
66
|
+
response.writeHead(404).end();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const answer = (status: number, title: string, message: string) =>
|
|
71
|
+
response.writeHead(status, { "content-type": "text/html; charset=utf-8" }).end(page(title, message));
|
|
72
|
+
|
|
73
|
+
// A page on another origin can load this URL too; only the state this process sent proves the answer.
|
|
74
|
+
if (url.searchParams.get("state") !== state) {
|
|
75
|
+
answer(400, "Login failed", "This login link does not belong to the command that is waiting. Run it again.");
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const error = url.searchParams.get("error");
|
|
80
|
+
const received = url.searchParams.get("code");
|
|
81
|
+
|
|
82
|
+
if (error || !received) {
|
|
83
|
+
answer(400, "Login failed", "Return to your terminal for the details.");
|
|
84
|
+
settle?.reject(callbackFailure(error ?? "missing_code", url.searchParams.get("error_description")));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
answer(200, "Logged in to Manta", "You can close this tab and return to your terminal.");
|
|
89
|
+
settle?.resolve(received);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
await new Promise<void>((resolve, reject) => {
|
|
93
|
+
server.once("error", reject);
|
|
94
|
+
server.listen(0, "127.0.0.1", () => {
|
|
95
|
+
server.off("error", reject);
|
|
96
|
+
resolve();
|
|
97
|
+
});
|
|
98
|
+
}).catch((cause: unknown) => {
|
|
99
|
+
throw new Error("cannot listen on 127.0.0.1 for the browser login. Use --use-device-code.", {
|
|
100
|
+
cause,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const timer = setTimeout(
|
|
105
|
+
() => settle?.reject(new Error("nobody finished the login in time. Run it again.")),
|
|
106
|
+
timeoutMs,
|
|
107
|
+
);
|
|
108
|
+
const { port } = server.address() as AddressInfo;
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
redirectUri: `http://127.0.0.1:${port}${callbackPath}`,
|
|
112
|
+
code,
|
|
113
|
+
close: () => {
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
server.close();
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function exchangeCode(
|
|
121
|
+
client: PublicClient,
|
|
122
|
+
{ code, redirectUri, verifier }: { code: string; redirectUri: string; verifier: string },
|
|
123
|
+
fetchImpl: FetchLike = fetch,
|
|
124
|
+
): Promise<DeviceTokens> {
|
|
125
|
+
const endpoint = tokenEndpoint(client.baseUrl, client.realm);
|
|
126
|
+
const request = formRequest({
|
|
127
|
+
grant_type: "authorization_code",
|
|
128
|
+
code,
|
|
129
|
+
redirect_uri: redirectUri,
|
|
130
|
+
client_id: client.clientId,
|
|
131
|
+
code_verifier: verifier,
|
|
132
|
+
});
|
|
133
|
+
const response = await postTo(endpoint, request, fetchImpl);
|
|
134
|
+
const body = await readJson<{ access_token?: string; refresh_token?: string; error_description?: string }>(
|
|
135
|
+
response,
|
|
136
|
+
endpoint,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
if (!response.ok || !body.access_token) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`${endpoint} refused the login with status ${response.status}. ${body.error_description ?? ""}`.trim(),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { accessToken: body.access_token, ...(body.refresh_token ? { refreshToken: body.refresh_token } : {}) };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Keycloak answers an unknown client or redirect URI with its own error page, never with a redirect back here.
|
|
149
|
+
export async function checkAuthorizationRequest(
|
|
150
|
+
client: PublicClient,
|
|
151
|
+
url: string,
|
|
152
|
+
fetchImpl: FetchLike = fetch,
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
const response = await postTo(url, { method: "GET", redirect: "manual" }, fetchImpl);
|
|
155
|
+
await response.body?.cancel().catch(() => undefined);
|
|
156
|
+
|
|
157
|
+
if (response.status >= 400) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`Keycloak refused to start the login with status ${response.status}. ` +
|
|
160
|
+
`Check that realm ${client.realm} at ${client.baseUrl} has the client ${client.clientId}.`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// The authorization code flow with PKCE and a loopback redirect (RFC 8252), as Claude Code and the AWS CLI log in.
|
|
166
|
+
export async function browserLogin(
|
|
167
|
+
client: PublicClient,
|
|
168
|
+
{
|
|
169
|
+
openUrl,
|
|
170
|
+
fetchImpl = fetch,
|
|
171
|
+
scope = loginScope,
|
|
172
|
+
timeoutMs = defaultTimeoutMs,
|
|
173
|
+
pkce = createPkcePair(),
|
|
174
|
+
state = randomBytes(16).toString("base64url"),
|
|
175
|
+
}: {
|
|
176
|
+
openUrl: (url: string) => void;
|
|
177
|
+
fetchImpl?: FetchLike;
|
|
178
|
+
scope?: string;
|
|
179
|
+
timeoutMs?: number;
|
|
180
|
+
pkce?: PkcePair;
|
|
181
|
+
state?: string;
|
|
182
|
+
},
|
|
183
|
+
): Promise<DeviceTokens> {
|
|
184
|
+
const callback = await listenForCallback(state, timeoutMs);
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
const url = authorizationUrl(client, {
|
|
188
|
+
redirectUri: callback.redirectUri,
|
|
189
|
+
state,
|
|
190
|
+
challenge: pkce.challenge,
|
|
191
|
+
scope,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
await checkAuthorizationRequest(client, url, fetchImpl);
|
|
195
|
+
openUrl(url);
|
|
196
|
+
|
|
197
|
+
const code = await callback.code;
|
|
198
|
+
|
|
199
|
+
return await exchangeCode(client, { code, redirectUri: callback.redirectUri, verifier: pkce.verifier }, fetchImpl);
|
|
200
|
+
} finally {
|
|
201
|
+
callback.close();
|
|
202
|
+
}
|
|
203
|
+
}
|
package/src/browser.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export function hasLocalBrowser(
|
|
4
|
+
env: Record<string, string | undefined> = process.env,
|
|
5
|
+
platform: string = process.platform,
|
|
6
|
+
): boolean {
|
|
7
|
+
if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) return false;
|
|
8
|
+
if (platform === "linux") return Boolean(env.DISPLAY || env.WAYLAND_DISPLAY);
|
|
9
|
+
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function browserCommand(url: string, platform: string = process.platform): [string, string[]] {
|
|
14
|
+
if (platform === "darwin") return ["open", [url]];
|
|
15
|
+
if (platform === "win32") return ["rundll32", ["url.dll,FileProtocolHandler", url]];
|
|
16
|
+
|
|
17
|
+
return ["xdg-open", [url]];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Best effort: the URL is always printed too, so a browser that fails to open costs nothing.
|
|
21
|
+
export function openInBrowser(url: string, platform: string = process.platform): void {
|
|
22
|
+
const [command, args] = browserCommand(url, platform);
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
26
|
+
|
|
27
|
+
child.on("error", () => undefined);
|
|
28
|
+
child.unref();
|
|
29
|
+
} catch {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import type { PublicClient } from "./device-login.js";
|
|
7
|
+
import { describeError } from "./http.js";
|
|
8
|
+
|
|
9
|
+
// Only the refresh token is kept: an access token lives five minutes, so a run mints its own.
|
|
10
|
+
export type StoredLogin = { refreshToken: string; username?: string; savedAt?: number };
|
|
11
|
+
|
|
12
|
+
export type CredentialStore = {
|
|
13
|
+
read: (client: PublicClient) => Promise<StoredLogin | undefined>;
|
|
14
|
+
write: (client: PublicClient, login: StoredLogin) => Promise<void>;
|
|
15
|
+
remove: (client: PublicClient) => Promise<void>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// One entry per Keycloak, realm and client, so staging, production and another realm never share a login.
|
|
19
|
+
export function loginKeyId({ baseUrl, realm, clientId }: PublicClient): string {
|
|
20
|
+
return `${baseUrl.replace(/\/+$/, "")} ${realm} ${clientId}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function configDir(env: Record<string, string | undefined> = process.env): string {
|
|
24
|
+
return env.MANTA_CONFIG_DIR || join(homedir(), ".manta");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function credentialsFilePath(env: Record<string, string | undefined> = process.env): string {
|
|
28
|
+
return join(configDir(env), "credentials.json");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isStoredLogin(value: unknown): value is StoredLogin {
|
|
32
|
+
if (typeof value !== "object" || value === null) return false;
|
|
33
|
+
|
|
34
|
+
const { refreshToken, username, savedAt } = value as Record<string, unknown>;
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
typeof refreshToken === "string" &&
|
|
38
|
+
(username === undefined || typeof username === "string") &&
|
|
39
|
+
(savedAt === undefined || typeof savedAt === "number")
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type CredentialsFile = { logins: Record<string, StoredLogin> };
|
|
44
|
+
|
|
45
|
+
function isMissingFile(error: unknown): boolean {
|
|
46
|
+
return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function readCredentialsFile(path: string): Promise<CredentialsFile> {
|
|
50
|
+
let text: string;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
text = await readFile(path, "utf8");
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (isMissingFile(error)) return { logins: {} };
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Rewriting a file it could not read would drop every other login in it.
|
|
60
|
+
const unreadable = new Error(`${path} is not a credentials file. Delete it and log in again.`);
|
|
61
|
+
let parsed: unknown;
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
parsed = JSON.parse(text);
|
|
65
|
+
} catch {
|
|
66
|
+
throw unreadable;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const logins = (parsed as { logins?: unknown } | null)?.logins;
|
|
70
|
+
if (typeof logins !== "object" || logins === null) throw unreadable;
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
logins: Object.fromEntries(
|
|
74
|
+
Object.entries(logins).filter((entry): entry is [string, StoredLogin] => isStoredLogin(entry[1])),
|
|
75
|
+
),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Written to a sibling and renamed, so a crash never leaves half a file behind.
|
|
80
|
+
async function writeCredentialsFile(path: string, file: CredentialsFile): Promise<void> {
|
|
81
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
82
|
+
|
|
83
|
+
const sibling = `${path}.${randomBytes(6).toString("hex")}.tmp`;
|
|
84
|
+
|
|
85
|
+
await writeFile(sibling, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
|
|
86
|
+
await rename(sibling, path);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Mode 0600 on macOS and Linux; on Windows the user profile's own permissions restrict it.
|
|
90
|
+
export function fileCredentialStore(path: string = credentialsFilePath()): CredentialStore {
|
|
91
|
+
return {
|
|
92
|
+
async read(client) {
|
|
93
|
+
return (await readCredentialsFile(path)).logins[loginKeyId(client)];
|
|
94
|
+
},
|
|
95
|
+
async write(client, login) {
|
|
96
|
+
const file = await readCredentialsFile(path);
|
|
97
|
+
|
|
98
|
+
await writeCredentialsFile(path, { logins: { ...file.logins, [loginKeyId(client)]: login } });
|
|
99
|
+
},
|
|
100
|
+
async remove(client) {
|
|
101
|
+
const file = await readCredentialsFile(path);
|
|
102
|
+
const id = loginKeyId(client);
|
|
103
|
+
|
|
104
|
+
if (!(id in file.logins)) return;
|
|
105
|
+
|
|
106
|
+
const { [id]: _removed, ...rest } = file.logins;
|
|
107
|
+
await writeCredentialsFile(path, { logins: rest });
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type SecurityResult = { code: number; stdout: string };
|
|
113
|
+
|
|
114
|
+
export type RunSecurity = (args: string[], stdin?: string) => Promise<SecurityResult>;
|
|
115
|
+
|
|
116
|
+
export const keychainService = "manta-cli";
|
|
117
|
+
|
|
118
|
+
export const runSecurity: RunSecurity = (args, stdin) =>
|
|
119
|
+
new Promise((resolve) => {
|
|
120
|
+
const child = spawn("security", args, { stdio: ["pipe", "pipe", "ignore"] });
|
|
121
|
+
let stdout = "";
|
|
122
|
+
|
|
123
|
+
child.stdout.on("data", (chunk: Buffer) => (stdout += chunk.toString("utf8")));
|
|
124
|
+
child.on("error", () => resolve({ code: -1, stdout: "" }));
|
|
125
|
+
child.on("close", (code) => resolve({ code: code ?? -1, stdout }));
|
|
126
|
+
child.stdin.end(stdin ?? "");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
function keychainAccount(client: PublicClient): string {
|
|
130
|
+
const account = loginKeyId(client);
|
|
131
|
+
|
|
132
|
+
// `security -i` splits its input like a shell, so a quote would end the account early.
|
|
133
|
+
if (/["\\\n]/.test(account)) throw new Error(`cannot store a login for ${account} in the Keychain.`);
|
|
134
|
+
|
|
135
|
+
return account;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function decodeKeychainSecret(stdout: string): StoredLogin | undefined {
|
|
139
|
+
try {
|
|
140
|
+
const parsed: unknown = JSON.parse(Buffer.from(stdout.trim(), "base64").toString("utf8"));
|
|
141
|
+
|
|
142
|
+
return isStoredLogin(parsed) ? parsed : undefined;
|
|
143
|
+
} catch {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The secret goes in on stdin through `security -i`, never as an argument other processes can list.
|
|
149
|
+
export function keychainCredentialStore(run: RunSecurity = runSecurity): CredentialStore {
|
|
150
|
+
async function read(client: PublicClient): Promise<StoredLogin | undefined> {
|
|
151
|
+
const found = await run(["find-generic-password", "-s", keychainService, "-a", keychainAccount(client), "-w"]);
|
|
152
|
+
|
|
153
|
+
return found.code === 0 ? decodeKeychainSecret(found.stdout) : undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
read,
|
|
158
|
+
async write(client, login) {
|
|
159
|
+
const secret = Buffer.from(JSON.stringify(login)).toString("base64");
|
|
160
|
+
const command = `add-generic-password -U -s ${keychainService} -a "${keychainAccount(client)}" -w ${secret}\n`;
|
|
161
|
+
|
|
162
|
+
await run(["-i"], command);
|
|
163
|
+
|
|
164
|
+
// `security -i` does not report a failed inner command in its exit code, so the write is read back.
|
|
165
|
+
if ((await read(client))?.refreshToken !== login.refreshToken) {
|
|
166
|
+
throw new Error("the Keychain refused the login.");
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
async remove(client) {
|
|
170
|
+
await run(["delete-generic-password", "-s", keychainService, "-a", keychainAccount(client)]);
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// The Keychain refuses a write while locked, e.g. over SSH, or past `security -i`'s ~4 KB line, so the file takes over.
|
|
176
|
+
export function keychainWithFileFallback(
|
|
177
|
+
keychain: CredentialStore,
|
|
178
|
+
file: CredentialStore,
|
|
179
|
+
{ clock = Date.now, warn = () => undefined }: { clock?: () => number; warn?: (reason: string) => void } = {},
|
|
180
|
+
): CredentialStore {
|
|
181
|
+
return {
|
|
182
|
+
async read(client) {
|
|
183
|
+
const [fromFile, fromKeychain] = await Promise.all([
|
|
184
|
+
file.read(client).catch(() => undefined),
|
|
185
|
+
keychain.read(client).catch(() => undefined),
|
|
186
|
+
]);
|
|
187
|
+
|
|
188
|
+
// Either copy can be stale: the Keychain's after it refused a write, the file's after a failed remove.
|
|
189
|
+
return (fromKeychain?.savedAt ?? 0) > (fromFile?.savedAt ?? 0) ? fromKeychain : (fromFile ?? fromKeychain);
|
|
190
|
+
},
|
|
191
|
+
async write(client, login) {
|
|
192
|
+
const stamped = { ...login, savedAt: clock() };
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
await keychain.write(client, stamped);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
const movesToFile = !(await file.read(client).catch(() => undefined));
|
|
198
|
+
|
|
199
|
+
await file.write(client, stamped);
|
|
200
|
+
if (movesToFile) warn(describeError(error));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
await file.remove(client).catch(() => undefined);
|
|
205
|
+
},
|
|
206
|
+
async remove(client) {
|
|
207
|
+
await keychain.remove(client).catch(() => undefined);
|
|
208
|
+
await file.remove(client);
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function defaultCredentialStore({
|
|
214
|
+
platform = process.platform,
|
|
215
|
+
warn = console.error,
|
|
216
|
+
}: { platform?: string; warn?: (message: string) => void } = {}): CredentialStore {
|
|
217
|
+
const path = credentialsFilePath();
|
|
218
|
+
const file = fileCredentialStore(path);
|
|
219
|
+
|
|
220
|
+
if (platform !== "darwin") return file;
|
|
221
|
+
|
|
222
|
+
return keychainWithFileFallback(keychainCredentialStore(), file, {
|
|
223
|
+
warn: (reason) => warn(`${reason} Stored it in ${path} instead.`),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
@@ -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
|
+
}
|