@manta-eu/auth 0.1.2
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/LICENSE +21 -0
- package/README.md +63 -0
- package/dist/auth-commands.d.ts +56 -0
- package/dist/auth-commands.d.ts.map +1 -0
- package/dist/auth-commands.js +137 -0
- package/dist/browser-login.d.ts +32 -0
- package/dist/browser-login.d.ts.map +1 -0
- package/dist/browser-login.js +129 -0
- package/dist/browser.d.ts +4 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +27 -0
- package/dist/credential-store.d.ts +32 -0
- package/dist/credential-store.d.ts.map +1 -0
- package/dist/credential-store.js +167 -0
- package/dist/device-login.d.ts +36 -0
- package/dist/device-login.d.ts.map +1 -0
- package/dist/device-login.js +109 -0
- package/dist/http.d.ts +11 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +40 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/stored-login.d.ts +14 -0
- package/dist/stored-login.d.ts.map +1 -0
- package/dist/stored-login.js +35 -0
- package/dist/token.d.ts +22 -0
- package/dist/token.d.ts.map +1 -0
- package/dist/token.js +90 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Manta AI AS
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# @manta-eu/auth
|
|
2
|
+
|
|
3
|
+
Keycloak login for the SDK CLIs (`@manta-eu/codegen`, `@manta-eu/upload`): browser and device-code
|
|
4
|
+
login, silent token renewal, and credential storage. It knows nothing about either CLI's own
|
|
5
|
+
purpose — it only mints and renews tokens, and gives each CLI `login`/`logout`/`status` commands to
|
|
6
|
+
register on its own `commander` program.
|
|
7
|
+
|
|
8
|
+
## Log in
|
|
9
|
+
|
|
10
|
+
`MANTA_CLIENT_SECRET` decides the path.
|
|
11
|
+
|
|
12
|
+
**No secret — a person.** `userTokenSource()` builds a token source that logs in once and renews
|
|
13
|
+
silently after that:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { publicClientFrom, userTokenSource } from "@manta-eu/auth";
|
|
17
|
+
|
|
18
|
+
const token = userTokenSource(publicClientFrom({ keycloakUrl, keycloakRealm }), { label: "my-cli" });
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
It opens the browser and listens on `127.0.0.1` for Keycloak to hand the login back (the
|
|
22
|
+
authorization code flow with PKCE). Over SSH, or on Linux without a display, it prints a link and a
|
|
23
|
+
code for a browser on another machine instead; `useDeviceCode: true` asks for that anywhere. The
|
|
24
|
+
token is the person's, so a caller reads only what that person can open.
|
|
25
|
+
|
|
26
|
+
The login is kept, so later runs renew it silently. It asks for `offline_access`, so it lasts until
|
|
27
|
+
it goes unused for the realm's offline idle limit (30 days by default) or the person logs out.
|
|
28
|
+
`userTokenSource` logs in by itself when nothing is stored and a terminal is attached; without one it
|
|
29
|
+
throws and names the `login` command instead of waiting for a browser.
|
|
30
|
+
|
|
31
|
+
`registerAuthCommands(program, { label })` wires `login`, `status` and `logout` into a `commander`
|
|
32
|
+
program, all reading and writing the same store:
|
|
33
|
+
|
|
34
|
+
| Command | What it does |
|
|
35
|
+
| -------- | ------------------------------------------------------------------- |
|
|
36
|
+
| `login` | Logs in and stores the login. |
|
|
37
|
+
| `status` | Says whether a login is stored, and for whom. Exits 1 when none is. |
|
|
38
|
+
| `logout` | Revokes the login at Keycloak and deletes the stored copy. |
|
|
39
|
+
|
|
40
|
+
Each takes the same `--keycloak-url` and `--keycloak-realm`, because a login belongs to one
|
|
41
|
+
Keycloak, realm and client. The client defaults to the realm's `<realm>-cli`, a public client made
|
|
42
|
+
for this; `--client-id` is only needed for another one. Every CLI that registers these commands
|
|
43
|
+
reads and writes the same store, so logging in with one serves all of them.
|
|
44
|
+
|
|
45
|
+
The login lives in the macOS Keychain (service `manta-cli`), or in `~/.manta/credentials.json` with
|
|
46
|
+
mode `0600` on Linux, on Windows, and on a Mac whose Keychain refuses it: while locked, e.g. over
|
|
47
|
+
SSH, or for a refresh token over about 2.9 KB, the most `security` takes in one line. The CLI says
|
|
48
|
+
on stderr when the file takes over. Only the refresh token is kept. `MANTA_CONFIG_DIR` moves the file.
|
|
49
|
+
|
|
50
|
+
**A secret — a machine.** `mintWithClientCredentials()` asks for a service account token directly,
|
|
51
|
+
with no stored login involved:
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { mintWithClientCredentials } from "@manta-eu/auth";
|
|
55
|
+
|
|
56
|
+
const accessToken = await mintWithClientCredentials({ baseUrl, realm, clientId, clientSecret, scope });
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Renewing on refusal
|
|
60
|
+
|
|
61
|
+
`withRenewal(token, call)` calls `call` with a fresh access token, and on a 401 specifically calls
|
|
62
|
+
the token source's `renew()` and retries once — the same pattern Claude Code uses for MCP OAuth. Wrap
|
|
63
|
+
any authenticated request with it instead of minting a token by hand.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import { type CredentialStore } from "./credential-store.js";
|
|
3
|
+
import { type DeviceTokens, type PublicClient } from "./device-login.js";
|
|
4
|
+
import { type FetchLike } from "./http.js";
|
|
5
|
+
import { type RenewableTokenSource } from "./token.js";
|
|
6
|
+
export type ClientOptions = {
|
|
7
|
+
keycloakUrl: string;
|
|
8
|
+
keycloakRealm: string;
|
|
9
|
+
clientId?: string;
|
|
10
|
+
};
|
|
11
|
+
export declare function cliClientId(realm: string): string;
|
|
12
|
+
export declare const ciClientId = "manta-codegen";
|
|
13
|
+
export declare const clientIdHelp = "Keycloak client id (default: <realm>-cli, or manta-codegen with MANTA_CLIENT_SECRET)";
|
|
14
|
+
export type LoginOptions = {
|
|
15
|
+
useDeviceCode?: boolean;
|
|
16
|
+
};
|
|
17
|
+
export declare function publicClientFrom({ keycloakUrl, keycloakRealm, clientId }: ClientOptions): PublicClient;
|
|
18
|
+
export declare function interactiveLogin(client: PublicClient, { label, useDeviceCode, fetchImpl, }: {
|
|
19
|
+
label: string;
|
|
20
|
+
useDeviceCode?: boolean;
|
|
21
|
+
fetchImpl?: FetchLike;
|
|
22
|
+
}): Promise<DeviceTokens>;
|
|
23
|
+
export declare function reportLogin(label: string, client: PublicClient, tokens: DeviceTokens, saved: boolean): void;
|
|
24
|
+
export declare function notLoggedIn(label: string, client: PublicClient): Error;
|
|
25
|
+
export declare function userTokenSource(client: PublicClient, { label, useDeviceCode, store, interactive, fetchImpl, }: {
|
|
26
|
+
label: string;
|
|
27
|
+
useDeviceCode?: boolean;
|
|
28
|
+
store?: CredentialStore;
|
|
29
|
+
interactive?: boolean;
|
|
30
|
+
fetchImpl?: FetchLike;
|
|
31
|
+
}): RenewableTokenSource;
|
|
32
|
+
export declare function revocationEndpoint(baseUrl: string, realm: string): string;
|
|
33
|
+
export declare function revokeRefreshToken(client: PublicClient, refreshToken: string, fetchImpl?: FetchLike): Promise<boolean>;
|
|
34
|
+
export declare function logIn(client: PublicClient, { label, useDeviceCode, store, fetchImpl, }: {
|
|
35
|
+
label: string;
|
|
36
|
+
useDeviceCode?: boolean;
|
|
37
|
+
store: CredentialStore;
|
|
38
|
+
fetchImpl?: FetchLike;
|
|
39
|
+
}): Promise<void>;
|
|
40
|
+
export declare function logOut(client: PublicClient, { label, store, fetchImpl }: {
|
|
41
|
+
label: string;
|
|
42
|
+
store: CredentialStore;
|
|
43
|
+
fetchImpl?: FetchLike;
|
|
44
|
+
}): Promise<void>;
|
|
45
|
+
export declare function loginStatus(client: PublicClient, { store }: {
|
|
46
|
+
store: CredentialStore;
|
|
47
|
+
}): Promise<{
|
|
48
|
+
loggedIn: boolean;
|
|
49
|
+
line: string;
|
|
50
|
+
}>;
|
|
51
|
+
export declare function registerAuthCommands(program: Command, { label, store, fetchImpl, }: {
|
|
52
|
+
label: string;
|
|
53
|
+
store?: CredentialStore;
|
|
54
|
+
fetchImpl?: FetchLike;
|
|
55
|
+
}): void;
|
|
56
|
+
//# sourceMappingURL=auth-commands.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth-commands.d.ts","sourceRoot":"","sources":["../src/auth-commands.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAGzC,OAAO,EAAE,KAAK,eAAe,EAA0B,MAAM,uBAAuB,CAAC;AACrF,OAAO,EAEL,KAAK,YAAY,EAEjB,KAAK,YAAY,EAElB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAiB,KAAK,SAAS,EAAuB,MAAM,WAAW,CAAC;AAE/E,OAAO,EAAuB,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAE5E,MAAM,MAAM,aAAa,GAAG;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,aAAa,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAE9F,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEjD;AAGD,eAAO,MAAM,UAAU,kBAAkB,CAAC;AAE1C,eAAO,MAAM,YAAY,yFAAyF,CAAC;AAEnH,MAAM,MAAM,YAAY,GAAG;IAAE,aAAa,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC;AAEvD,wBAAgB,gBAAgB,CAAC,EAAE,WAAW,EAAE,aAAa,EAAE,QAAQ,EAAE,EAAE,aAAa,GAAG,YAAY,CAMtG;AAUD,wBAAsB,gBAAgB,CACpC,MAAM,EAAE,YAAY,EACpB,EACE,KAAK,EACL,aAAqB,EACrB,SAAiB,GAClB,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,aAAa,CAAC,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,SAAS,CAAA;CAAE,GACnE,OAAO,CAAC,YAAY,CAAC,CAmBvB;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,YAAY,EAAE,KAAK,EAAE,OAAO,GAAG,IAAI,CAK3G;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,YAAY,GAAG,KAAK,CAOtE;AAGD,wBAAgB,eAAe,CAC7B,MAAM,EAAE,YAAY,EACpB,EACE,KAAK,EACL,aAAa,EACb,KAAuD,EACvD,WAA0C,EAC1C,SAAiB,GAClB,EAAE;IACD,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,KAAK,CAAC,EAAE,eAAe,CAAC;IACxB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,SAAS,CAAC,EAAE,SAAS,CAAC;CACvB,GACA,oBAAoB,CAetB;AAED,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAEzE;AAGD,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,YAAY,EACpB,YAAY,EAAE,MAAM,EACpB,SAAS,GAAE,SAAiB,GAC3B,OAAO,CAAC,OAAO,CAAC,CAQlB;AAED,wBAAsB,KAAK,CACzB,MAAM,EAAE,YAAY,EACpB,EACE,KAAK,EACL,aAAa,EACb,KAAK,EACL,SAAiB,GAClB,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,aAAa,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,eAAe,CAAC;IAAC,SAAS,CAAC,EAAE,SAAS,CAAA;CAAE,GAC3F,OAAO,CAAC,IAAI,CAAC,CAIf;AAGD,wBAAsB,MAAM,CAC1B,MAAM,EAAE,YAAY,EACpB,EAAE,KAAK,EAAE,KAAK,EAAE,SAAiB,EAAE,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,eAAe,CAAC;IAAC,SAAS,CAAC,EAAE,SAAS,CAAA;CAAE,GACpG,OAAO,CAAC,IAAI,CAAC,CAgBf;AAGD,wBAAsB,WAAW,CAC/B,MAAM,EAAE,YAAY,EACpB,EAAE,KAAK,EAAE,EAAE;IAAE,KAAK,EAAE,eAAe,CAAA;CAAE,GACpC,OAAO,CAAC;IAAE,QAAQ,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAM9C;AAqBD,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,OAAO,EAChB,EACE,KAAK,EACL,KAAuD,EACvD,SAAiB,GAClB,EAAE;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,eAAe,CAAC;IAAC,SAAS,CAAC,EAAE,SAAS,CAAA;CAAE,GACnE,IAAI,CAsBN"}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { browserLogin, loginScope } from "./browser-login.js";
|
|
2
|
+
import { hasLocalBrowser, openInBrowser } from "./browser.js";
|
|
3
|
+
import { defaultCredentialStore } from "./credential-store.js";
|
|
4
|
+
import { createPkcePair, pollForDeviceTokens, startDeviceLogin, } from "./device-login.js";
|
|
5
|
+
import { describeError, formRequest, postTo } from "./http.js";
|
|
6
|
+
import { saveLogin, storedLoginTokenSource } from "./stored-login.js";
|
|
7
|
+
import { accessTokenUsername } from "./token.js";
|
|
8
|
+
export function cliClientId(realm) {
|
|
9
|
+
return `${realm}-cli`;
|
|
10
|
+
}
|
|
11
|
+
// The confidential client a CI job authenticates with through MANTA_CLIENT_SECRET.
|
|
12
|
+
export const ciClientId = "manta-codegen";
|
|
13
|
+
export const clientIdHelp = `Keycloak client id (default: <realm>-cli, or ${ciClientId} with MANTA_CLIENT_SECRET)`;
|
|
14
|
+
export function publicClientFrom({ keycloakUrl, keycloakRealm, clientId }) {
|
|
15
|
+
return {
|
|
16
|
+
baseUrl: keycloakUrl.replace(/\/+$/, ""),
|
|
17
|
+
realm: keycloakRealm,
|
|
18
|
+
clientId: clientId ?? cliClientId(keycloakRealm),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function warnAs(label) {
|
|
22
|
+
return (message) => console.error(`${label}: ${message}`);
|
|
23
|
+
}
|
|
24
|
+
function where(client) {
|
|
25
|
+
return `${client.realm} at ${client.baseUrl}`;
|
|
26
|
+
}
|
|
27
|
+
export async function interactiveLogin(client, { label, useDeviceCode = false, fetchImpl = fetch, }) {
|
|
28
|
+
const localBrowser = hasLocalBrowser();
|
|
29
|
+
if (useDeviceCode || !localBrowser) {
|
|
30
|
+
const login = await startDeviceLogin(client, fetchImpl, createPkcePair(), loginScope);
|
|
31
|
+
console.error(`${label}: open ${login.verificationUri} and enter the code ${login.userCode}`);
|
|
32
|
+
if (localBrowser)
|
|
33
|
+
openInBrowser(login.verificationUri);
|
|
34
|
+
return pollForDeviceTokens(client, login, { fetchImpl });
|
|
35
|
+
}
|
|
36
|
+
return browserLogin(client, {
|
|
37
|
+
fetchImpl,
|
|
38
|
+
openUrl: (url) => {
|
|
39
|
+
console.error(`${label}: log in with the browser that opened. If none did, open ${url}`);
|
|
40
|
+
openInBrowser(url);
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
export function reportLogin(label, client, tokens, saved) {
|
|
45
|
+
const username = accessTokenUsername(tokens.accessToken);
|
|
46
|
+
console.error(`${label}: logged in to ${where(client)}${username ? ` as ${username}` : ""}`);
|
|
47
|
+
if (!saved)
|
|
48
|
+
console.error(`${label}: Keycloak sent no refresh token, so the next run logs in again.`);
|
|
49
|
+
}
|
|
50
|
+
export function notLoggedIn(label, client) {
|
|
51
|
+
const clientFlag = client.clientId === cliClientId(client.realm) ? "" : ` --client-id ${client.clientId}`;
|
|
52
|
+
return new Error(`not logged in to ${where(client)}. ` +
|
|
53
|
+
`Run: ${label} login --keycloak-url ${client.baseUrl} --keycloak-realm ${client.realm}${clientFlag}`);
|
|
54
|
+
}
|
|
55
|
+
// A run without a terminal cannot answer a browser, so it names the command to run instead of waiting.
|
|
56
|
+
export function userTokenSource(client, { label, useDeviceCode, store = defaultCredentialStore({ warn: warnAs(label) }), interactive = Boolean(process.stdin.isTTY), fetchImpl = fetch, }) {
|
|
57
|
+
return storedLoginTokenSource({
|
|
58
|
+
client,
|
|
59
|
+
store,
|
|
60
|
+
fetchImpl,
|
|
61
|
+
warn: warnAs(label),
|
|
62
|
+
login: async () => {
|
|
63
|
+
if (!interactive)
|
|
64
|
+
throw notLoggedIn(label, client);
|
|
65
|
+
const tokens = await interactiveLogin(client, { label, useDeviceCode, fetchImpl });
|
|
66
|
+
reportLogin(label, client, tokens, Boolean(tokens.refreshToken));
|
|
67
|
+
return tokens;
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
export function revocationEndpoint(baseUrl, realm) {
|
|
72
|
+
return `${baseUrl}/realms/${realm}/protocol/openid-connect/revoke`;
|
|
73
|
+
}
|
|
74
|
+
// Revoking the refresh token ends its offline session in Keycloak, not just this machine's copy.
|
|
75
|
+
export async function revokeRefreshToken(client, refreshToken, fetchImpl = fetch) {
|
|
76
|
+
const request = formRequest({ token: refreshToken, token_type_hint: "refresh_token", client_id: client.clientId });
|
|
77
|
+
try {
|
|
78
|
+
return (await postTo(revocationEndpoint(client.baseUrl, client.realm), request, fetchImpl)).ok;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export async function logIn(client, { label, useDeviceCode, store, fetchImpl = fetch, }) {
|
|
85
|
+
const tokens = await interactiveLogin(client, { label, useDeviceCode, fetchImpl });
|
|
86
|
+
reportLogin(label, client, tokens, await saveLogin(store, client, tokens));
|
|
87
|
+
}
|
|
88
|
+
// The local copy goes even when Keycloak is unreachable, since a login the user asked to end must not linger here.
|
|
89
|
+
export async function logOut(client, { label, store, fetchImpl = fetch }) {
|
|
90
|
+
const stored = await store.read(client);
|
|
91
|
+
if (!stored) {
|
|
92
|
+
console.error(`${label}: not logged in to ${where(client)}`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const revoked = await revokeRefreshToken(client, stored.refreshToken, fetchImpl);
|
|
96
|
+
await store.remove(client);
|
|
97
|
+
console.error(revoked
|
|
98
|
+
? `${label}: logged out of ${where(client)}`
|
|
99
|
+
: `${label}: deleted the stored login for ${where(client)}, but Keycloak did not confirm the revocation.`);
|
|
100
|
+
}
|
|
101
|
+
// Reads the store only, so it answers offline; the next run finds out whether Keycloak still accepts the login.
|
|
102
|
+
export async function loginStatus(client, { store }) {
|
|
103
|
+
const stored = await store.read(client);
|
|
104
|
+
if (!stored)
|
|
105
|
+
return { loggedIn: false, line: `not logged in to ${where(client)}` };
|
|
106
|
+
return { loggedIn: true, line: `logged in to ${where(client)}${stored.username ? ` as ${stored.username}` : ""}` };
|
|
107
|
+
}
|
|
108
|
+
function clientCommand(program, name, description) {
|
|
109
|
+
return program
|
|
110
|
+
.command(name)
|
|
111
|
+
.description(description)
|
|
112
|
+
.requiredOption("--keycloak-url <url>", "Keycloak base URL")
|
|
113
|
+
.requiredOption("--keycloak-realm <realm>", "Keycloak realm")
|
|
114
|
+
.option("--client-id <id>", "Keycloak client id (default: <realm>-cli)");
|
|
115
|
+
}
|
|
116
|
+
async function reportingFailure(label, action) {
|
|
117
|
+
try {
|
|
118
|
+
await action();
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
console.error(`${label}: ${describeError(error)}`);
|
|
122
|
+
process.exitCode = 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// Both CLIs register the same commands over the same store, so one login serves both.
|
|
126
|
+
export function registerAuthCommands(program, { label, store = defaultCredentialStore({ warn: warnAs(label) }), fetchImpl = fetch, }) {
|
|
127
|
+
clientCommand(program, "login", "log in once; later runs renew the stored login without a browser")
|
|
128
|
+
.option("--use-device-code", "log in with a one-time code instead of a browser redirect, e.g. over SSH")
|
|
129
|
+
.action((options) => reportingFailure(label, () => logIn(publicClientFrom(options), { label, useDeviceCode: options.useDeviceCode, store, fetchImpl })));
|
|
130
|
+
clientCommand(program, "logout", "revoke the stored login at Keycloak and delete it").action((options) => reportingFailure(label, () => logOut(publicClientFrom(options), { label, store, fetchImpl })));
|
|
131
|
+
clientCommand(program, "status", "show whether a login is stored").action((options) => reportingFailure(label, async () => {
|
|
132
|
+
const { loggedIn, line } = await loginStatus(publicClientFrom(options), { store });
|
|
133
|
+
console.log(line);
|
|
134
|
+
if (!loggedIn)
|
|
135
|
+
process.exitCode = 1;
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type DeviceTokens, type PkcePair, type PublicClient } from "./device-login.js";
|
|
2
|
+
import { type FetchLike } from "./http.js";
|
|
3
|
+
export declare const loginScope = "openid offline_access";
|
|
4
|
+
export declare function authorizationEndpoint(baseUrl: string, realm: string): string;
|
|
5
|
+
export declare function authorizationUrl(client: PublicClient, { redirectUri, state, challenge, scope }: {
|
|
6
|
+
redirectUri: string;
|
|
7
|
+
state: string;
|
|
8
|
+
challenge: string;
|
|
9
|
+
scope: string;
|
|
10
|
+
}): string;
|
|
11
|
+
type PendingCallback = {
|
|
12
|
+
redirectUri: string;
|
|
13
|
+
code: Promise<string>;
|
|
14
|
+
close: () => void;
|
|
15
|
+
};
|
|
16
|
+
export declare function listenForCallback(state: string, timeoutMs?: number): Promise<PendingCallback>;
|
|
17
|
+
export declare function exchangeCode(client: PublicClient, { code, redirectUri, verifier }: {
|
|
18
|
+
code: string;
|
|
19
|
+
redirectUri: string;
|
|
20
|
+
verifier: string;
|
|
21
|
+
}, fetchImpl?: FetchLike): Promise<DeviceTokens>;
|
|
22
|
+
export declare function checkAuthorizationRequest(client: PublicClient, url: string, fetchImpl?: FetchLike): Promise<void>;
|
|
23
|
+
export declare function browserLogin(client: PublicClient, { openUrl, fetchImpl, scope, timeoutMs, pkce, state, }: {
|
|
24
|
+
openUrl: (url: string) => void;
|
|
25
|
+
fetchImpl?: FetchLike;
|
|
26
|
+
scope?: string;
|
|
27
|
+
timeoutMs?: number;
|
|
28
|
+
pkce?: PkcePair;
|
|
29
|
+
state?: string;
|
|
30
|
+
}): Promise<DeviceTokens>;
|
|
31
|
+
export {};
|
|
32
|
+
//# sourceMappingURL=browser-login.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"browser-login.d.ts","sourceRoot":"","sources":["../src/browser-login.ts"],"names":[],"mappings":"AAGA,OAAO,EAAkB,KAAK,YAAY,EAAE,KAAK,QAAQ,EAAE,KAAK,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACxG,OAAO,EAAE,KAAK,SAAS,EAAiC,MAAM,WAAW,CAAC;AAI1E,eAAO,MAAM,UAAU,0BAA0B,CAAC;AAMlD,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAE5E;AAED,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,YAAY,EACpB,EAAE,WAAW,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,EAAE;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GACjH,MAAM,CAcR;AAgBD,KAAK,eAAe,GAAG;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC;IAAC,KAAK,EAAE,MAAM,IAAI,CAAA;CAAE,CAAC;AAGzF,wBAAsB,iBAAiB,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,GAAE,MAAyB,GAAG,OAAO,CAAC,eAAe,CAAC,CA+DrH;AAED,wBAAsB,YAAY,CAChC,MAAM,EAAE,YAAY,EACpB,EAAE,IAAI,EAAE,WAAW,EAAE,QAAQ,EAAE,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,EACxF,SAAS,GAAE,SAAiB,GAC3B,OAAO,CAAC,YAAY,CAAC,CAsBvB;AAGD,wBAAsB,yBAAyB,CAC7C,MAAM,EAAE,YAAY,EACpB,GAAG,EAAE,MAAM,EACX,SAAS,GAAE,SAAiB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAUf;AAGD,wBAAsB,YAAY,CAChC,MAAM,EAAE,YAAY,EACpB,EACE,OAAO,EACP,SAAiB,EACjB,KAAkB,EAClB,SAA4B,EAC5B,IAAuB,EACvB,KAA6C,GAC9C,EAAE;IACD,OAAO,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,CAAC,EAAE,QAAQ,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,GACA,OAAO,CAAC,YAAY,CAAC,CAoBvB"}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { createPkcePair } from "./device-login.js";
|
|
4
|
+
import { formRequest, postTo, readJson } from "./http.js";
|
|
5
|
+
import { tokenEndpoint } from "./token.js";
|
|
6
|
+
// offline_access makes the refresh token outlive the browser session: it lasts while it is used.
|
|
7
|
+
export const loginScope = "openid offline_access";
|
|
8
|
+
const callbackPath = "/callback";
|
|
9
|
+
const defaultTimeoutMs = 300_000;
|
|
10
|
+
export function authorizationEndpoint(baseUrl, realm) {
|
|
11
|
+
return `${baseUrl}/realms/${realm}/protocol/openid-connect/auth`;
|
|
12
|
+
}
|
|
13
|
+
export function authorizationUrl(client, { redirectUri, state, challenge, scope }) {
|
|
14
|
+
const url = new URL(authorizationEndpoint(client.baseUrl, client.realm));
|
|
15
|
+
url.search = new URLSearchParams({
|
|
16
|
+
client_id: client.clientId,
|
|
17
|
+
response_type: "code",
|
|
18
|
+
redirect_uri: redirectUri,
|
|
19
|
+
scope,
|
|
20
|
+
state,
|
|
21
|
+
code_challenge: challenge,
|
|
22
|
+
code_challenge_method: "S256",
|
|
23
|
+
}).toString();
|
|
24
|
+
return url.toString();
|
|
25
|
+
}
|
|
26
|
+
function page(title, message) {
|
|
27
|
+
return (`<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${title}</title></head>` +
|
|
28
|
+
`<body style="font-family: system-ui, sans-serif; max-width: 32rem; margin: 20vh auto; text-align: center">` +
|
|
29
|
+
`<h1 style="font-size: 1.25rem">${title}</h1><p>${message}</p></body></html>`);
|
|
30
|
+
}
|
|
31
|
+
function callbackFailure(error, description) {
|
|
32
|
+
return error === "access_denied"
|
|
33
|
+
? new Error("the login was refused in the browser.")
|
|
34
|
+
: new Error(`the browser login failed with "${description ?? error}".`);
|
|
35
|
+
}
|
|
36
|
+
// Keycloak matches a 127.0.0.1 redirect URI whatever its port, so the OS picks a free one.
|
|
37
|
+
export async function listenForCallback(state, timeoutMs = defaultTimeoutMs) {
|
|
38
|
+
let settle;
|
|
39
|
+
const code = new Promise((resolve, reject) => (settle = { resolve, reject }));
|
|
40
|
+
// A timeout can fire after the caller stopped waiting; that must not surface as an unhandled rejection.
|
|
41
|
+
code.catch(() => undefined);
|
|
42
|
+
const server = createServer((request, response) => {
|
|
43
|
+
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
44
|
+
if (url.pathname !== callbackPath) {
|
|
45
|
+
response.writeHead(404).end();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const answer = (status, title, message) => response.writeHead(status, { "content-type": "text/html; charset=utf-8" }).end(page(title, message));
|
|
49
|
+
// A page on another origin can load this URL too; only the state this process sent proves the answer.
|
|
50
|
+
if (url.searchParams.get("state") !== state) {
|
|
51
|
+
answer(400, "Login failed", "This login link does not belong to the command that is waiting. Run it again.");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const error = url.searchParams.get("error");
|
|
55
|
+
const received = url.searchParams.get("code");
|
|
56
|
+
if (error || !received) {
|
|
57
|
+
answer(400, "Login failed", "Return to your terminal for the details.");
|
|
58
|
+
settle?.reject(callbackFailure(error ?? "missing_code", url.searchParams.get("error_description")));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
answer(200, "Logged in to Manta", "You can close this tab and return to your terminal.");
|
|
62
|
+
settle?.resolve(received);
|
|
63
|
+
});
|
|
64
|
+
await new Promise((resolve, reject) => {
|
|
65
|
+
server.once("error", reject);
|
|
66
|
+
server.listen(0, "127.0.0.1", () => {
|
|
67
|
+
server.off("error", reject);
|
|
68
|
+
resolve();
|
|
69
|
+
});
|
|
70
|
+
}).catch((cause) => {
|
|
71
|
+
throw new Error("cannot listen on 127.0.0.1 for the browser login. Use --use-device-code.", {
|
|
72
|
+
cause,
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
const timer = setTimeout(() => settle?.reject(new Error("nobody finished the login in time. Run it again.")), timeoutMs);
|
|
76
|
+
const { port } = server.address();
|
|
77
|
+
return {
|
|
78
|
+
redirectUri: `http://127.0.0.1:${port}${callbackPath}`,
|
|
79
|
+
code,
|
|
80
|
+
close: () => {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
server.close();
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
export async function exchangeCode(client, { code, redirectUri, verifier }, fetchImpl = fetch) {
|
|
87
|
+
const endpoint = tokenEndpoint(client.baseUrl, client.realm);
|
|
88
|
+
const request = formRequest({
|
|
89
|
+
grant_type: "authorization_code",
|
|
90
|
+
code,
|
|
91
|
+
redirect_uri: redirectUri,
|
|
92
|
+
client_id: client.clientId,
|
|
93
|
+
code_verifier: verifier,
|
|
94
|
+
});
|
|
95
|
+
const response = await postTo(endpoint, request, fetchImpl);
|
|
96
|
+
const body = await readJson(response, endpoint);
|
|
97
|
+
if (!response.ok || !body.access_token) {
|
|
98
|
+
throw new Error(`${endpoint} refused the login with status ${response.status}. ${body.error_description ?? ""}`.trim());
|
|
99
|
+
}
|
|
100
|
+
return { accessToken: body.access_token, ...(body.refresh_token ? { refreshToken: body.refresh_token } : {}) };
|
|
101
|
+
}
|
|
102
|
+
// Keycloak answers an unknown client or redirect URI with its own error page, never with a redirect back here.
|
|
103
|
+
export async function checkAuthorizationRequest(client, url, fetchImpl = fetch) {
|
|
104
|
+
const response = await postTo(url, { method: "GET", redirect: "manual" }, fetchImpl);
|
|
105
|
+
await response.body?.cancel().catch(() => undefined);
|
|
106
|
+
if (response.status >= 400) {
|
|
107
|
+
throw new Error(`Keycloak refused to start the login with status ${response.status}. ` +
|
|
108
|
+
`Check that realm ${client.realm} at ${client.baseUrl} has the client ${client.clientId}.`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// The authorization code flow with PKCE and a loopback redirect (RFC 8252), as Claude Code and the AWS CLI log in.
|
|
112
|
+
export async function browserLogin(client, { openUrl, fetchImpl = fetch, scope = loginScope, timeoutMs = defaultTimeoutMs, pkce = createPkcePair(), state = randomBytes(16).toString("base64url"), }) {
|
|
113
|
+
const callback = await listenForCallback(state, timeoutMs);
|
|
114
|
+
try {
|
|
115
|
+
const url = authorizationUrl(client, {
|
|
116
|
+
redirectUri: callback.redirectUri,
|
|
117
|
+
state,
|
|
118
|
+
challenge: pkce.challenge,
|
|
119
|
+
scope,
|
|
120
|
+
});
|
|
121
|
+
await checkAuthorizationRequest(client, url, fetchImpl);
|
|
122
|
+
openUrl(url);
|
|
123
|
+
const code = await callback.code;
|
|
124
|
+
return await exchangeCode(client, { code, redirectUri: callback.redirectUri, verifier: pkce.verifier }, fetchImpl);
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
callback.close();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function hasLocalBrowser(env?: Record<string, string | undefined>, platform?: string): boolean;
|
|
2
|
+
export declare function browserCommand(url: string, platform?: string): [string, string[]];
|
|
3
|
+
export declare function openInBrowser(url: string, platform?: string): void;
|
|
4
|
+
//# sourceMappingURL=browser.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"browser.d.ts","sourceRoot":"","sources":["../src/browser.ts"],"names":[],"mappings":"AAEA,wBAAgB,eAAe,CAC7B,GAAG,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAe,EACrD,QAAQ,GAAE,MAAyB,GAClC,OAAO,CAKT;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,GAAE,MAAyB,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAKnG;AAGD,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,GAAE,MAAyB,GAAG,IAAI,CAWpF"}
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
export function hasLocalBrowser(env = process.env, platform = process.platform) {
|
|
3
|
+
if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY)
|
|
4
|
+
return false;
|
|
5
|
+
if (platform === "linux")
|
|
6
|
+
return Boolean(env.DISPLAY || env.WAYLAND_DISPLAY);
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
export function browserCommand(url, platform = process.platform) {
|
|
10
|
+
if (platform === "darwin")
|
|
11
|
+
return ["open", [url]];
|
|
12
|
+
if (platform === "win32")
|
|
13
|
+
return ["rundll32", ["url.dll,FileProtocolHandler", url]];
|
|
14
|
+
return ["xdg-open", [url]];
|
|
15
|
+
}
|
|
16
|
+
// Best effort: the URL is always printed too, so a browser that fails to open costs nothing.
|
|
17
|
+
export function openInBrowser(url, platform = process.platform) {
|
|
18
|
+
const [command, args] = browserCommand(url, platform);
|
|
19
|
+
try {
|
|
20
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
21
|
+
child.on("error", () => undefined);
|
|
22
|
+
child.unref();
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { PublicClient } from "./device-login.js";
|
|
2
|
+
export type StoredLogin = {
|
|
3
|
+
refreshToken: string;
|
|
4
|
+
username?: string;
|
|
5
|
+
savedAt?: number;
|
|
6
|
+
};
|
|
7
|
+
export type CredentialStore = {
|
|
8
|
+
read: (client: PublicClient) => Promise<StoredLogin | undefined>;
|
|
9
|
+
write: (client: PublicClient, login: StoredLogin) => Promise<void>;
|
|
10
|
+
remove: (client: PublicClient) => Promise<void>;
|
|
11
|
+
};
|
|
12
|
+
export declare function loginKeyId({ baseUrl, realm, clientId }: PublicClient): string;
|
|
13
|
+
export declare function configDir(env?: Record<string, string | undefined>): string;
|
|
14
|
+
export declare function credentialsFilePath(env?: Record<string, string | undefined>): string;
|
|
15
|
+
export declare function fileCredentialStore(path?: string): CredentialStore;
|
|
16
|
+
export type SecurityResult = {
|
|
17
|
+
code: number;
|
|
18
|
+
stdout: string;
|
|
19
|
+
};
|
|
20
|
+
export type RunSecurity = (args: string[], stdin?: string) => Promise<SecurityResult>;
|
|
21
|
+
export declare const keychainService = "manta-cli";
|
|
22
|
+
export declare const runSecurity: RunSecurity;
|
|
23
|
+
export declare function keychainCredentialStore(run?: RunSecurity): CredentialStore;
|
|
24
|
+
export declare function keychainWithFileFallback(keychain: CredentialStore, file: CredentialStore, { clock, warn }?: {
|
|
25
|
+
clock?: () => number;
|
|
26
|
+
warn?: (reason: string) => void;
|
|
27
|
+
}): CredentialStore;
|
|
28
|
+
export declare function defaultCredentialStore({ platform, warn, }?: {
|
|
29
|
+
platform?: string;
|
|
30
|
+
warn?: (message: string) => void;
|
|
31
|
+
}): CredentialStore;
|
|
32
|
+
//# sourceMappingURL=credential-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"credential-store.d.ts","sourceRoot":"","sources":["../src/credential-store.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAItD,MAAM,MAAM,WAAW,GAAG;IAAE,YAAY,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAExF,MAAM,MAAM,eAAe,GAAG;IAC5B,IAAI,EAAE,CAAC,MAAM,EAAE,YAAY,KAAK,OAAO,CAAC,WAAW,GAAG,SAAS,CAAC,CAAC;IACjE,KAAK,EAAE,CAAC,MAAM,EAAE,YAAY,EAAE,KAAK,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACnE,MAAM,EAAE,CAAC,MAAM,EAAE,YAAY,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACjD,CAAC;AAGF,wBAAgB,UAAU,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,YAAY,GAAG,MAAM,CAE7E;AAED,wBAAgB,SAAS,CAAC,GAAG,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAe,GAAG,MAAM,CAEvF;AAED,wBAAgB,mBAAmB,CAAC,GAAG,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAe,GAAG,MAAM,CAEjG;AA6DD,wBAAgB,mBAAmB,CAAC,IAAI,GAAE,MAA8B,GAAG,eAAe,CAoBzF;AAED,MAAM,MAAM,cAAc,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAE9D,MAAM,MAAM,WAAW,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,KAAK,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,cAAc,CAAC,CAAC;AAEtF,eAAO,MAAM,eAAe,cAAc,CAAC;AAE3C,eAAO,MAAM,WAAW,EAAE,WAStB,CAAC;AAsBL,wBAAgB,uBAAuB,CAAC,GAAG,GAAE,WAAyB,GAAG,eAAe,CAwBvF;AAGD,wBAAgB,wBAAwB,CACtC,QAAQ,EAAE,eAAe,EACzB,IAAI,EAAE,eAAe,EACrB,EAAE,KAAgB,EAAE,IAAsB,EAAE,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAA;CAAO,GAC3G,eAAe,CA+BjB;AAED,wBAAgB,sBAAsB,CAAC,EACrC,QAA2B,EAC3B,IAAoB,GACrB,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAA;CAAO,GAAG,eAAe,CAShF"}
|
|
@@ -0,0 +1,167 @@
|
|
|
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 { describeError } from "./http.js";
|
|
7
|
+
// One entry per Keycloak, realm and client, so staging, production and another realm never share a login.
|
|
8
|
+
export function loginKeyId({ baseUrl, realm, clientId }) {
|
|
9
|
+
return `${baseUrl.replace(/\/+$/, "")} ${realm} ${clientId}`;
|
|
10
|
+
}
|
|
11
|
+
export function configDir(env = process.env) {
|
|
12
|
+
return env.MANTA_CONFIG_DIR || join(homedir(), ".manta");
|
|
13
|
+
}
|
|
14
|
+
export function credentialsFilePath(env = process.env) {
|
|
15
|
+
return join(configDir(env), "credentials.json");
|
|
16
|
+
}
|
|
17
|
+
function isStoredLogin(value) {
|
|
18
|
+
if (typeof value !== "object" || value === null)
|
|
19
|
+
return false;
|
|
20
|
+
const { refreshToken, username, savedAt } = value;
|
|
21
|
+
return (typeof refreshToken === "string" &&
|
|
22
|
+
(username === undefined || typeof username === "string") &&
|
|
23
|
+
(savedAt === undefined || typeof savedAt === "number"));
|
|
24
|
+
}
|
|
25
|
+
function isMissingFile(error) {
|
|
26
|
+
return error?.code === "ENOENT";
|
|
27
|
+
}
|
|
28
|
+
async function readCredentialsFile(path) {
|
|
29
|
+
let text;
|
|
30
|
+
try {
|
|
31
|
+
text = await readFile(path, "utf8");
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (isMissingFile(error))
|
|
35
|
+
return { logins: {} };
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
// Rewriting a file it could not read would drop every other login in it.
|
|
39
|
+
const unreadable = new Error(`${path} is not a credentials file. Delete it and log in again.`);
|
|
40
|
+
let parsed;
|
|
41
|
+
try {
|
|
42
|
+
parsed = JSON.parse(text);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
throw unreadable;
|
|
46
|
+
}
|
|
47
|
+
const logins = parsed?.logins;
|
|
48
|
+
if (typeof logins !== "object" || logins === null)
|
|
49
|
+
throw unreadable;
|
|
50
|
+
return {
|
|
51
|
+
logins: Object.fromEntries(Object.entries(logins).filter((entry) => isStoredLogin(entry[1]))),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
// Written to a sibling and renamed, so a crash never leaves half a file behind.
|
|
55
|
+
async function writeCredentialsFile(path, file) {
|
|
56
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
57
|
+
const sibling = `${path}.${randomBytes(6).toString("hex")}.tmp`;
|
|
58
|
+
await writeFile(sibling, `${JSON.stringify(file, null, 2)}\n`, { mode: 0o600 });
|
|
59
|
+
await rename(sibling, path);
|
|
60
|
+
}
|
|
61
|
+
// Mode 0600 on macOS and Linux; on Windows the user profile's own permissions restrict it.
|
|
62
|
+
export function fileCredentialStore(path = credentialsFilePath()) {
|
|
63
|
+
return {
|
|
64
|
+
async read(client) {
|
|
65
|
+
return (await readCredentialsFile(path)).logins[loginKeyId(client)];
|
|
66
|
+
},
|
|
67
|
+
async write(client, login) {
|
|
68
|
+
const file = await readCredentialsFile(path);
|
|
69
|
+
await writeCredentialsFile(path, { logins: { ...file.logins, [loginKeyId(client)]: login } });
|
|
70
|
+
},
|
|
71
|
+
async remove(client) {
|
|
72
|
+
const file = await readCredentialsFile(path);
|
|
73
|
+
const id = loginKeyId(client);
|
|
74
|
+
if (!(id in file.logins))
|
|
75
|
+
return;
|
|
76
|
+
const { [id]: _removed, ...rest } = file.logins;
|
|
77
|
+
await writeCredentialsFile(path, { logins: rest });
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export const keychainService = "manta-cli";
|
|
82
|
+
export const runSecurity = (args, stdin) => new Promise((resolve) => {
|
|
83
|
+
const child = spawn("security", args, { stdio: ["pipe", "pipe", "ignore"] });
|
|
84
|
+
let stdout = "";
|
|
85
|
+
child.stdout.on("data", (chunk) => (stdout += chunk.toString("utf8")));
|
|
86
|
+
child.on("error", () => resolve({ code: -1, stdout: "" }));
|
|
87
|
+
child.on("close", (code) => resolve({ code: code ?? -1, stdout }));
|
|
88
|
+
child.stdin.end(stdin ?? "");
|
|
89
|
+
});
|
|
90
|
+
function keychainAccount(client) {
|
|
91
|
+
const account = loginKeyId(client);
|
|
92
|
+
// `security -i` splits its input like a shell, so a quote would end the account early.
|
|
93
|
+
if (/["\\\n]/.test(account))
|
|
94
|
+
throw new Error(`cannot store a login for ${account} in the Keychain.`);
|
|
95
|
+
return account;
|
|
96
|
+
}
|
|
97
|
+
function decodeKeychainSecret(stdout) {
|
|
98
|
+
try {
|
|
99
|
+
const parsed = JSON.parse(Buffer.from(stdout.trim(), "base64").toString("utf8"));
|
|
100
|
+
return isStoredLogin(parsed) ? parsed : undefined;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// The secret goes in on stdin through `security -i`, never as an argument other processes can list.
|
|
107
|
+
export function keychainCredentialStore(run = runSecurity) {
|
|
108
|
+
async function read(client) {
|
|
109
|
+
const found = await run(["find-generic-password", "-s", keychainService, "-a", keychainAccount(client), "-w"]);
|
|
110
|
+
return found.code === 0 ? decodeKeychainSecret(found.stdout) : undefined;
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
read,
|
|
114
|
+
async write(client, login) {
|
|
115
|
+
const secret = Buffer.from(JSON.stringify(login)).toString("base64");
|
|
116
|
+
const command = `add-generic-password -U -s ${keychainService} -a "${keychainAccount(client)}" -w ${secret}\n`;
|
|
117
|
+
await run(["-i"], command);
|
|
118
|
+
// `security -i` does not report a failed inner command in its exit code, so the write is read back.
|
|
119
|
+
if ((await read(client))?.refreshToken !== login.refreshToken) {
|
|
120
|
+
throw new Error("the Keychain refused the login.");
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
async remove(client) {
|
|
124
|
+
await run(["delete-generic-password", "-s", keychainService, "-a", keychainAccount(client)]);
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
// The Keychain refuses a write while locked, e.g. over SSH, or past `security -i`'s ~4 KB line, so the file takes over.
|
|
129
|
+
export function keychainWithFileFallback(keychain, file, { clock = Date.now, warn = () => undefined } = {}) {
|
|
130
|
+
return {
|
|
131
|
+
async read(client) {
|
|
132
|
+
const [fromFile, fromKeychain] = await Promise.all([
|
|
133
|
+
file.read(client).catch(() => undefined),
|
|
134
|
+
keychain.read(client).catch(() => undefined),
|
|
135
|
+
]);
|
|
136
|
+
// Either copy can be stale: the Keychain's after it refused a write, the file's after a failed remove.
|
|
137
|
+
return (fromKeychain?.savedAt ?? 0) > (fromFile?.savedAt ?? 0) ? fromKeychain : (fromFile ?? fromKeychain);
|
|
138
|
+
},
|
|
139
|
+
async write(client, login) {
|
|
140
|
+
const stamped = { ...login, savedAt: clock() };
|
|
141
|
+
try {
|
|
142
|
+
await keychain.write(client, stamped);
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
const movesToFile = !(await file.read(client).catch(() => undefined));
|
|
146
|
+
await file.write(client, stamped);
|
|
147
|
+
if (movesToFile)
|
|
148
|
+
warn(describeError(error));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
await file.remove(client).catch(() => undefined);
|
|
152
|
+
},
|
|
153
|
+
async remove(client) {
|
|
154
|
+
await keychain.remove(client).catch(() => undefined);
|
|
155
|
+
await file.remove(client);
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
export function defaultCredentialStore({ platform = process.platform, warn = console.error, } = {}) {
|
|
160
|
+
const path = credentialsFilePath();
|
|
161
|
+
const file = fileCredentialStore(path);
|
|
162
|
+
if (platform !== "darwin")
|
|
163
|
+
return file;
|
|
164
|
+
return keychainWithFileFallback(keychainCredentialStore(), file, {
|
|
165
|
+
warn: (reason) => warn(`${reason} Stored it in ${path} instead.`),
|
|
166
|
+
});
|
|
167
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type FetchLike } from "./http.js";
|
|
2
|
+
export type PublicClient = {
|
|
3
|
+
baseUrl: string;
|
|
4
|
+
realm: string;
|
|
5
|
+
clientId: string;
|
|
6
|
+
};
|
|
7
|
+
export type DeviceLogin = {
|
|
8
|
+
deviceCode: string;
|
|
9
|
+
userCode: string;
|
|
10
|
+
verificationUri: string;
|
|
11
|
+
intervalMs: number;
|
|
12
|
+
expiresInMs: number;
|
|
13
|
+
codeVerifier: string;
|
|
14
|
+
};
|
|
15
|
+
export type PkcePair = {
|
|
16
|
+
verifier: string;
|
|
17
|
+
challenge: string;
|
|
18
|
+
};
|
|
19
|
+
export type Sleep = (ms: number) => Promise<void>;
|
|
20
|
+
export declare function deviceAuthEndpoint(baseUrl: string, realm: string): string;
|
|
21
|
+
export declare function createPkcePair(): PkcePair;
|
|
22
|
+
export declare function startDeviceLogin(client: PublicClient, fetchImpl?: FetchLike, pkce?: PkcePair, scope?: string): Promise<DeviceLogin>;
|
|
23
|
+
export type DeviceTokens = {
|
|
24
|
+
accessToken: string;
|
|
25
|
+
refreshToken?: string;
|
|
26
|
+
};
|
|
27
|
+
export declare function pollForDeviceTokens(client: PublicClient, { deviceCode, codeVerifier, intervalMs, expiresInMs, }: Pick<DeviceLogin, "deviceCode" | "codeVerifier"> & {
|
|
28
|
+
intervalMs?: number;
|
|
29
|
+
expiresInMs?: number;
|
|
30
|
+
}, { fetchImpl, sleep, clock, }?: {
|
|
31
|
+
fetchImpl?: FetchLike;
|
|
32
|
+
sleep?: Sleep;
|
|
33
|
+
clock?: () => number;
|
|
34
|
+
}): Promise<DeviceTokens>;
|
|
35
|
+
export declare function refreshAccessToken(client: PublicClient, refreshToken: string, fetchImpl?: FetchLike): Promise<DeviceTokens | undefined>;
|
|
36
|
+
//# sourceMappingURL=device-login.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"device-login.d.ts","sourceRoot":"","sources":["../src/device-login.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,SAAS,EAAoD,MAAM,WAAW,CAAC;AAG7F,MAAM,MAAM,YAAY,GAAG;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,eAAe,EAAE,MAAM,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,QAAQ,GAAG;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAAC;AAE/D,MAAM,MAAM,KAAK,GAAG,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;AAclD,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAEzE;AAGD,wBAAgB,cAAc,IAAI,QAAQ,CAIzC;AA+BD,wBAAsB,gBAAgB,CACpC,MAAM,EAAE,YAAY,EACpB,SAAS,GAAE,SAAiB,EAC5B,IAAI,GAAE,QAA2B,EACjC,KAAK,CAAC,EAAE,MAAM,GACb,OAAO,CAAC,WAAW,CAAC,CAkBtB;AAyBD,MAAM,MAAM,YAAY,GAAG;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAE1E,wBAAsB,mBAAmB,CACvC,MAAM,EAAE,YAAY,EACpB,EACE,UAAU,EACV,YAAY,EACZ,UAA8B,EAC9B,WAAgC,GACjC,EAAE,IAAI,CAAC,WAAW,EAAE,YAAY,GAAG,cAAc,CAAC,GAAG;IAAE,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAAE,EACnG,EACE,SAAiB,EACjB,KAAgB,EAChB,KAAgB,GACjB,GAAE;IAAE,SAAS,CAAC,EAAE,SAAS,CAAC;IAAC,KAAK,CAAC,EAAE,KAAK,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,MAAM,CAAA;CAAO,GACrE,OAAO,CAAC,YAAY,CAAC,CAwBvB;AAGD,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,YAAY,EACpB,YAAY,EAAE,MAAM,EACpB,SAAS,GAAE,SAAiB,GAC3B,OAAO,CAAC,YAAY,GAAG,SAAS,CAAC,CAiBnC"}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { formRequest, isTransientStatus, postTo, readJson } from "./http.js";
|
|
3
|
+
import { tokenEndpoint } from "./token.js";
|
|
4
|
+
const deviceCodeGrant = "urn:ietf:params:oauth:grant-type:device_code";
|
|
5
|
+
// RFC 8628 says to add this much whenever the server answers slow_down.
|
|
6
|
+
const slowDownStepMs = 5_000;
|
|
7
|
+
const defaultIntervalMs = 5_000;
|
|
8
|
+
// RFC 8628 makes expires_in optional, and Keycloak sends ten minutes.
|
|
9
|
+
const defaultExpiresInMs = 600_000;
|
|
10
|
+
const sleepFor = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
11
|
+
export function deviceAuthEndpoint(baseUrl, realm) {
|
|
12
|
+
return `${baseUrl}/realms/${realm}/protocol/openid-connect/auth/device`;
|
|
13
|
+
}
|
|
14
|
+
// A public client holds no secret, so PKCE is what ties the token request back to this process.
|
|
15
|
+
export function createPkcePair() {
|
|
16
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
17
|
+
return { verifier, challenge: createHash("sha256").update(verifier).digest("base64url") };
|
|
18
|
+
}
|
|
19
|
+
function readDeviceLogin(body, endpoint, codeVerifier) {
|
|
20
|
+
const deviceCode = body.device_code;
|
|
21
|
+
const userCode = body.user_code;
|
|
22
|
+
const verificationUri = body.verification_uri_complete ?? body.verification_uri;
|
|
23
|
+
if (!deviceCode || !userCode || !verificationUri) {
|
|
24
|
+
throw new Error(`the device login response from ${endpoint} is incomplete.`);
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
deviceCode,
|
|
28
|
+
userCode,
|
|
29
|
+
verificationUri,
|
|
30
|
+
intervalMs: body.interval ? body.interval * 1000 : defaultIntervalMs,
|
|
31
|
+
expiresInMs: body.expires_in ? body.expires_in * 1000 : defaultExpiresInMs,
|
|
32
|
+
codeVerifier,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
// No secret lives on the machine: a developer needs nothing beyond a browser login.
|
|
36
|
+
export async function startDeviceLogin(client, fetchImpl = fetch, pkce = createPkcePair(), scope) {
|
|
37
|
+
const endpoint = deviceAuthEndpoint(client.baseUrl, client.realm);
|
|
38
|
+
const request = formRequest({
|
|
39
|
+
client_id: client.clientId,
|
|
40
|
+
code_challenge: pkce.challenge,
|
|
41
|
+
code_challenge_method: "S256",
|
|
42
|
+
...(scope ? { scope } : {}),
|
|
43
|
+
});
|
|
44
|
+
const response = await postTo(endpoint, request, fetchImpl);
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
throw new Error(`${endpoint} refused the device login with status ${response.status}. ` +
|
|
47
|
+
`Check that the client ${client.clientId} is public and allows the OAuth device flow.`);
|
|
48
|
+
}
|
|
49
|
+
return readDeviceLogin(await readJson(response, endpoint), endpoint, pkce.verifier);
|
|
50
|
+
}
|
|
51
|
+
function deviceLoginFailure(error, status) {
|
|
52
|
+
switch (error) {
|
|
53
|
+
case "access_denied":
|
|
54
|
+
return new Error("the login was refused in the browser.");
|
|
55
|
+
case "expired_token":
|
|
56
|
+
return new Error("the login code expired. Run the command again.");
|
|
57
|
+
case "authorization_pending":
|
|
58
|
+
case "slow_down":
|
|
59
|
+
return undefined;
|
|
60
|
+
default:
|
|
61
|
+
return new Error(`the device login failed with "${error ?? status}".`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function deviceTokenRequest(client, deviceCode, codeVerifier) {
|
|
65
|
+
return formRequest({
|
|
66
|
+
grant_type: deviceCodeGrant,
|
|
67
|
+
device_code: deviceCode,
|
|
68
|
+
client_id: client.clientId,
|
|
69
|
+
code_verifier: codeVerifier,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
export async function pollForDeviceTokens(client, { deviceCode, codeVerifier, intervalMs = defaultIntervalMs, expiresInMs = defaultExpiresInMs, }, { fetchImpl = fetch, sleep = sleepFor, clock = Date.now, } = {}) {
|
|
73
|
+
const endpoint = tokenEndpoint(client.baseUrl, client.realm);
|
|
74
|
+
const request = deviceTokenRequest(client, deviceCode, codeVerifier);
|
|
75
|
+
const deadline = clock() + expiresInMs;
|
|
76
|
+
let wait = intervalMs;
|
|
77
|
+
for (;;) {
|
|
78
|
+
// Keycloak answers authorization_pending for ever if nobody opens the link.
|
|
79
|
+
if (clock() >= deadline)
|
|
80
|
+
throw new Error("nobody approved the login in time. Run it again.");
|
|
81
|
+
const response = await postTo(endpoint, request, fetchImpl);
|
|
82
|
+
const body = await readJson(response, endpoint);
|
|
83
|
+
if (response.ok && body.access_token) {
|
|
84
|
+
return { accessToken: body.access_token, ...(body.refresh_token ? { refreshToken: body.refresh_token } : {}) };
|
|
85
|
+
}
|
|
86
|
+
const failure = deviceLoginFailure(body.error, response.status);
|
|
87
|
+
if (failure)
|
|
88
|
+
throw failure;
|
|
89
|
+
if (body.error === "slow_down")
|
|
90
|
+
wait += slowDownStepMs;
|
|
91
|
+
await sleep(wait);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// A refresh token outlives many access tokens, so a long run renews without a second browser login.
|
|
95
|
+
export async function refreshAccessToken(client, refreshToken, fetchImpl = fetch) {
|
|
96
|
+
const endpoint = tokenEndpoint(client.baseUrl, client.realm);
|
|
97
|
+
const request = formRequest({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: client.clientId });
|
|
98
|
+
const response = await postTo(endpoint, request, fetchImpl);
|
|
99
|
+
// A refused refresh token is terminal and earns a new login; an overloaded Keycloak must not cost one.
|
|
100
|
+
if (isTransientStatus(response.status)) {
|
|
101
|
+
throw new Error(`${endpoint} could not renew the token (status ${response.status}).`);
|
|
102
|
+
}
|
|
103
|
+
if (!response.ok)
|
|
104
|
+
return undefined;
|
|
105
|
+
const body = await readJson(response, endpoint);
|
|
106
|
+
if (!body.access_token)
|
|
107
|
+
return undefined;
|
|
108
|
+
return { accessToken: body.access_token, refreshToken: body.refresh_token ?? refreshToken };
|
|
109
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type FetchLike = (url: string, init: RequestInit) => Promise<Response>;
|
|
2
|
+
export declare function formRequest(fields: Record<string, string>): RequestInit;
|
|
3
|
+
export declare class RefusedRequestError extends Error {
|
|
4
|
+
readonly status: number;
|
|
5
|
+
constructor(message: string, status: number);
|
|
6
|
+
}
|
|
7
|
+
export declare function isTransientStatus(status: number): boolean;
|
|
8
|
+
export declare function readJson<T>(response: Response, endpoint: string): Promise<T>;
|
|
9
|
+
export declare function postTo(endpoint: string, init: RequestInit, fetchImpl?: FetchLike): Promise<Response>;
|
|
10
|
+
export declare function describeError(error: unknown): string;
|
|
11
|
+
//# sourceMappingURL=http.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../src/http.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,SAAS,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;AAE9E,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,WAAW,CAMvE;AAED,qBAAa,mBAAoB,SAAQ,KAAK;IAG1C,QAAQ,CAAC,MAAM,EAAE,MAAM;gBADvB,OAAO,EAAE,MAAM,EACN,MAAM,EAAE,MAAM;CAI1B;AAGD,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAEzD;AAED,wBAAsB,QAAQ,CAAC,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,CAAC,CAQlF;AAED,wBAAsB,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,EAAE,SAAS,GAAE,SAAiB,GAAG,OAAO,CAAC,QAAQ,CAAC,CAMjH;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAIpD"}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export function formRequest(fields) {
|
|
2
|
+
return {
|
|
3
|
+
method: "POST",
|
|
4
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
5
|
+
body: new URLSearchParams(fields).toString(),
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
export class RefusedRequestError extends Error {
|
|
9
|
+
status;
|
|
10
|
+
constructor(message, status) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.status = status;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
// Overload and timeout, which are worth repeating; every other refusal is the server's final word.
|
|
16
|
+
export function isTransientStatus(status) {
|
|
17
|
+
return status >= 500 || status === 429 || status === 408;
|
|
18
|
+
}
|
|
19
|
+
export async function readJson(response, endpoint) {
|
|
20
|
+
const body = await response.text();
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(body);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
throw new Error(`${endpoint} answered status ${response.status} with text, not JSON: ${body}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export async function postTo(endpoint, init, fetchImpl = fetch) {
|
|
29
|
+
try {
|
|
30
|
+
return await fetchImpl(endpoint, init);
|
|
31
|
+
}
|
|
32
|
+
catch (cause) {
|
|
33
|
+
throw new Error(`cannot reach ${endpoint}.`, { cause });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function describeError(error) {
|
|
37
|
+
if (!(error instanceof Error))
|
|
38
|
+
return String(error);
|
|
39
|
+
return error.cause instanceof Error ? `${error.message}\n caused by: ${error.cause.message}` : error.message;
|
|
40
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { ciClientId, cliClientId, clientIdHelp, interactiveLogin, logIn, loginStatus, logOut, publicClientFrom, registerAuthCommands, revokeRefreshToken, userTokenSource, type ClientOptions, type LoginOptions, } from "./auth-commands.js";
|
|
2
|
+
export { browserLogin, loginScope } from "./browser-login.js";
|
|
3
|
+
export { defaultCredentialStore, fileCredentialStore, keychainCredentialStore, keychainWithFileFallback, type CredentialStore, type RunSecurity, type SecurityResult, type StoredLogin, } from "./credential-store.js";
|
|
4
|
+
export { pollForDeviceTokens, refreshAccessToken, startDeviceLogin, type DeviceLogin, type DeviceTokens, type PkcePair, type PublicClient, type Sleep, } from "./device-login.js";
|
|
5
|
+
export { describeError, isTransientStatus, postTo, readJson, RefusedRequestError, type FetchLike } from "./http.js";
|
|
6
|
+
export { saveLogin, storedLoginTokenSource } from "./stored-login.js";
|
|
7
|
+
export { cachedToken, mintWithClientCredentials, withRenewal, type ClientCredentials, type RenewableTokenSource, type TokenSource, } from "./token.js";
|
|
8
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,UAAU,EACV,WAAW,EACX,YAAY,EACZ,gBAAgB,EAChB,KAAK,EACL,WAAW,EACX,MAAM,EACN,gBAAgB,EAChB,oBAAoB,EACpB,kBAAkB,EAClB,eAAe,EACf,KAAK,aAAa,EAClB,KAAK,YAAY,GAClB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAC9D,OAAO,EACL,sBAAsB,EACtB,mBAAmB,EACnB,uBAAuB,EACvB,wBAAwB,EACxB,KAAK,eAAe,EACpB,KAAK,WAAW,EAChB,KAAK,cAAc,EACnB,KAAK,WAAW,GACjB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EACL,mBAAmB,EACnB,kBAAkB,EAClB,gBAAgB,EAChB,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,KAAK,GACX,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,EAAE,QAAQ,EAAE,mBAAmB,EAAE,KAAK,SAAS,EAAE,MAAM,WAAW,CAAC;AACpH,OAAO,EAAE,SAAS,EAAE,sBAAsB,EAAE,MAAM,mBAAmB,CAAC;AACtE,OAAO,EACL,WAAW,EACX,yBAAyB,EACzB,WAAW,EACX,KAAK,iBAAiB,EACtB,KAAK,oBAAoB,EACzB,KAAK,WAAW,GACjB,MAAM,YAAY,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { ciClientId, cliClientId, clientIdHelp, interactiveLogin, logIn, loginStatus, logOut, publicClientFrom, registerAuthCommands, revokeRefreshToken, userTokenSource, } from "./auth-commands.js";
|
|
2
|
+
export { browserLogin, loginScope } from "./browser-login.js";
|
|
3
|
+
export { defaultCredentialStore, fileCredentialStore, keychainCredentialStore, keychainWithFileFallback, } from "./credential-store.js";
|
|
4
|
+
export { pollForDeviceTokens, refreshAccessToken, startDeviceLogin, } from "./device-login.js";
|
|
5
|
+
export { describeError, isTransientStatus, postTo, readJson, RefusedRequestError } from "./http.js";
|
|
6
|
+
export { saveLogin, storedLoginTokenSource } from "./stored-login.js";
|
|
7
|
+
export { cachedToken, mintWithClientCredentials, withRenewal, } from "./token.js";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { CredentialStore } from "./credential-store.js";
|
|
2
|
+
import { type DeviceTokens, type PublicClient } from "./device-login.js";
|
|
3
|
+
import { type FetchLike } from "./http.js";
|
|
4
|
+
import { type RenewableTokenSource } from "./token.js";
|
|
5
|
+
export declare function saveLogin(store: CredentialStore, client: PublicClient, tokens: DeviceTokens): Promise<boolean>;
|
|
6
|
+
export declare function storedLoginTokenSource({ client, store, login, warn, fetchImpl, clock, }: {
|
|
7
|
+
client: PublicClient;
|
|
8
|
+
store: CredentialStore;
|
|
9
|
+
login: () => Promise<DeviceTokens>;
|
|
10
|
+
warn?: (message: string) => void;
|
|
11
|
+
fetchImpl?: FetchLike;
|
|
12
|
+
clock?: () => number;
|
|
13
|
+
}): RenewableTokenSource;
|
|
14
|
+
//# sourceMappingURL=stored-login.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stored-login.d.ts","sourceRoot":"","sources":["../src/stored-login.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAC7D,OAAO,EAAE,KAAK,YAAY,EAAE,KAAK,YAAY,EAAsB,MAAM,mBAAmB,CAAC;AAC7F,OAAO,EAAiB,KAAK,SAAS,EAAE,MAAM,WAAW,CAAC;AAC1D,OAAO,EAAoC,KAAK,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAGzF,wBAAsB,SAAS,CAAC,KAAK,EAAE,eAAe,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,OAAO,CAAC,CAQpH;AAGD,wBAAgB,sBAAsB,CAAC,EACrC,MAAM,EACN,KAAK,EACL,KAAK,EACL,IAAoB,EACpB,SAAiB,EACjB,KAAgB,GACjB,EAAE;IACD,MAAM,EAAE,YAAY,CAAC;IACrB,KAAK,EAAE,eAAe,CAAC;IACvB,KAAK,EAAE,MAAM,OAAO,CAAC,YAAY,CAAC,CAAC;IACnC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,SAAS,CAAC,EAAE,SAAS,CAAC;IACtB,KAAK,CAAC,EAAE,MAAM,MAAM,CAAC;CACtB,GAAG,oBAAoB,CA+BvB"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { refreshAccessToken } from "./device-login.js";
|
|
2
|
+
import { describeError } from "./http.js";
|
|
3
|
+
import { accessTokenUsername, cachedToken } from "./token.js";
|
|
4
|
+
// Keycloak may rotate the refresh token on every renewal, so each new one replaces the stored one.
|
|
5
|
+
export async function saveLogin(store, client, tokens) {
|
|
6
|
+
if (!tokens.refreshToken)
|
|
7
|
+
return false;
|
|
8
|
+
const username = accessTokenUsername(tokens.accessToken);
|
|
9
|
+
await store.write(client, { refreshToken: tokens.refreshToken, ...(username ? { username } : {}) });
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
// Renews from the stored login, and only asks for a new one when Keycloak refuses it; a network error keeps it.
|
|
13
|
+
export function storedLoginTokenSource({ client, store, login, warn = console.error, fetchImpl = fetch, clock = Date.now, }) {
|
|
14
|
+
// A store it cannot write, e.g. a read-only $HOME, costs the next run a login instead of failing this one.
|
|
15
|
+
async function keep(tokens) {
|
|
16
|
+
await saveLogin(store, client, tokens).catch((error) => warn(`could not store the login, so the next run may ask for another: ${describeError(error)}`));
|
|
17
|
+
}
|
|
18
|
+
async function renewOrLogIn() {
|
|
19
|
+
const stored = await store.read(client);
|
|
20
|
+
if (stored) {
|
|
21
|
+
const renewed = await refreshAccessToken(client, stored.refreshToken, fetchImpl);
|
|
22
|
+
if (renewed) {
|
|
23
|
+
await keep(renewed);
|
|
24
|
+
return renewed.accessToken;
|
|
25
|
+
}
|
|
26
|
+
await store
|
|
27
|
+
.remove(client)
|
|
28
|
+
.catch((error) => warn(`could not delete the refused login: ${describeError(error)}`));
|
|
29
|
+
}
|
|
30
|
+
const tokens = await login();
|
|
31
|
+
await keep(tokens);
|
|
32
|
+
return tokens.accessToken;
|
|
33
|
+
}
|
|
34
|
+
return cachedToken(renewOrLogIn, clock);
|
|
35
|
+
}
|
package/dist/token.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type FetchLike } from "./http.js";
|
|
2
|
+
export type ClientCredentials = {
|
|
3
|
+
baseUrl: string;
|
|
4
|
+
realm: string;
|
|
5
|
+
clientId: string;
|
|
6
|
+
clientSecret: string;
|
|
7
|
+
scope?: string;
|
|
8
|
+
};
|
|
9
|
+
export type TokenSource = () => Promise<string>;
|
|
10
|
+
export type RenewableTokenSource = TokenSource & {
|
|
11
|
+
renew: () => void;
|
|
12
|
+
};
|
|
13
|
+
export declare function withRenewal<T>(token: TokenSource, call: (accessToken: string) => Promise<T>): Promise<T>;
|
|
14
|
+
export declare const tokenExpiryBufferMs = 60000;
|
|
15
|
+
export declare const unparsableTokenTtlMs = 30000;
|
|
16
|
+
export declare function tokenEndpoint(baseUrl: string, realm: string): string;
|
|
17
|
+
export declare function accessTokenExpiresAt(accessToken: string, now: number): number;
|
|
18
|
+
export declare function accessTokenUsername(accessToken: string): string | undefined;
|
|
19
|
+
export declare function isFresh(expiresAt: number, now: number): boolean;
|
|
20
|
+
export declare function mintWithClientCredentials(credentials: ClientCredentials, fetchImpl?: FetchLike): Promise<string>;
|
|
21
|
+
export declare function cachedToken(mint: TokenSource, clock?: () => number): RenewableTokenSource;
|
|
22
|
+
//# sourceMappingURL=token.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token.d.ts","sourceRoot":"","sources":["../src/token.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAA4C,MAAM,WAAW,CAAC;AAErF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;AAGhD,MAAM,MAAM,oBAAoB,GAAG,WAAW,GAAG;IAAE,KAAK,EAAE,MAAM,IAAI,CAAA;CAAE,CAAC;AAOvE,wBAAsB,WAAW,CAAC,CAAC,EAAE,KAAK,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAU9G;AAGD,eAAO,MAAM,mBAAmB,QAAS,CAAC;AAG1C,eAAO,MAAM,oBAAoB,QAAS,CAAC;AAE3C,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAEpE;AAiBD,wBAAgB,oBAAoB,CAAC,WAAW,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAI7E;AAED,wBAAgB,mBAAmB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAK3E;AAED,wBAAgB,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAE/D;AAqBD,wBAAsB,yBAAyB,CAC7C,WAAW,EAAE,iBAAiB,EAC9B,SAAS,GAAE,SAAiB,GAC3B,OAAO,CAAC,MAAM,CAAC,CAajB;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,WAAW,EAAE,KAAK,GAAE,MAAM,MAAiB,GAAG,oBAAoB,CAoBnG"}
|
package/dist/token.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { formRequest, postTo, RefusedRequestError } from "./http.js";
|
|
2
|
+
function isRenewable(token) {
|
|
3
|
+
return typeof token.renew === "function";
|
|
4
|
+
}
|
|
5
|
+
// A token revoked or expired early answers 401 once; renewing and retrying once is what Claude Code does for MCP.
|
|
6
|
+
export async function withRenewal(token, call) {
|
|
7
|
+
try {
|
|
8
|
+
return await call(await token());
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
if (!(error instanceof RefusedRequestError && error.status === 401) || !isRenewable(token))
|
|
12
|
+
throw error;
|
|
13
|
+
token.renew();
|
|
14
|
+
return call(await token());
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
// Re-mint before Keycloak's own exp, so an in-flight request never races an expired token.
|
|
18
|
+
export const tokenExpiryBufferMs = 60_000;
|
|
19
|
+
// The exp claim did not parse, so the token gets a short life rather than none.
|
|
20
|
+
export const unparsableTokenTtlMs = 30_000;
|
|
21
|
+
export function tokenEndpoint(baseUrl, realm) {
|
|
22
|
+
return `${baseUrl}/realms/${realm}/protocol/openid-connect/token`;
|
|
23
|
+
}
|
|
24
|
+
// Reads the claims without checking the signature: callers only use them to schedule and to label.
|
|
25
|
+
function unverifiedClaims(accessToken) {
|
|
26
|
+
const payload = accessToken.split(".")[1];
|
|
27
|
+
if (!payload)
|
|
28
|
+
return {};
|
|
29
|
+
try {
|
|
30
|
+
const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
31
|
+
return typeof claims === "object" && claims !== null ? claims : {};
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function accessTokenExpiresAt(accessToken, now) {
|
|
38
|
+
const exp = unverifiedClaims(accessToken).exp;
|
|
39
|
+
return typeof exp === "number" ? exp * 1000 - tokenExpiryBufferMs : now + unparsableTokenTtlMs;
|
|
40
|
+
}
|
|
41
|
+
export function accessTokenUsername(accessToken) {
|
|
42
|
+
const claims = unverifiedClaims(accessToken);
|
|
43
|
+
const name = claims.preferred_username ?? claims.email;
|
|
44
|
+
return typeof name === "string" ? name : undefined;
|
|
45
|
+
}
|
|
46
|
+
export function isFresh(expiresAt, now) {
|
|
47
|
+
return expiresAt > now;
|
|
48
|
+
}
|
|
49
|
+
function clientCredentialsRequest({ clientId, clientSecret, scope }) {
|
|
50
|
+
return formRequest({
|
|
51
|
+
grant_type: "client_credentials",
|
|
52
|
+
client_id: clientId,
|
|
53
|
+
client_secret: clientSecret,
|
|
54
|
+
...(scope ? { scope } : {}),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function readAccessToken(body, realm) {
|
|
58
|
+
const accessToken = body.access_token;
|
|
59
|
+
if (typeof accessToken !== "string") {
|
|
60
|
+
throw new Error(`the Keycloak response for realm ${realm} carries no access_token.`);
|
|
61
|
+
}
|
|
62
|
+
return accessToken;
|
|
63
|
+
}
|
|
64
|
+
export async function mintWithClientCredentials(credentials, fetchImpl = fetch) {
|
|
65
|
+
const { baseUrl, realm } = credentials;
|
|
66
|
+
const endpoint = tokenEndpoint(baseUrl, realm);
|
|
67
|
+
const response = await postTo(endpoint, clientCredentialsRequest(credentials), fetchImpl);
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
throw new Error(`Keycloak refused the client credentials for realm ${realm} with status ${response.status}. ` +
|
|
70
|
+
`Check the client id and the secret against ${endpoint}.`);
|
|
71
|
+
}
|
|
72
|
+
return readAccessToken(await response.json(), realm);
|
|
73
|
+
}
|
|
74
|
+
export function cachedToken(mint, clock = Date.now) {
|
|
75
|
+
let held;
|
|
76
|
+
const source = async () => {
|
|
77
|
+
const now = clock();
|
|
78
|
+
if (held && isFresh(held.expiresAt, now))
|
|
79
|
+
return held.token;
|
|
80
|
+
const token = await mint();
|
|
81
|
+
// A device login blocks until the browser answers, so `now` is already stale here.
|
|
82
|
+
held = { token, expiresAt: accessTokenExpiresAt(token, clock()) };
|
|
83
|
+
return token;
|
|
84
|
+
};
|
|
85
|
+
return Object.assign(source, {
|
|
86
|
+
renew: () => {
|
|
87
|
+
held = undefined;
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@manta-eu/auth",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Keycloak login for the SDK CLIs: browser and device-code login, silent token renewal, and credential storage",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"import": "./dist/index.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"author": "",
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"commander": "^15.0.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "^24.13.3",
|
|
19
|
+
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
|
20
|
+
"@typescript-eslint/parser": "^8.56.1",
|
|
21
|
+
"eslint": "^9.39.2",
|
|
22
|
+
"rimraf": "^6.1.3",
|
|
23
|
+
"tsx": "^4.21.0",
|
|
24
|
+
"typescript": "^5.9.3",
|
|
25
|
+
"vitest": "^4.1.8"
|
|
26
|
+
},
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"files": [
|
|
30
|
+
"dist"
|
|
31
|
+
],
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "rimraf dist && tsc -p tsconfig.build.json",
|
|
37
|
+
"build:lib": "pnpm run build",
|
|
38
|
+
"test": "vitest --run",
|
|
39
|
+
"types": "tsc --noEmit"
|
|
40
|
+
}
|
|
41
|
+
}
|