@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.
- package/README.md +98 -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/custom-app-user-token.d.ts +33 -0
- package/dist/custom-app-user-token.d.ts.map +1 -0
- package/dist/custom-app-user-token.js +56 -0
- package/dist/custom-app-user-token.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 +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -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 +36 -11
- 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 +9 -0
- package/dist/web.d.ts.map +1 -0
- package/dist/web.js +8 -0
- package/dist/web.js.map +1 -0
- package/package.json +15 -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/custom-app-user-token.ts +100 -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 +67 -0
- package/src/oidc.ts +40 -0
- package/src/stored-login.ts +63 -0
- package/src/token.ts +154 -0
- package/src/web-login.ts +106 -0
- package/src/web-routes.ts +321 -0
- package/src/web.ts +32 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { browserLogin, loginScope } from "./browser-login.js";
|
|
3
|
+
import { hasLocalBrowser, openInBrowser } from "./browser.js";
|
|
4
|
+
import { type CredentialStore, defaultCredentialStore } from "./credential-store.js";
|
|
5
|
+
import { defaultDeployment } from "./default-deployment.js";
|
|
6
|
+
import {
|
|
7
|
+
createPkcePair,
|
|
8
|
+
type DeviceTokens,
|
|
9
|
+
pollForDeviceTokens,
|
|
10
|
+
type PublicClient,
|
|
11
|
+
startDeviceLogin,
|
|
12
|
+
} from "./device-login.js";
|
|
13
|
+
import { describeError, type FetchLike, formRequest, postTo } from "./http.js";
|
|
14
|
+
import { saveLogin, storedLoginTokenSource } from "./stored-login.js";
|
|
15
|
+
import { accessTokenUsername, type RenewableTokenSource } from "./token.js";
|
|
16
|
+
|
|
17
|
+
export type ClientOptions = { keycloakUrl: string; keycloakRealm: string; clientId?: string };
|
|
18
|
+
|
|
19
|
+
export function cliClientId(realm: string): string {
|
|
20
|
+
return `${realm}-cli`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The confidential client a CI job authenticates with through MANTA_CLIENT_SECRET.
|
|
24
|
+
export const ciClientId = "manta-codegen";
|
|
25
|
+
|
|
26
|
+
export const clientIdHelp = `Keycloak client id (default: <realm>-cli, or ${ciClientId} with MANTA_CLIENT_SECRET)`;
|
|
27
|
+
|
|
28
|
+
export type LoginOptions = { useDeviceCode?: boolean };
|
|
29
|
+
|
|
30
|
+
export function publicClientFrom({ keycloakUrl, keycloakRealm, clientId }: ClientOptions): PublicClient {
|
|
31
|
+
return {
|
|
32
|
+
baseUrl: keycloakUrl.replace(/\/+$/, ""),
|
|
33
|
+
realm: keycloakRealm,
|
|
34
|
+
clientId: clientId ?? cliClientId(keycloakRealm),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function warnAs(label: string): (message: string) => void {
|
|
39
|
+
return (message) => console.error(`${label}: ${message}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function where(client: PublicClient): string {
|
|
43
|
+
return `${client.realm} at ${client.baseUrl}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function interactiveLogin(
|
|
47
|
+
client: PublicClient,
|
|
48
|
+
{
|
|
49
|
+
label,
|
|
50
|
+
useDeviceCode = false,
|
|
51
|
+
fetchImpl = fetch,
|
|
52
|
+
}: { label: string; useDeviceCode?: boolean; fetchImpl?: FetchLike },
|
|
53
|
+
): Promise<DeviceTokens> {
|
|
54
|
+
const localBrowser = hasLocalBrowser();
|
|
55
|
+
|
|
56
|
+
if (useDeviceCode || !localBrowser) {
|
|
57
|
+
const login = await startDeviceLogin(client, fetchImpl, createPkcePair(), loginScope);
|
|
58
|
+
|
|
59
|
+
console.error(`${label}: open ${login.verificationUri} and enter the code ${login.userCode}`);
|
|
60
|
+
if (localBrowser) openInBrowser(login.verificationUri);
|
|
61
|
+
|
|
62
|
+
return pollForDeviceTokens(client, login, { fetchImpl });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return browserLogin(client, {
|
|
66
|
+
fetchImpl,
|
|
67
|
+
openUrl: (url) => {
|
|
68
|
+
console.error(`${label}: log in with the browser that opened. If none did, open ${url}`);
|
|
69
|
+
openInBrowser(url);
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function reportLogin(label: string, client: PublicClient, tokens: DeviceTokens, saved: boolean): void {
|
|
75
|
+
const username = accessTokenUsername(tokens.accessToken);
|
|
76
|
+
|
|
77
|
+
console.error(`${label}: logged in to ${where(client)}${username ? ` as ${username}` : ""}`);
|
|
78
|
+
if (!saved) console.error(`${label}: Keycloak sent no refresh token, so the next run logs in again.`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function notLoggedIn(label: string, client: PublicClient): Error {
|
|
82
|
+
const flags = [
|
|
83
|
+
client.baseUrl === defaultDeployment.keycloakUrl ? "" : ` --keycloak-url ${client.baseUrl}`,
|
|
84
|
+
client.realm === defaultDeployment.keycloakRealm ? "" : ` --keycloak-realm ${client.realm}`,
|
|
85
|
+
client.clientId === cliClientId(client.realm) ? "" : ` --client-id ${client.clientId}`,
|
|
86
|
+
].join("");
|
|
87
|
+
|
|
88
|
+
return new Error(`not logged in to ${where(client)}. Run: ${label} login${flags}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A run without a terminal cannot answer a browser, so it names the command to run instead of waiting.
|
|
92
|
+
export function userTokenSource(
|
|
93
|
+
client: PublicClient,
|
|
94
|
+
{
|
|
95
|
+
label,
|
|
96
|
+
useDeviceCode,
|
|
97
|
+
store = defaultCredentialStore({ warn: warnAs(label) }),
|
|
98
|
+
interactive = Boolean(process.stdin.isTTY),
|
|
99
|
+
fetchImpl = fetch,
|
|
100
|
+
}: {
|
|
101
|
+
label: string;
|
|
102
|
+
useDeviceCode?: boolean;
|
|
103
|
+
store?: CredentialStore;
|
|
104
|
+
interactive?: boolean;
|
|
105
|
+
fetchImpl?: FetchLike;
|
|
106
|
+
},
|
|
107
|
+
): RenewableTokenSource {
|
|
108
|
+
return storedLoginTokenSource({
|
|
109
|
+
client,
|
|
110
|
+
store,
|
|
111
|
+
fetchImpl,
|
|
112
|
+
warn: warnAs(label),
|
|
113
|
+
login: async () => {
|
|
114
|
+
if (!interactive) throw notLoggedIn(label, client);
|
|
115
|
+
|
|
116
|
+
const tokens = await interactiveLogin(client, { label, useDeviceCode, fetchImpl });
|
|
117
|
+
reportLogin(label, client, tokens, Boolean(tokens.refreshToken));
|
|
118
|
+
|
|
119
|
+
return tokens;
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function revocationEndpoint(baseUrl: string, realm: string): string {
|
|
125
|
+
return `${baseUrl}/realms/${realm}/protocol/openid-connect/revoke`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Revoking the refresh token ends its offline session in Keycloak, not just this machine's copy.
|
|
129
|
+
export async function revokeRefreshToken(
|
|
130
|
+
client: PublicClient,
|
|
131
|
+
refreshToken: string,
|
|
132
|
+
fetchImpl: FetchLike = fetch,
|
|
133
|
+
): Promise<boolean> {
|
|
134
|
+
const request = formRequest({ token: refreshToken, token_type_hint: "refresh_token", client_id: client.clientId });
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
return (await postTo(revocationEndpoint(client.baseUrl, client.realm), request, fetchImpl)).ok;
|
|
138
|
+
} catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function logIn(
|
|
144
|
+
client: PublicClient,
|
|
145
|
+
{
|
|
146
|
+
label,
|
|
147
|
+
useDeviceCode,
|
|
148
|
+
store,
|
|
149
|
+
fetchImpl = fetch,
|
|
150
|
+
}: { label: string; useDeviceCode?: boolean; store: CredentialStore; fetchImpl?: FetchLike },
|
|
151
|
+
): Promise<void> {
|
|
152
|
+
const tokens = await interactiveLogin(client, { label, useDeviceCode, fetchImpl });
|
|
153
|
+
|
|
154
|
+
reportLogin(label, client, tokens, await saveLogin(store, client, tokens));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// The local copy goes even when Keycloak is unreachable, since a login the user asked to end must not linger here.
|
|
158
|
+
export async function logOut(
|
|
159
|
+
client: PublicClient,
|
|
160
|
+
{ label, store, fetchImpl = fetch }: { label: string; store: CredentialStore; fetchImpl?: FetchLike },
|
|
161
|
+
): Promise<void> {
|
|
162
|
+
const stored = await store.read(client);
|
|
163
|
+
|
|
164
|
+
if (!stored) {
|
|
165
|
+
console.error(`${label}: not logged in to ${where(client)}`);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const revoked = await revokeRefreshToken(client, stored.refreshToken, fetchImpl);
|
|
170
|
+
await store.remove(client);
|
|
171
|
+
|
|
172
|
+
console.error(
|
|
173
|
+
revoked
|
|
174
|
+
? `${label}: logged out of ${where(client)}`
|
|
175
|
+
: `${label}: deleted the stored login for ${where(client)}, but Keycloak did not confirm the revocation.`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Reads the store only, so it answers offline; the next run finds out whether Keycloak still accepts the login.
|
|
180
|
+
export async function loginStatus(
|
|
181
|
+
client: PublicClient,
|
|
182
|
+
{ store }: { store: CredentialStore },
|
|
183
|
+
): Promise<{ loggedIn: boolean; line: string }> {
|
|
184
|
+
const stored = await store.read(client);
|
|
185
|
+
|
|
186
|
+
if (!stored) return { loggedIn: false, line: `not logged in to ${where(client)}` };
|
|
187
|
+
|
|
188
|
+
return { loggedIn: true, line: `logged in to ${where(client)}${stored.username ? ` as ${stored.username}` : ""}` };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function clientCommand(program: Command, name: string, description: string): Command {
|
|
192
|
+
return program
|
|
193
|
+
.command(name)
|
|
194
|
+
.description(description)
|
|
195
|
+
.option("--keycloak-url <url>", "Keycloak base URL", defaultDeployment.keycloakUrl)
|
|
196
|
+
.option("--keycloak-realm <realm>", "Keycloak realm", defaultDeployment.keycloakRealm)
|
|
197
|
+
.option("--client-id <id>", "Keycloak client id (default: <realm>-cli)");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function reportingFailure(label: string, action: () => Promise<void>): Promise<void> {
|
|
201
|
+
try {
|
|
202
|
+
await action();
|
|
203
|
+
} catch (error) {
|
|
204
|
+
console.error(`${label}: ${describeError(error)}`);
|
|
205
|
+
process.exitCode = 1;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Both CLIs register the same commands over the same store, so one login serves both.
|
|
210
|
+
export function registerAuthCommands(
|
|
211
|
+
program: Command,
|
|
212
|
+
{
|
|
213
|
+
label,
|
|
214
|
+
store = defaultCredentialStore({ warn: warnAs(label) }),
|
|
215
|
+
fetchImpl = fetch,
|
|
216
|
+
}: { label: string; store?: CredentialStore; fetchImpl?: FetchLike },
|
|
217
|
+
): void {
|
|
218
|
+
clientCommand(program, "login", "log in once; later runs renew the stored login without a browser")
|
|
219
|
+
.option("--use-device-code", "log in with a one-time code instead of a browser redirect, e.g. over SSH")
|
|
220
|
+
.action((options: ClientOptions & LoginOptions) =>
|
|
221
|
+
reportingFailure(label, () =>
|
|
222
|
+
logIn(publicClientFrom(options), { label, useDeviceCode: options.useDeviceCode, store, fetchImpl }),
|
|
223
|
+
),
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
clientCommand(program, "logout", "revoke the stored login at Keycloak and delete it").action(
|
|
227
|
+
(options: ClientOptions) =>
|
|
228
|
+
reportingFailure(label, () => logOut(publicClientFrom(options), { label, store, fetchImpl })),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
clientCommand(program, "status", "show whether a login is stored").action((options: ClientOptions) =>
|
|
232
|
+
reportingFailure(label, async () => {
|
|
233
|
+
const { loggedIn, line } = await loginStatus(publicClientFrom(options), { store });
|
|
234
|
+
|
|
235
|
+
console.log(line);
|
|
236
|
+
if (!loggedIn) process.exitCode = 1;
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
}
|
|
@@ -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
|
+
}
|