Skip to content

Commit 8e68d68

Browse files
committed
feat(bp): support Linux with shared file-based credentials
1 parent 3ff10a2 commit 8e68d68

3 files changed

Lines changed: 87 additions & 37 deletions

File tree

‎packages/bp/src/credentials.ts‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { createHash } from "node:crypto";
2+
import { homedir } from "node:os";
3+
4+
import * as Effect from "effect/Effect";
5+
import * as FileSystem from "effect/FileSystem";
6+
import * as Path from "effect/Path";
7+
import * as Schema from "effect/Schema";
8+
9+
import { UserError } from "./runtime.ts";
10+
11+
export const Credentials = Schema.Struct({ idToken: Schema.String, expiresAt: Schema.Number });
12+
export type Credentials = typeof Credentials.Type;
13+
const oauthError = (message: string) => new UserError({ message });
14+
15+
const credentialsDirectory = (path: Path.Path) => {
16+
const config = process.env.XDG_CONFIG_HOME;
17+
return path.join(config && path.isAbsolute(config) ? config : path.join(homedir(), ".config"), "bp", "credentials");
18+
};
19+
const credentialsFilename = (baseUrl: string) => `${createHash("sha256").update(baseUrl).digest("hex")}.json`;
20+
21+
/** Private file storage shared by macOS and Linux, including headless sessions. */
22+
export const loadCredentials = (baseUrl: string) =>
23+
Effect.gen(function* () {
24+
const fs = yield* FileSystem.FileSystem;
25+
const path = yield* Path.Path;
26+
const raw = yield* fs
27+
.readFileString(path.join(credentialsDirectory(path), credentialsFilename(baseUrl)))
28+
.pipe(
29+
Effect.catch((error) =>
30+
error.reason._tag === "NotFound"
31+
? Effect.succeed(null)
32+
: Effect.fail(
33+
oauthError("could not read OAuth credentials; check permissions on the bp credentials directory"),
34+
),
35+
),
36+
);
37+
if (raw === null) return null;
38+
return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Credentials))(raw).pipe(
39+
Effect.mapError(() => oauthError("stored OAuth credentials are invalid; run `bp login` again")),
40+
);
41+
});
42+
43+
export const saveCredentials = (baseUrl: string, credentials: Credentials) =>
44+
Effect.gen(function* () {
45+
const fs = yield* FileSystem.FileSystem;
46+
const path = yield* Path.Path;
47+
const directory = credentialsDirectory(path);
48+
yield* fs.makeDirectory(directory, { recursive: true, mode: 0o700 });
49+
yield* fs.chmod(directory, 0o700);
50+
const temporary = yield* fs.makeTempDirectoryScoped({ directory, prefix: ".login-" });
51+
const file = path.join(temporary, "credentials.json");
52+
yield* fs.writeFileString(file, JSON.stringify(credentials), { mode: 0o600, flag: "wx" });
53+
yield* fs.rename(file, path.join(directory, credentialsFilename(baseUrl)));
54+
}).pipe(
55+
Effect.scoped,
56+
Effect.mapError(() =>
57+
oauthError("could not save OAuth credentials; check permissions on the bp credentials directory"),
58+
),
59+
);

‎packages/bp/src/desktop.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import * as Effect from "effect/Effect";
2+
3+
import { capture } from "./runtime.ts";
4+
5+
export const useDeviceLogin = (
6+
requested: boolean,
7+
platform: NodeJS.Platform = process.platform,
8+
env: NodeJS.ProcessEnv = process.env,
9+
) =>
10+
requested ||
11+
Boolean(env.SSH_CONNECTION || env.SSH_TTY) ||
12+
(platform !== "darwin" && (platform !== "linux" || !(env.DISPLAY || env.WAYLAND_DISPLAY)));
13+
14+
/** Best effort: the sign-in URL is always printed if no browser can be launched. */
15+
export const openBrowser = (url: string) =>
16+
Effect.gen(function* () {
17+
if (process.platform === "darwin") {
18+
if ((yield* capture("open", ["-a", "Helium", url])) === null) yield* capture("open", [url]);
19+
} else if (process.platform === "linux") {
20+
if ((yield* capture("xdg-open", [url])) === null) yield* capture("gio", ["open", url]);
21+
}
22+
});

‎packages/bp/src/oauth.ts‎

Lines changed: 6 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,19 @@ import * as HttpClientRequest from "effect/http/HttpClientRequest";
1010
import * as HttpClientResponse from "effect/http/HttpClientResponse";
1111
import * as Schema from "effect/Schema";
1212

13-
import { capture, exec, UserError } from "./runtime.ts";
13+
import { type Credentials, loadCredentials, saveCredentials } from "./credentials.ts";
14+
import { openBrowser, useDeviceLogin } from "./desktop.ts";
15+
import { UserError } from "./runtime.ts";
1416

15-
const KEYCHAIN_SERVICE = "com.blankparticle.bp.oauth";
1617
const CALLBACK_TIMEOUT_MS = 2 * 60 * 1000;
1718

1819
const Discovery = Schema.Struct({
1920
authorization_endpoint: Schema.String,
2021
token_endpoint: Schema.String,
2122
device_authorization_endpoint: Schema.optional(Schema.String),
2223
});
23-
const TokenResponse = Schema.Struct({ id_token: Schema.String, expires_in: Schema.Number });
2424
/** The signed id_token is the bearer credential; there is no refresh, `bp login` again once it expires */
25-
const Credentials = Schema.Struct({ idToken: Schema.String, expiresAt: Schema.Number });
26-
type Credentials = typeof Credentials.Type;
25+
const TokenResponse = Schema.Struct({ id_token: Schema.String, expires_in: Schema.Number });
2726

2827
const oauthError = (message: string) => new UserError({ message });
2928

@@ -41,31 +40,6 @@ const requestJson = <S extends Schema.ConstraintDecoder<unknown>>(
4140
Effect.mapError((cause) => (cause instanceof UserError ? cause : oauthError(`OAuth request failed: ${cause}`))),
4241
);
4342

44-
const keychainCredentials = (baseUrl: string) =>
45-
Effect.flatMap(capture("security", ["find-generic-password", "-a", baseUrl, "-s", KEYCHAIN_SERVICE, "-w"]), (raw) => {
46-
if (raw === null) return Effect.succeed<Credentials | null>(null);
47-
return Effect.try({
48-
try: () => Schema.decodeUnknownSync(Credentials)(JSON.parse(raw)),
49-
catch: () => oauthError("stored OAuth credentials are invalid; run `bp login` again"),
50-
});
51-
});
52-
53-
const saveCredentials = (baseUrl: string, credentials: Credentials) =>
54-
Effect.flatMap(
55-
capture("security", [
56-
"add-generic-password",
57-
"-U",
58-
"-a",
59-
baseUrl,
60-
"-s",
61-
KEYCHAIN_SERVICE,
62-
"-w",
63-
JSON.stringify(credentials),
64-
]),
65-
(result) =>
66-
result === null ? Effect.fail(oauthError("could not save OAuth credentials to macOS Keychain")) : Effect.void,
67-
);
68-
6943
const randomBase64Url = (bytes: number) =>
7044
Buffer.from(crypto.getRandomValues(new Uint8Array(bytes))).toString("base64url");
7145

@@ -183,10 +157,6 @@ const identity = (resource: string) => ({
183157
resource,
184158
});
185159

186-
/** Best effort; on machines without Helium the printed URL is the fallback */
187-
const openBrowser = (url: string) =>
188-
process.platform === "darwin" ? Effect.ignore(exec("open", ["-a", "Helium", url])) : Effect.void;
189-
190160
const store = (resource: string, tokens: { id_token: string; expires_in: number }) =>
191161
Effect.gen(function* () {
192162
const credentials: Credentials = { idToken: tokens.id_token, expiresAt: Date.now() + tokens.expires_in * 1000 };
@@ -284,15 +254,14 @@ export const login = (baseUrl: string, options: { device: boolean }) =>
284254
Discovery,
285255
HttpClientRequest.get(`${authOrigin()}/.well-known/openid-configuration`),
286256
);
287-
// Off macOS there is no known browser to open, so the code flow is the default there
288-
return yield* options.device || process.platform !== "darwin"
257+
return yield* useDeviceLogin(options.device)
289258
? loginWithDevice(resource, metadata)
290259
: loginWithBrowser(resource, metadata);
291260
});
292261

293262
export const accessToken = (baseUrl: string) =>
294263
Effect.gen(function* () {
295-
const credentials = yield* keychainCredentials(new URL(baseUrl).origin);
264+
const credentials = yield* loadCredentials(new URL(baseUrl).origin);
296265
if (credentials === null) return yield* oauthError("not signed in; run `bp login`");
297266
if (credentials.expiresAt <= Date.now() + 30_000) return yield* oauthError("sign-in expired; run `bp login` again");
298267
return credentials.idToken;

0 commit comments

Comments
 (0)