@yielded/auth-electron 0.1.0-beta.17
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 +51 -0
- package/dist/BrowserLogin.d.mts +8 -0
- package/dist/BrowserLogin.mjs +13 -0
- package/dist/BrowserLogin.mjs.map +1 -0
- package/dist/_virtual/_rolldown/runtime.mjs +13 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.mjs +2 -0
- package/dist/internal/browser.d.mts +30 -0
- package/dist/internal/browser.mjs +142 -0
- package/dist/internal/browser.mjs.map +1 -0
- package/dist/internal/vault.d.mts +13 -0
- package/dist/internal/vault.mjs +113 -0
- package/dist/internal/vault.mjs.map +1 -0
- package/package.json +1 -0
- package/src/BrowserLogin.ts +2 -0
- package/src/index.ts +1 -0
- package/src/internal/browser.ts +221 -0
- package/src/internal/vault.ts +176 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel van der Merwe
|
|
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,51 @@
|
|
|
1
|
+
# @yielded/auth-electron
|
|
2
|
+
|
|
3
|
+
Open hosted sign-in in the system browser and keep the native session in
|
|
4
|
+
Electron's main process. The renderer receives public session data, never credentials.
|
|
5
|
+
|
|
6
|
+
Install alongside Electron and Effect:
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
bun add @yielded/auth-electron@beta @yielded/auth@beta effect
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Import `BrowserLogin` from `@yielded/auth-electron`. The
|
|
13
|
+
[Electron example](../../examples/browser-login-electron) shows complete setup.
|
|
14
|
+
|
|
15
|
+
- Hold Electron's single-instance lock, then acquire
|
|
16
|
+
`makeBrowser({ hostedUrl, returnUrl })` synchronously before `app.whenReady()`.
|
|
17
|
+
Keep it in the application's `Scope` so callbacks can arrive during startup.
|
|
18
|
+
- After readiness, provide `layerVault({ path })` with Effect `FileSystem` and
|
|
19
|
+
`Path`. Use one writer and a private absolute path per app, backend, and client.
|
|
20
|
+
- Provide both services to core `BrowserLogin.makeClient`, sharing the vault with
|
|
21
|
+
the native `OperationHttpClient`. Keep credential-bearing operations in main.
|
|
22
|
+
|
|
23
|
+
The hosted page requires HTTPS, except for loopback HTTP development. Register
|
|
24
|
+
the exact return URL with the server. Electron does not support `ephemeral: true`;
|
|
25
|
+
it cannot promise a private system-browser session or close the tab on cancellation.
|
|
26
|
+
|
|
27
|
+
## Callback setup
|
|
28
|
+
|
|
29
|
+
For custom schemes, macOS requires a packaged app declaring the scheme in
|
|
30
|
+
`Info.plist`. Linux requires a packaged app with an installed `.desktop` handler
|
|
31
|
+
selected as default. Windows supports packaged apps and development registration
|
|
32
|
+
with the executable and application paths. See the example for platform steps.
|
|
33
|
+
|
|
34
|
+
HTTPS callbacks require a signed, packaged macOS app with an Associated Domains
|
|
35
|
+
entitlement and a matching website association. Follow the
|
|
36
|
+
[claimed HTTPS callback setup](../../docs/src/content/docs/reference/browser-login.md#apple-association)
|
|
37
|
+
to compose and host your app's association file. Browser and user
|
|
38
|
+
preferences may still require **Open in app**. Windows and Linux support only
|
|
39
|
+
custom schemes through this adapter.
|
|
40
|
+
|
|
41
|
+
## Storage and recovery
|
|
42
|
+
|
|
43
|
+
OS encryption is required. Linux needs GNOME Keyring or KWallet; there is no
|
|
44
|
+
plaintext fallback. Keep the signing identity stable for macOS Keychain access.
|
|
45
|
+
Windows encryption does not isolate the vault from other apps running as the same user.
|
|
46
|
+
|
|
47
|
+
Pending login state survives restarts; use `resume` while the attempt is valid.
|
|
48
|
+
Never delete an unreadable vault or reset an uncertain exchange just to restart login:
|
|
49
|
+
a native session may already exist. Follow
|
|
50
|
+
[session recovery](../../docs/src/content/docs/reference/browser-login.md#recovery)
|
|
51
|
+
before retiring the attempt or resetting storage.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { layerBrowser, makeBrowser } from "./internal/browser.mjs";
|
|
2
|
+
import { layerVault } from "./internal/vault.mjs";
|
|
3
|
+
declare namespace BrowserLogin_d_exports {
|
|
4
|
+
export { layerBrowser, layerVault, makeBrowser };
|
|
5
|
+
}
|
|
6
|
+
//#endregion
|
|
7
|
+
export { BrowserLogin_d_exports, layerBrowser, layerVault, makeBrowser };
|
|
8
|
+
//# sourceMappingURL=BrowserLogin.d.mts.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { __exportAll } from "./_virtual/_rolldown/runtime.mjs";
|
|
2
|
+
import { layerBrowser, makeBrowser } from "./internal/browser.mjs";
|
|
3
|
+
import { layerVault } from "./internal/vault.mjs";
|
|
4
|
+
//#region src/BrowserLogin.ts
|
|
5
|
+
var BrowserLogin_exports = /* @__PURE__ */ __exportAll({
|
|
6
|
+
layerBrowser: () => layerBrowser,
|
|
7
|
+
layerVault: () => layerVault,
|
|
8
|
+
makeBrowser: () => makeBrowser
|
|
9
|
+
});
|
|
10
|
+
//#endregion
|
|
11
|
+
export { BrowserLogin_exports, layerBrowser, layerVault, makeBrowser };
|
|
12
|
+
|
|
13
|
+
//# sourceMappingURL=BrowserLogin.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"BrowserLogin.mjs","names":[],"sources":["../src/BrowserLogin.ts"],"sourcesContent":["export { makeBrowser, layerBrowser } from \"./internal/browser\";\nexport { layerVault } from \"./internal/vault\";\n"],"mappings":""}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//#region \0rolldown/runtime.js
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __exportAll = (all, no_symbols) => {
|
|
4
|
+
let target = {};
|
|
5
|
+
for (var name in all) __defProp(target, name, {
|
|
6
|
+
get: all[name],
|
|
7
|
+
enumerable: true
|
|
8
|
+
});
|
|
9
|
+
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
10
|
+
return target;
|
|
11
|
+
};
|
|
12
|
+
//#endregion
|
|
13
|
+
export { __exportAll };
|
package/dist/index.d.mts
ADDED
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as BrowserLogin from "@yielded/auth/BrowserLogin";
|
|
2
|
+
import { Effect, Layer, Schema } from "effect";
|
|
3
|
+
//#region src/internal/browser.d.ts
|
|
4
|
+
declare const optionsSchema: Schema.Struct<{
|
|
5
|
+
readonly hostedUrl: Schema.String;
|
|
6
|
+
readonly returnUrl: Schema.String;
|
|
7
|
+
}>;
|
|
8
|
+
/** Acquire synchronously in the main process BEFORE app.whenReady(), under the
|
|
9
|
+
* application's Scope. The host must already hold app.requestSingleInstanceLock().
|
|
10
|
+
* No import-time listeners, protocol registration, or external browser launch.
|
|
11
|
+
* HTTPS callbacks require packaged macOS with signed associated domains and a
|
|
12
|
+
* matching website association. Only OS-delivered Universal Links are accepted;
|
|
13
|
+
* this adapter cannot preflight the OS association. Custom schemes require a
|
|
14
|
+
* packaged Info.plist on macOS or installed .desktop handler on Linux. Windows
|
|
15
|
+
* also supports the default Electron executable in development.
|
|
16
|
+
*/
|
|
17
|
+
export declare const makeBrowser: (options: {
|
|
18
|
+
readonly hostedUrl: string;
|
|
19
|
+
readonly returnUrl: string;
|
|
20
|
+
}) => Effect.Effect<{
|
|
21
|
+
readonly open: (input: {
|
|
22
|
+
readonly url: string;
|
|
23
|
+
readonly returnUrl: string;
|
|
24
|
+
readonly ephemeral: boolean;
|
|
25
|
+
}) => Effect.Effect<string, BrowserLogin.PlatformError>;
|
|
26
|
+
}, BrowserLogin.PlatformError, import("effect/Scope").Scope>;
|
|
27
|
+
/** Build before app readiness, or use makeBrowser for synchronous startup capture. */
|
|
28
|
+
export declare const layerBrowser: (options: typeof optionsSchema.Type) => Layer.Layer<BrowserLogin.Browser, BrowserLogin.PlatformError, never>;
|
|
29
|
+
//#endregion
|
|
30
|
+
//# sourceMappingURL=browser.d.mts.map
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import * as BrowserLogin from "@yielded/auth/BrowserLogin";
|
|
2
|
+
import { Effect, Layer, Schema } from "effect";
|
|
3
|
+
import { app, shell } from "electron";
|
|
4
|
+
//#region src/internal/browser.ts
|
|
5
|
+
const unavailable = () => BrowserLogin.PlatformError.make({ reason: "unavailable" });
|
|
6
|
+
const optionsSchema = Schema.Struct({
|
|
7
|
+
hostedUrl: BrowserLogin.HostedUrl,
|
|
8
|
+
returnUrl: BrowserLogin.ReturnUrl
|
|
9
|
+
});
|
|
10
|
+
/** Acquire synchronously in the main process BEFORE app.whenReady(), under the
|
|
11
|
+
* application's Scope. The host must already hold app.requestSingleInstanceLock().
|
|
12
|
+
* No import-time listeners, protocol registration, or external browser launch.
|
|
13
|
+
* HTTPS callbacks require packaged macOS with signed associated domains and a
|
|
14
|
+
* matching website association. Only OS-delivered Universal Links are accepted;
|
|
15
|
+
* this adapter cannot preflight the OS association. Custom schemes require a
|
|
16
|
+
* packaged Info.plist on macOS or installed .desktop handler on Linux. Windows
|
|
17
|
+
* also supports the default Electron executable in development.
|
|
18
|
+
*/
|
|
19
|
+
const makeBrowser = Effect.fnUntraced(function* (options) {
|
|
20
|
+
if (process.type !== "browser" || !app.hasSingleInstanceLock()) return yield* unavailable();
|
|
21
|
+
const config = yield* Schema.decodeEffect(optionsSchema)(options).pipe(Effect.mapError(unavailable));
|
|
22
|
+
const target = new URL(config.returnUrl);
|
|
23
|
+
const scheme = target.protocol.slice(0, -1);
|
|
24
|
+
const universalLink = scheme === "https";
|
|
25
|
+
if (universalLink && (process.platform !== "darwin" || !app.isPackaged || target.port !== "")) return yield* unavailable();
|
|
26
|
+
const callbackSchema = Schema.String.check(Schema.isMaxLength(2304), Schema.makeFilter((text) => {
|
|
27
|
+
try {
|
|
28
|
+
const url = new URL(text);
|
|
29
|
+
const keys = [...url.searchParams.keys()];
|
|
30
|
+
if (url.href !== text || url.username || url.password || url.hash || keys.length !== 2 || !keys.includes("code") || !keys.includes("state") || !Schema.is(BrowserLogin.Random)(url.searchParams.get("code")) || !Schema.is(BrowserLogin.Random)(url.searchParams.get("state"))) return false;
|
|
31
|
+
url.search = "";
|
|
32
|
+
return url.href === config.returnUrl;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}));
|
|
37
|
+
const openSchema = Schema.Struct({
|
|
38
|
+
returnUrl: Schema.Literal(config.returnUrl),
|
|
39
|
+
ephemeral: Schema.Literal(false),
|
|
40
|
+
url: Schema.String.check(Schema.isMaxLength(config.hostedUrl.length + 9 + 43), Schema.makeFilter((text) => {
|
|
41
|
+
try {
|
|
42
|
+
const url = new URL(text);
|
|
43
|
+
if (url.href !== text || url.hash || [...url.searchParams.keys()].join() !== "attempt" || !Schema.is(BrowserLogin.Random)(url.searchParams.get("attempt"))) return false;
|
|
44
|
+
url.search = "";
|
|
45
|
+
return url.href === config.hostedUrl;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}))
|
|
50
|
+
});
|
|
51
|
+
const queued = [];
|
|
52
|
+
let receive;
|
|
53
|
+
let close;
|
|
54
|
+
let disposed = false;
|
|
55
|
+
const capture = (url) => {
|
|
56
|
+
if (disposed || !Schema.is(callbackSchema)(url)) return;
|
|
57
|
+
if (receive !== void 0) receive(url);
|
|
58
|
+
else if (!queued.includes(url) && queued.length < 8) queued.push(url);
|
|
59
|
+
};
|
|
60
|
+
const openUrl = (event, url) => {
|
|
61
|
+
if (!Schema.is(callbackSchema)(url)) return;
|
|
62
|
+
event.preventDefault();
|
|
63
|
+
capture(url);
|
|
64
|
+
};
|
|
65
|
+
const secondInstance = (_event, argv) => {
|
|
66
|
+
for (const arg of argv) capture(arg);
|
|
67
|
+
};
|
|
68
|
+
const activitySchema = Schema.Struct({ webpageURL: callbackSchema });
|
|
69
|
+
const continueActivity = (event, type, _userInfo, details) => {
|
|
70
|
+
if (type !== "NSUserActivityTypeBrowsingWeb" || !Schema.is(activitySchema)(details)) return;
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
capture(details.webpageURL);
|
|
73
|
+
};
|
|
74
|
+
yield* Effect.acquireRelease(Effect.sync(() => {
|
|
75
|
+
if (universalLink) app.on("continue-activity", continueActivity);
|
|
76
|
+
else {
|
|
77
|
+
app.on("open-url", openUrl);
|
|
78
|
+
app.on("second-instance", secondInstance);
|
|
79
|
+
for (const arg of process.argv) capture(arg);
|
|
80
|
+
}
|
|
81
|
+
}), () => Effect.sync(() => {
|
|
82
|
+
disposed = true;
|
|
83
|
+
if (universalLink) app.removeListener("continue-activity", continueActivity);
|
|
84
|
+
else {
|
|
85
|
+
app.removeListener("open-url", openUrl);
|
|
86
|
+
app.removeListener("second-instance", secondInstance);
|
|
87
|
+
}
|
|
88
|
+
queued.length = 0;
|
|
89
|
+
close?.();
|
|
90
|
+
}));
|
|
91
|
+
const prepare = Effect.try({
|
|
92
|
+
try: () => {
|
|
93
|
+
if (!app.isReady() || disposed) return false;
|
|
94
|
+
if (universalLink) return process.platform === "darwin" && app.isPackaged;
|
|
95
|
+
if (process.platform === "linux") return app.isPackaged && app.isDefaultProtocolClient(scheme);
|
|
96
|
+
if (process.platform === "darwin") return app.isPackaged && app.setAsDefaultProtocolClient(scheme) && app.isDefaultProtocolClient(scheme);
|
|
97
|
+
if (process.platform !== "win32") return false;
|
|
98
|
+
const args = process.defaultApp ? [app.getAppPath()] : [];
|
|
99
|
+
return app.setAsDefaultProtocolClient(scheme, process.execPath, args) && app.isDefaultProtocolClient(scheme, process.execPath, args);
|
|
100
|
+
},
|
|
101
|
+
catch: unavailable
|
|
102
|
+
}).pipe(Effect.flatMap((available) => available ? Effect.void : Effect.fail(unavailable())));
|
|
103
|
+
return BrowserLogin.Browser.of({ open: Effect.fnUntraced(function* (input) {
|
|
104
|
+
const request = yield* Schema.decodeUnknownEffect(openSchema)(input).pipe(Effect.mapError(unavailable));
|
|
105
|
+
yield* prepare;
|
|
106
|
+
return yield* Effect.callback((resume) => {
|
|
107
|
+
if (receive !== void 0) {
|
|
108
|
+
resume(Effect.fail(BrowserLogin.PlatformError.make({ reason: "busy" })));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
let active = true;
|
|
112
|
+
const cleanup = () => {
|
|
113
|
+
if (!active) return;
|
|
114
|
+
active = false;
|
|
115
|
+
receive = void 0;
|
|
116
|
+
close = void 0;
|
|
117
|
+
};
|
|
118
|
+
const finish = (result) => {
|
|
119
|
+
if (active) {
|
|
120
|
+
cleanup();
|
|
121
|
+
resume(result);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
receive = (url) => finish(Effect.succeed(url));
|
|
125
|
+
close = () => finish(Effect.fail(unavailable()));
|
|
126
|
+
const pending = queued.shift();
|
|
127
|
+
if (pending !== void 0) receive(pending);
|
|
128
|
+
else try {
|
|
129
|
+
shell.openExternal(request.url).catch(() => finish(Effect.fail(unavailable())));
|
|
130
|
+
} catch {
|
|
131
|
+
finish(Effect.fail(unavailable()));
|
|
132
|
+
}
|
|
133
|
+
return Effect.sync(cleanup);
|
|
134
|
+
}).pipe(Effect.timeout("10 minutes"), Effect.catchTag("TimeoutError", () => Effect.fail(BrowserLogin.PlatformError.make({ reason: "expired" }))));
|
|
135
|
+
}) });
|
|
136
|
+
});
|
|
137
|
+
/** Build before app readiness, or use makeBrowser for synchronous startup capture. */
|
|
138
|
+
const layerBrowser = (options) => Layer.effect(BrowserLogin.Browser, makeBrowser(options));
|
|
139
|
+
//#endregion
|
|
140
|
+
export { layerBrowser, makeBrowser };
|
|
141
|
+
|
|
142
|
+
//# sourceMappingURL=browser.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"browser.mjs","names":[],"sources":["../../src/internal/browser.ts"],"sourcesContent":["import * as BrowserLogin from \"@yielded/auth/BrowserLogin\";\nimport { Effect, Layer, Schema } from \"effect\";\nimport { app, shell, type Event } from \"electron\";\n\nconst unavailable = () => BrowserLogin.PlatformError.make({ reason: \"unavailable\" });\n\nconst optionsSchema = Schema.Struct({\n hostedUrl: BrowserLogin.HostedUrl,\n returnUrl: BrowserLogin.ReturnUrl,\n});\n\n/** Acquire synchronously in the main process BEFORE app.whenReady(), under the\n * application's Scope. The host must already hold app.requestSingleInstanceLock().\n * No import-time listeners, protocol registration, or external browser launch.\n * HTTPS callbacks require packaged macOS with signed associated domains and a\n * matching website association. Only OS-delivered Universal Links are accepted;\n * this adapter cannot preflight the OS association. Custom schemes require a\n * packaged Info.plist on macOS or installed .desktop handler on Linux. Windows\n * also supports the default Electron executable in development.\n */\nexport const makeBrowser = Effect.fnUntraced(function* (options: typeof optionsSchema.Type) {\n if (process.type !== \"browser\" || !app.hasSingleInstanceLock()) return yield* unavailable();\n\n const config = yield* Schema.decodeEffect(optionsSchema)(options).pipe(\n Effect.mapError(unavailable),\n );\n\n const target = new URL(config.returnUrl);\n const scheme = target.protocol.slice(0, -1);\n const universalLink = scheme === \"https\";\n\n if (universalLink && (process.platform !== \"darwin\" || !app.isPackaged || target.port !== \"\"))\n return yield* unavailable();\n\n const callbackSchema = Schema.String.check(\n Schema.isMaxLength(2304),\n Schema.makeFilter((text) => {\n try {\n const url = new URL(text);\n const keys = [...url.searchParams.keys()];\n\n if (\n url.href !== text ||\n url.username ||\n url.password ||\n url.hash ||\n keys.length !== 2 ||\n !keys.includes(\"code\") ||\n !keys.includes(\"state\") ||\n !Schema.is(BrowserLogin.Random)(url.searchParams.get(\"code\")) ||\n !Schema.is(BrowserLogin.Random)(url.searchParams.get(\"state\"))\n )\n return false;\n url.search = \"\";\n\n return url.href === config.returnUrl;\n } catch {\n return false;\n }\n }),\n );\n\n const openSchema = Schema.Struct({\n returnUrl: Schema.Literal(config.returnUrl),\n ephemeral: Schema.Literal(false),\n url: Schema.String.check(\n Schema.isMaxLength(config.hostedUrl.length + \"?attempt=\".length + 43),\n Schema.makeFilter((text) => {\n try {\n const url = new URL(text);\n\n if (\n url.href !== text ||\n url.hash ||\n [...url.searchParams.keys()].join() !== \"attempt\" ||\n !Schema.is(BrowserLogin.Random)(url.searchParams.get(\"attempt\"))\n )\n return false;\n url.search = \"\";\n\n return url.href === config.hostedUrl;\n } catch {\n return false;\n }\n }),\n ),\n });\n\n const queued: string[] = [];\n let receive: ((url: string) => void) | undefined;\n let close: (() => void) | undefined;\n let disposed = false;\n\n const capture = (url: string) => {\n if (disposed || !Schema.is(callbackSchema)(url)) return;\n if (receive !== undefined) receive(url);\n else if (!queued.includes(url) && queued.length < 8) queued.push(url);\n };\n\n const openUrl = (event: Event, url: string) => {\n if (!Schema.is(callbackSchema)(url)) return;\n event.preventDefault();\n capture(url);\n };\n\n const secondInstance = (_event: Event, argv: string[]) => {\n for (const arg of argv) capture(arg);\n };\n\n const activitySchema = Schema.Struct({ webpageURL: callbackSchema });\n\n const continueActivity = (event: Event, type: string, _userInfo: unknown, details: unknown) => {\n if (type !== \"NSUserActivityTypeBrowsingWeb\" || !Schema.is(activitySchema)(details)) return;\n event.preventDefault();\n capture(details.webpageURL);\n };\n\n yield* Effect.acquireRelease(\n Effect.sync(() => {\n if (universalLink) app.on(\"continue-activity\", continueActivity);\n else {\n app.on(\"open-url\", openUrl);\n app.on(\"second-instance\", secondInstance);\n for (const arg of process.argv) capture(arg);\n }\n }),\n () =>\n Effect.sync(() => {\n disposed = true;\n if (universalLink) app.removeListener(\"continue-activity\", continueActivity);\n else {\n app.removeListener(\"open-url\", openUrl);\n app.removeListener(\"second-instance\", secondInstance);\n }\n queued.length = 0;\n close?.();\n }),\n );\n\n const prepare = Effect.try({\n try: () => {\n if (!app.isReady() || disposed) return false;\n // Associated domains are verified by macOS when routing the callback.\n // Registering as the default HTTPS handler would not establish ownership.\n if (universalLink) return process.platform === \"darwin\" && app.isPackaged;\n if (process.platform === \"linux\")\n return app.isPackaged && app.isDefaultProtocolClient(scheme);\n if (process.platform === \"darwin\")\n return (\n app.isPackaged &&\n app.setAsDefaultProtocolClient(scheme) &&\n app.isDefaultProtocolClient(scheme)\n );\n if (process.platform !== \"win32\") return false;\n const args = process.defaultApp ? [app.getAppPath()] : [];\n\n return (\n app.setAsDefaultProtocolClient(scheme, process.execPath, args) &&\n app.isDefaultProtocolClient(scheme, process.execPath, args)\n );\n },\n catch: unavailable,\n }).pipe(Effect.flatMap((available) => (available ? Effect.void : Effect.fail(unavailable()))));\n\n return BrowserLogin.Browser.of({\n open: Effect.fnUntraced(function* (input) {\n const request = yield* Schema.decodeUnknownEffect(openSchema)(input).pipe(\n Effect.mapError(unavailable),\n );\n\n yield* prepare;\n\n return yield* Effect.callback<string, BrowserLogin.PlatformError>((resume) => {\n if (receive !== undefined) {\n resume(Effect.fail(BrowserLogin.PlatformError.make({ reason: \"busy\" })));\n\n return;\n }\n let active = true;\n\n const cleanup = () => {\n if (!active) return;\n active = false;\n receive = undefined;\n close = undefined;\n };\n\n const finish = (result: Effect.Effect<string, BrowserLogin.PlatformError>) => {\n if (active) {\n cleanup();\n resume(result);\n }\n };\n\n receive = (url) => finish(Effect.succeed(url));\n close = () => finish(Effect.fail(unavailable()));\n const pending = queued.shift();\n\n if (pending !== undefined) receive(pending);\n else {\n try {\n void shell.openExternal(request.url).catch(() => finish(Effect.fail(unavailable())));\n } catch {\n finish(Effect.fail(unavailable()));\n }\n }\n\n return Effect.sync(cleanup);\n }).pipe(\n Effect.timeout(\"10 minutes\"),\n Effect.catchTag(\"TimeoutError\", () =>\n Effect.fail(BrowserLogin.PlatformError.make({ reason: \"expired\" })),\n ),\n );\n }),\n });\n});\n\n/** Build before app readiness, or use makeBrowser for synchronous startup capture. */\nexport const layerBrowser = (options: typeof optionsSchema.Type) =>\n Layer.effect(BrowserLogin.Browser, makeBrowser(options));\n"],"mappings":"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"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import * as BrowserLogin from "@yielded/auth/BrowserLogin";
|
|
2
|
+
import { FileSystem, Layer, Path } from "effect";
|
|
3
|
+
//#region src/internal/vault.d.ts
|
|
4
|
+
/** One writer per private application/environment path, protected by the host's
|
|
5
|
+
* single-instance lock. Never reset an unreadable store: it may contain the
|
|
6
|
+
* Exchanging fence. Writes fsync ciphertext, close, rename on the same filesystem,
|
|
7
|
+
* then sync the parent on POSIX (the replaced file on Windows).
|
|
8
|
+
*/
|
|
9
|
+
export declare const layerVault: (options: {
|
|
10
|
+
readonly path: string;
|
|
11
|
+
}) => Layer.Layer<BrowserLogin.Vault, BrowserLogin.PlatformError, FileSystem.FileSystem | Path.Path>;
|
|
12
|
+
//#endregion
|
|
13
|
+
//# sourceMappingURL=vault.d.mts.map
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import * as BrowserLogin from "@yielded/auth/BrowserLogin";
|
|
2
|
+
import { DateTime, Effect, FileSystem, Layer, Path, Schema, Semaphore } from "effect";
|
|
3
|
+
import { app, safeStorage } from "electron";
|
|
4
|
+
import { Buffer } from "node:buffer";
|
|
5
|
+
import { OperationHttpError } from "@yielded/auth/OperationHttp";
|
|
6
|
+
//#region src/internal/vault.ts
|
|
7
|
+
const recordSchema = BrowserLogin.VaultRecord;
|
|
8
|
+
const slots = recordSchema.fields.credentials.key.literals;
|
|
9
|
+
const recordJson = Schema.fromJsonString(recordSchema);
|
|
10
|
+
const failure = () => BrowserLogin.PlatformError.make({ reason: "storage" });
|
|
11
|
+
/** One writer per private application/environment path, protected by the host's
|
|
12
|
+
* single-instance lock. Never reset an unreadable store: it may contain the
|
|
13
|
+
* Exchanging fence. Writes fsync ciphertext, close, rename on the same filesystem,
|
|
14
|
+
* then sync the parent on POSIX (the replaced file on Windows).
|
|
15
|
+
*/
|
|
16
|
+
const layerVault = (options) => Layer.effect(BrowserLogin.Vault, Effect.gen(function* () {
|
|
17
|
+
const fs = yield* FileSystem.FileSystem;
|
|
18
|
+
const path = yield* Path.Path;
|
|
19
|
+
const checkEncryption = Effect.try({
|
|
20
|
+
try: () => process.type === "browser" && app.isReady() && app.hasSingleInstanceLock() && safeStorage.isEncryptionAvailable() && (process.platform !== "linux" || [
|
|
21
|
+
"gnome_libsecret",
|
|
22
|
+
"kwallet",
|
|
23
|
+
"kwallet5",
|
|
24
|
+
"kwallet6"
|
|
25
|
+
].includes(safeStorage.getSelectedStorageBackend())),
|
|
26
|
+
catch: failure
|
|
27
|
+
}).pipe(Effect.flatMap((available) => available ? Effect.void : Effect.fail(failure())));
|
|
28
|
+
yield* checkEncryption;
|
|
29
|
+
const destination = yield* Schema.decodeEffect(Schema.NonEmptyString.check(Schema.makeFilter((value) => path.isAbsolute(value))))(options.path).pipe(Effect.mapError(failure));
|
|
30
|
+
const directory = path.dirname(destination);
|
|
31
|
+
yield* fs.makeDirectory(directory, {
|
|
32
|
+
recursive: true,
|
|
33
|
+
mode: 448
|
|
34
|
+
}).pipe(Effect.mapError(failure));
|
|
35
|
+
const gate = yield* Semaphore.make(1);
|
|
36
|
+
const readRecord = Effect.gen(function* () {
|
|
37
|
+
yield* checkEncryption;
|
|
38
|
+
const stat = yield* fs.stat(destination).pipe(Effect.catchTag("PlatformError", (error) => error.reason._tag === "NotFound" ? Effect.void : Effect.fail(error)));
|
|
39
|
+
if (stat === void 0) return { credentials: {} };
|
|
40
|
+
if (stat.type !== "File" || stat.size > 1048576) return yield* failure();
|
|
41
|
+
const bytes = yield* fs.readFile(destination);
|
|
42
|
+
const json = yield* Effect.try({
|
|
43
|
+
try: () => safeStorage.decryptString(Buffer.from(bytes)),
|
|
44
|
+
catch: failure
|
|
45
|
+
});
|
|
46
|
+
return yield* Schema.decodeEffect(recordJson)(json, { onExcessProperty: "error" });
|
|
47
|
+
}).pipe(Effect.mapError(failure));
|
|
48
|
+
const writeRecord = Effect.fnUntraced(function* (record) {
|
|
49
|
+
yield* checkEncryption;
|
|
50
|
+
const json = yield* Schema.encodeEffect(recordJson)(record);
|
|
51
|
+
const bytes = yield* Effect.try({
|
|
52
|
+
try: () => safeStorage.encryptString(json),
|
|
53
|
+
catch: failure
|
|
54
|
+
});
|
|
55
|
+
yield* Effect.scoped(Effect.gen(function* () {
|
|
56
|
+
const temporary = yield* fs.makeTempFileScoped({
|
|
57
|
+
directory,
|
|
58
|
+
prefix: ".auth-"
|
|
59
|
+
});
|
|
60
|
+
yield* fs.chmod(temporary, 384);
|
|
61
|
+
yield* Effect.scoped(Effect.gen(function* () {
|
|
62
|
+
const file = yield* fs.open(temporary, {
|
|
63
|
+
flag: "w",
|
|
64
|
+
mode: 384
|
|
65
|
+
});
|
|
66
|
+
yield* file.writeAll(bytes);
|
|
67
|
+
yield* file.sync;
|
|
68
|
+
}));
|
|
69
|
+
yield* fs.rename(temporary, destination);
|
|
70
|
+
yield* (yield* fs.open(process.platform === "win32" ? destination : directory, { flag: process.platform === "win32" ? "r+" : "r" })).sync;
|
|
71
|
+
}));
|
|
72
|
+
}, Effect.mapError(failure));
|
|
73
|
+
const locked = (effect) => gate.withPermits(1)(Effect.uninterruptible(effect));
|
|
74
|
+
const read = locked(Effect.gen(function* () {
|
|
75
|
+
const record = yield* readRecord;
|
|
76
|
+
const now = DateTime.toEpochMillis(yield* DateTime.now);
|
|
77
|
+
const credentials = {};
|
|
78
|
+
for (const slot of slots) {
|
|
79
|
+
const value = record.credentials[slot];
|
|
80
|
+
if (value !== void 0 && value.expiresAtMillis > now) credentials[slot] = value.credential;
|
|
81
|
+
}
|
|
82
|
+
return credentials;
|
|
83
|
+
})).pipe(Effect.mapError(() => OperationHttpError.make({ reason: "credentials" })));
|
|
84
|
+
const accept = (commands) => locked(Effect.gen(function* () {
|
|
85
|
+
const record = yield* readRecord;
|
|
86
|
+
const credentials = { ...record.credentials };
|
|
87
|
+
for (const command of commands) if (command._tag === "Clear") delete credentials[command.slot];
|
|
88
|
+
else credentials[command.slot] = {
|
|
89
|
+
credential: command.credential,
|
|
90
|
+
expiresAtMillis: command.expiresAtMillis
|
|
91
|
+
};
|
|
92
|
+
yield* writeRecord({
|
|
93
|
+
...record,
|
|
94
|
+
credentials
|
|
95
|
+
});
|
|
96
|
+
})).pipe(Effect.mapError(() => OperationHttpError.make({ reason: "credentials" })));
|
|
97
|
+
return BrowserLogin.Vault.of({
|
|
98
|
+
read,
|
|
99
|
+
accept,
|
|
100
|
+
loadAttempt: locked(readRecord.pipe(Effect.map((record) => record.attempt))),
|
|
101
|
+
saveAttempt: (attempt) => locked(Effect.gen(function* () {
|
|
102
|
+
const record = yield* readRecord;
|
|
103
|
+
yield* writeRecord(attempt === void 0 ? { credentials: record.credentials } : {
|
|
104
|
+
...record,
|
|
105
|
+
attempt
|
|
106
|
+
});
|
|
107
|
+
}))
|
|
108
|
+
});
|
|
109
|
+
}));
|
|
110
|
+
//#endregion
|
|
111
|
+
export { layerVault };
|
|
112
|
+
|
|
113
|
+
//# sourceMappingURL=vault.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vault.mjs","names":[],"sources":["../../src/internal/vault.ts"],"sourcesContent":["import { Buffer } from \"node:buffer\";\n\nimport * as BrowserLogin from \"@yielded/auth/BrowserLogin\";\nimport { OperationHttpError } from \"@yielded/auth/OperationHttp\";\nimport type { NativeCredentials } from \"@yielded/auth/OperationHttpClient\";\nimport type { CredentialSlot } from \"@yielded/auth/Operations\";\nimport {\n DateTime,\n Effect,\n FileSystem,\n Layer,\n Path,\n type Redacted,\n Schema,\n Semaphore,\n} from \"effect\";\nimport { app, safeStorage } from \"electron\";\n\nconst recordSchema = BrowserLogin.VaultRecord;\nconst slots = recordSchema.fields.credentials.key.literals;\n\nconst recordJson = Schema.fromJsonString(recordSchema);\nconst failure = () => BrowserLogin.PlatformError.make({ reason: \"storage\" });\n\n/** One writer per private application/environment path, protected by the host's\n * single-instance lock. Never reset an unreadable store: it may contain the\n * Exchanging fence. Writes fsync ciphertext, close, rename on the same filesystem,\n * then sync the parent on POSIX (the replaced file on Windows).\n */\nexport const layerVault = (options: { readonly path: string }) =>\n Layer.effect(\n BrowserLogin.Vault,\n Effect.gen(function* () {\n const fs = yield* FileSystem.FileSystem;\n const path = yield* Path.Path;\n\n const checkEncryption = Effect.try({\n try: () =>\n process.type === \"browser\" &&\n app.isReady() &&\n app.hasSingleInstanceLock() &&\n safeStorage.isEncryptionAvailable() &&\n (process.platform !== \"linux\" ||\n [\"gnome_libsecret\", \"kwallet\", \"kwallet5\", \"kwallet6\"].includes(\n safeStorage.getSelectedStorageBackend(),\n )),\n catch: failure,\n }).pipe(Effect.flatMap((available) => (available ? Effect.void : Effect.fail(failure()))));\n\n yield* checkEncryption;\n\n const destination = yield* Schema.decodeEffect(\n Schema.NonEmptyString.check(Schema.makeFilter((value) => path.isAbsolute(value))),\n )(options.path).pipe(Effect.mapError(failure));\n\n const directory = path.dirname(destination);\n\n yield* fs\n .makeDirectory(directory, { recursive: true, mode: 0o700 })\n .pipe(Effect.mapError(failure));\n const gate = yield* Semaphore.make(1);\n\n const readRecord = Effect.gen(function* () {\n yield* checkEncryption;\n\n const stat = yield* fs\n .stat(destination)\n .pipe(\n Effect.catchTag(\"PlatformError\", (error) =>\n error.reason._tag === \"NotFound\" ? Effect.void : Effect.fail(error),\n ),\n );\n\n if (stat === undefined) return { credentials: {} } satisfies typeof recordSchema.Type;\n if (stat.type !== \"File\" || stat.size > 1048576) return yield* failure();\n const bytes = yield* fs.readFile(destination);\n\n const json = yield* Effect.try({\n try: () => safeStorage.decryptString(Buffer.from(bytes)),\n catch: failure,\n });\n\n return yield* Schema.decodeEffect(recordJson)(json, { onExcessProperty: \"error\" });\n }).pipe(Effect.mapError(failure));\n\n const writeRecord = Effect.fnUntraced(function* (record: typeof recordSchema.Type) {\n yield* checkEncryption;\n const json = yield* Schema.encodeEffect(recordJson)(record);\n\n const bytes = yield* Effect.try({\n try: () => safeStorage.encryptString(json),\n catch: failure,\n });\n\n yield* Effect.scoped(\n Effect.gen(function* () {\n const temporary = yield* fs.makeTempFileScoped({ directory, prefix: \".auth-\" });\n\n yield* fs.chmod(temporary, 0o600);\n yield* Effect.scoped(\n Effect.gen(function* () {\n const file = yield* fs.open(temporary, { flag: \"w\", mode: 0o600 });\n\n yield* file.writeAll(bytes);\n yield* file.sync;\n }),\n );\n yield* fs.rename(temporary, destination);\n\n const committed = yield* fs.open(\n process.platform === \"win32\" ? destination : directory,\n { flag: process.platform === \"win32\" ? \"r+\" : \"r\" },\n );\n\n yield* committed.sync;\n }),\n );\n }, Effect.mapError(failure));\n\n const locked = <A, E>(effect: Effect.Effect<A, E>) =>\n gate.withPermits(1)(Effect.uninterruptible(effect));\n\n const read: NativeCredentials[\"read\"] = locked(\n Effect.gen(function* () {\n const record = yield* readRecord;\n const now = DateTime.toEpochMillis(yield* DateTime.now);\n const credentials: Partial<Record<CredentialSlot, Redacted.Redacted<string>>> = {};\n\n for (const slot of slots) {\n const value = record.credentials[slot];\n\n if (value !== undefined && value.expiresAtMillis > now)\n credentials[slot] = value.credential;\n }\n\n return credentials;\n }),\n ).pipe(Effect.mapError(() => OperationHttpError.make({ reason: \"credentials\" })));\n\n const accept: NativeCredentials[\"accept\"] = (commands) =>\n locked(\n Effect.gen(function* () {\n const record = yield* readRecord;\n const credentials = { ...record.credentials };\n\n for (const command of commands) {\n if (command._tag === \"Clear\") delete credentials[command.slot];\n else\n credentials[command.slot] = {\n credential: command.credential,\n expiresAtMillis: command.expiresAtMillis,\n };\n }\n yield* writeRecord({ ...record, credentials });\n }),\n ).pipe(Effect.mapError(() => OperationHttpError.make({ reason: \"credentials\" })));\n\n return BrowserLogin.Vault.of({\n read,\n accept,\n loadAttempt: locked(readRecord.pipe(Effect.map((record) => record.attempt))),\n saveAttempt: (attempt) =>\n locked(\n Effect.gen(function* () {\n const record = yield* readRecord;\n\n yield* writeRecord(\n attempt === undefined\n ? { credentials: record.credentials }\n : { ...record, attempt },\n );\n }),\n ),\n });\n }),\n );\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"name":"@yielded/auth-electron","version":"0.1.0-beta.17","dependencies":{"@yielded/auth":"0.1.0-beta.17"},"devDependencies":{"@types/node":"26.1.2","effect":"4.0.0","electron":"44.5.1","typescript":"7.0.2","vite-plus":"0.3.3"},"peerDependencies":{"effect":"^4.0.0","electron":"^44.5.1"},"exports":{".":{"types":"./dist/index.d.mts","default":"./dist/index.mjs"},"./BrowserLogin":{"types":"./dist/BrowserLogin.d.mts","default":"./dist/BrowserLogin.mjs"}},"description":"Main-process Electron browser login and encrypted credential custody for Yielded Auth.","homepage":"https://yielded.dev/auth/","license":"MIT","repository":{"type":"git","url":"git+https://github.com/yielded-dev/auth.git","directory":"packages/auth-electron"},"files":["dist","src"],"type":"module","sideEffects":[],"publishConfig":{"access":"public"},"scripts":{"build":"vp pack","check":"tsc --noEmit -p tsconfig.json"}}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * as BrowserLogin from "./BrowserLogin.ts";
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import * as BrowserLogin from "@yielded/auth/BrowserLogin";
|
|
2
|
+
import { Effect, Layer, Schema } from "effect";
|
|
3
|
+
import { app, shell, type Event } from "electron";
|
|
4
|
+
|
|
5
|
+
const unavailable = () => BrowserLogin.PlatformError.make({ reason: "unavailable" });
|
|
6
|
+
|
|
7
|
+
const optionsSchema = Schema.Struct({
|
|
8
|
+
hostedUrl: BrowserLogin.HostedUrl,
|
|
9
|
+
returnUrl: BrowserLogin.ReturnUrl,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
/** Acquire synchronously in the main process BEFORE app.whenReady(), under the
|
|
13
|
+
* application's Scope. The host must already hold app.requestSingleInstanceLock().
|
|
14
|
+
* No import-time listeners, protocol registration, or external browser launch.
|
|
15
|
+
* HTTPS callbacks require packaged macOS with signed associated domains and a
|
|
16
|
+
* matching website association. Only OS-delivered Universal Links are accepted;
|
|
17
|
+
* this adapter cannot preflight the OS association. Custom schemes require a
|
|
18
|
+
* packaged Info.plist on macOS or installed .desktop handler on Linux. Windows
|
|
19
|
+
* also supports the default Electron executable in development.
|
|
20
|
+
*/
|
|
21
|
+
export const makeBrowser = Effect.fnUntraced(function* (options: typeof optionsSchema.Type) {
|
|
22
|
+
if (process.type !== "browser" || !app.hasSingleInstanceLock()) return yield* unavailable();
|
|
23
|
+
|
|
24
|
+
const config = yield* Schema.decodeEffect(optionsSchema)(options).pipe(
|
|
25
|
+
Effect.mapError(unavailable),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const target = new URL(config.returnUrl);
|
|
29
|
+
const scheme = target.protocol.slice(0, -1);
|
|
30
|
+
const universalLink = scheme === "https";
|
|
31
|
+
|
|
32
|
+
if (universalLink && (process.platform !== "darwin" || !app.isPackaged || target.port !== ""))
|
|
33
|
+
return yield* unavailable();
|
|
34
|
+
|
|
35
|
+
const callbackSchema = Schema.String.check(
|
|
36
|
+
Schema.isMaxLength(2304),
|
|
37
|
+
Schema.makeFilter((text) => {
|
|
38
|
+
try {
|
|
39
|
+
const url = new URL(text);
|
|
40
|
+
const keys = [...url.searchParams.keys()];
|
|
41
|
+
|
|
42
|
+
if (
|
|
43
|
+
url.href !== text ||
|
|
44
|
+
url.username ||
|
|
45
|
+
url.password ||
|
|
46
|
+
url.hash ||
|
|
47
|
+
keys.length !== 2 ||
|
|
48
|
+
!keys.includes("code") ||
|
|
49
|
+
!keys.includes("state") ||
|
|
50
|
+
!Schema.is(BrowserLogin.Random)(url.searchParams.get("code")) ||
|
|
51
|
+
!Schema.is(BrowserLogin.Random)(url.searchParams.get("state"))
|
|
52
|
+
)
|
|
53
|
+
return false;
|
|
54
|
+
url.search = "";
|
|
55
|
+
|
|
56
|
+
return url.href === config.returnUrl;
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const openSchema = Schema.Struct({
|
|
64
|
+
returnUrl: Schema.Literal(config.returnUrl),
|
|
65
|
+
ephemeral: Schema.Literal(false),
|
|
66
|
+
url: Schema.String.check(
|
|
67
|
+
Schema.isMaxLength(config.hostedUrl.length + "?attempt=".length + 43),
|
|
68
|
+
Schema.makeFilter((text) => {
|
|
69
|
+
try {
|
|
70
|
+
const url = new URL(text);
|
|
71
|
+
|
|
72
|
+
if (
|
|
73
|
+
url.href !== text ||
|
|
74
|
+
url.hash ||
|
|
75
|
+
[...url.searchParams.keys()].join() !== "attempt" ||
|
|
76
|
+
!Schema.is(BrowserLogin.Random)(url.searchParams.get("attempt"))
|
|
77
|
+
)
|
|
78
|
+
return false;
|
|
79
|
+
url.search = "";
|
|
80
|
+
|
|
81
|
+
return url.href === config.hostedUrl;
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}),
|
|
86
|
+
),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const queued: string[] = [];
|
|
90
|
+
let receive: ((url: string) => void) | undefined;
|
|
91
|
+
let close: (() => void) | undefined;
|
|
92
|
+
let disposed = false;
|
|
93
|
+
|
|
94
|
+
const capture = (url: string) => {
|
|
95
|
+
if (disposed || !Schema.is(callbackSchema)(url)) return;
|
|
96
|
+
if (receive !== undefined) receive(url);
|
|
97
|
+
else if (!queued.includes(url) && queued.length < 8) queued.push(url);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const openUrl = (event: Event, url: string) => {
|
|
101
|
+
if (!Schema.is(callbackSchema)(url)) return;
|
|
102
|
+
event.preventDefault();
|
|
103
|
+
capture(url);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const secondInstance = (_event: Event, argv: string[]) => {
|
|
107
|
+
for (const arg of argv) capture(arg);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const activitySchema = Schema.Struct({ webpageURL: callbackSchema });
|
|
111
|
+
|
|
112
|
+
const continueActivity = (event: Event, type: string, _userInfo: unknown, details: unknown) => {
|
|
113
|
+
if (type !== "NSUserActivityTypeBrowsingWeb" || !Schema.is(activitySchema)(details)) return;
|
|
114
|
+
event.preventDefault();
|
|
115
|
+
capture(details.webpageURL);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
yield* Effect.acquireRelease(
|
|
119
|
+
Effect.sync(() => {
|
|
120
|
+
if (universalLink) app.on("continue-activity", continueActivity);
|
|
121
|
+
else {
|
|
122
|
+
app.on("open-url", openUrl);
|
|
123
|
+
app.on("second-instance", secondInstance);
|
|
124
|
+
for (const arg of process.argv) capture(arg);
|
|
125
|
+
}
|
|
126
|
+
}),
|
|
127
|
+
() =>
|
|
128
|
+
Effect.sync(() => {
|
|
129
|
+
disposed = true;
|
|
130
|
+
if (universalLink) app.removeListener("continue-activity", continueActivity);
|
|
131
|
+
else {
|
|
132
|
+
app.removeListener("open-url", openUrl);
|
|
133
|
+
app.removeListener("second-instance", secondInstance);
|
|
134
|
+
}
|
|
135
|
+
queued.length = 0;
|
|
136
|
+
close?.();
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const prepare = Effect.try({
|
|
141
|
+
try: () => {
|
|
142
|
+
if (!app.isReady() || disposed) return false;
|
|
143
|
+
// Associated domains are verified by macOS when routing the callback.
|
|
144
|
+
// Registering as the default HTTPS handler would not establish ownership.
|
|
145
|
+
if (universalLink) return process.platform === "darwin" && app.isPackaged;
|
|
146
|
+
if (process.platform === "linux")
|
|
147
|
+
return app.isPackaged && app.isDefaultProtocolClient(scheme);
|
|
148
|
+
if (process.platform === "darwin")
|
|
149
|
+
return (
|
|
150
|
+
app.isPackaged &&
|
|
151
|
+
app.setAsDefaultProtocolClient(scheme) &&
|
|
152
|
+
app.isDefaultProtocolClient(scheme)
|
|
153
|
+
);
|
|
154
|
+
if (process.platform !== "win32") return false;
|
|
155
|
+
const args = process.defaultApp ? [app.getAppPath()] : [];
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
app.setAsDefaultProtocolClient(scheme, process.execPath, args) &&
|
|
159
|
+
app.isDefaultProtocolClient(scheme, process.execPath, args)
|
|
160
|
+
);
|
|
161
|
+
},
|
|
162
|
+
catch: unavailable,
|
|
163
|
+
}).pipe(Effect.flatMap((available) => (available ? Effect.void : Effect.fail(unavailable()))));
|
|
164
|
+
|
|
165
|
+
return BrowserLogin.Browser.of({
|
|
166
|
+
open: Effect.fnUntraced(function* (input) {
|
|
167
|
+
const request = yield* Schema.decodeUnknownEffect(openSchema)(input).pipe(
|
|
168
|
+
Effect.mapError(unavailable),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
yield* prepare;
|
|
172
|
+
|
|
173
|
+
return yield* Effect.callback<string, BrowserLogin.PlatformError>((resume) => {
|
|
174
|
+
if (receive !== undefined) {
|
|
175
|
+
resume(Effect.fail(BrowserLogin.PlatformError.make({ reason: "busy" })));
|
|
176
|
+
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
let active = true;
|
|
180
|
+
|
|
181
|
+
const cleanup = () => {
|
|
182
|
+
if (!active) return;
|
|
183
|
+
active = false;
|
|
184
|
+
receive = undefined;
|
|
185
|
+
close = undefined;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const finish = (result: Effect.Effect<string, BrowserLogin.PlatformError>) => {
|
|
189
|
+
if (active) {
|
|
190
|
+
cleanup();
|
|
191
|
+
resume(result);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
receive = (url) => finish(Effect.succeed(url));
|
|
196
|
+
close = () => finish(Effect.fail(unavailable()));
|
|
197
|
+
const pending = queued.shift();
|
|
198
|
+
|
|
199
|
+
if (pending !== undefined) receive(pending);
|
|
200
|
+
else {
|
|
201
|
+
try {
|
|
202
|
+
void shell.openExternal(request.url).catch(() => finish(Effect.fail(unavailable())));
|
|
203
|
+
} catch {
|
|
204
|
+
finish(Effect.fail(unavailable()));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return Effect.sync(cleanup);
|
|
209
|
+
}).pipe(
|
|
210
|
+
Effect.timeout("10 minutes"),
|
|
211
|
+
Effect.catchTag("TimeoutError", () =>
|
|
212
|
+
Effect.fail(BrowserLogin.PlatformError.make({ reason: "expired" })),
|
|
213
|
+
),
|
|
214
|
+
);
|
|
215
|
+
}),
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
/** Build before app readiness, or use makeBrowser for synchronous startup capture. */
|
|
220
|
+
export const layerBrowser = (options: typeof optionsSchema.Type) =>
|
|
221
|
+
Layer.effect(BrowserLogin.Browser, makeBrowser(options));
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
|
|
3
|
+
import * as BrowserLogin from "@yielded/auth/BrowserLogin";
|
|
4
|
+
import { OperationHttpError } from "@yielded/auth/OperationHttp";
|
|
5
|
+
import type { NativeCredentials } from "@yielded/auth/OperationHttpClient";
|
|
6
|
+
import type { CredentialSlot } from "@yielded/auth/Operations";
|
|
7
|
+
import {
|
|
8
|
+
DateTime,
|
|
9
|
+
Effect,
|
|
10
|
+
FileSystem,
|
|
11
|
+
Layer,
|
|
12
|
+
Path,
|
|
13
|
+
type Redacted,
|
|
14
|
+
Schema,
|
|
15
|
+
Semaphore,
|
|
16
|
+
} from "effect";
|
|
17
|
+
import { app, safeStorage } from "electron";
|
|
18
|
+
|
|
19
|
+
const recordSchema = BrowserLogin.VaultRecord;
|
|
20
|
+
const slots = recordSchema.fields.credentials.key.literals;
|
|
21
|
+
|
|
22
|
+
const recordJson = Schema.fromJsonString(recordSchema);
|
|
23
|
+
const failure = () => BrowserLogin.PlatformError.make({ reason: "storage" });
|
|
24
|
+
|
|
25
|
+
/** One writer per private application/environment path, protected by the host's
|
|
26
|
+
* single-instance lock. Never reset an unreadable store: it may contain the
|
|
27
|
+
* Exchanging fence. Writes fsync ciphertext, close, rename on the same filesystem,
|
|
28
|
+
* then sync the parent on POSIX (the replaced file on Windows).
|
|
29
|
+
*/
|
|
30
|
+
export const layerVault = (options: { readonly path: string }) =>
|
|
31
|
+
Layer.effect(
|
|
32
|
+
BrowserLogin.Vault,
|
|
33
|
+
Effect.gen(function* () {
|
|
34
|
+
const fs = yield* FileSystem.FileSystem;
|
|
35
|
+
const path = yield* Path.Path;
|
|
36
|
+
|
|
37
|
+
const checkEncryption = Effect.try({
|
|
38
|
+
try: () =>
|
|
39
|
+
process.type === "browser" &&
|
|
40
|
+
app.isReady() &&
|
|
41
|
+
app.hasSingleInstanceLock() &&
|
|
42
|
+
safeStorage.isEncryptionAvailable() &&
|
|
43
|
+
(process.platform !== "linux" ||
|
|
44
|
+
["gnome_libsecret", "kwallet", "kwallet5", "kwallet6"].includes(
|
|
45
|
+
safeStorage.getSelectedStorageBackend(),
|
|
46
|
+
)),
|
|
47
|
+
catch: failure,
|
|
48
|
+
}).pipe(Effect.flatMap((available) => (available ? Effect.void : Effect.fail(failure()))));
|
|
49
|
+
|
|
50
|
+
yield* checkEncryption;
|
|
51
|
+
|
|
52
|
+
const destination = yield* Schema.decodeEffect(
|
|
53
|
+
Schema.NonEmptyString.check(Schema.makeFilter((value) => path.isAbsolute(value))),
|
|
54
|
+
)(options.path).pipe(Effect.mapError(failure));
|
|
55
|
+
|
|
56
|
+
const directory = path.dirname(destination);
|
|
57
|
+
|
|
58
|
+
yield* fs
|
|
59
|
+
.makeDirectory(directory, { recursive: true, mode: 0o700 })
|
|
60
|
+
.pipe(Effect.mapError(failure));
|
|
61
|
+
const gate = yield* Semaphore.make(1);
|
|
62
|
+
|
|
63
|
+
const readRecord = Effect.gen(function* () {
|
|
64
|
+
yield* checkEncryption;
|
|
65
|
+
|
|
66
|
+
const stat = yield* fs
|
|
67
|
+
.stat(destination)
|
|
68
|
+
.pipe(
|
|
69
|
+
Effect.catchTag("PlatformError", (error) =>
|
|
70
|
+
error.reason._tag === "NotFound" ? Effect.void : Effect.fail(error),
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
if (stat === undefined) return { credentials: {} } satisfies typeof recordSchema.Type;
|
|
75
|
+
if (stat.type !== "File" || stat.size > 1048576) return yield* failure();
|
|
76
|
+
const bytes = yield* fs.readFile(destination);
|
|
77
|
+
|
|
78
|
+
const json = yield* Effect.try({
|
|
79
|
+
try: () => safeStorage.decryptString(Buffer.from(bytes)),
|
|
80
|
+
catch: failure,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return yield* Schema.decodeEffect(recordJson)(json, { onExcessProperty: "error" });
|
|
84
|
+
}).pipe(Effect.mapError(failure));
|
|
85
|
+
|
|
86
|
+
const writeRecord = Effect.fnUntraced(function* (record: typeof recordSchema.Type) {
|
|
87
|
+
yield* checkEncryption;
|
|
88
|
+
const json = yield* Schema.encodeEffect(recordJson)(record);
|
|
89
|
+
|
|
90
|
+
const bytes = yield* Effect.try({
|
|
91
|
+
try: () => safeStorage.encryptString(json),
|
|
92
|
+
catch: failure,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
yield* Effect.scoped(
|
|
96
|
+
Effect.gen(function* () {
|
|
97
|
+
const temporary = yield* fs.makeTempFileScoped({ directory, prefix: ".auth-" });
|
|
98
|
+
|
|
99
|
+
yield* fs.chmod(temporary, 0o600);
|
|
100
|
+
yield* Effect.scoped(
|
|
101
|
+
Effect.gen(function* () {
|
|
102
|
+
const file = yield* fs.open(temporary, { flag: "w", mode: 0o600 });
|
|
103
|
+
|
|
104
|
+
yield* file.writeAll(bytes);
|
|
105
|
+
yield* file.sync;
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
yield* fs.rename(temporary, destination);
|
|
109
|
+
|
|
110
|
+
const committed = yield* fs.open(
|
|
111
|
+
process.platform === "win32" ? destination : directory,
|
|
112
|
+
{ flag: process.platform === "win32" ? "r+" : "r" },
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
yield* committed.sync;
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
}, Effect.mapError(failure));
|
|
119
|
+
|
|
120
|
+
const locked = <A, E>(effect: Effect.Effect<A, E>) =>
|
|
121
|
+
gate.withPermits(1)(Effect.uninterruptible(effect));
|
|
122
|
+
|
|
123
|
+
const read: NativeCredentials["read"] = locked(
|
|
124
|
+
Effect.gen(function* () {
|
|
125
|
+
const record = yield* readRecord;
|
|
126
|
+
const now = DateTime.toEpochMillis(yield* DateTime.now);
|
|
127
|
+
const credentials: Partial<Record<CredentialSlot, Redacted.Redacted<string>>> = {};
|
|
128
|
+
|
|
129
|
+
for (const slot of slots) {
|
|
130
|
+
const value = record.credentials[slot];
|
|
131
|
+
|
|
132
|
+
if (value !== undefined && value.expiresAtMillis > now)
|
|
133
|
+
credentials[slot] = value.credential;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return credentials;
|
|
137
|
+
}),
|
|
138
|
+
).pipe(Effect.mapError(() => OperationHttpError.make({ reason: "credentials" })));
|
|
139
|
+
|
|
140
|
+
const accept: NativeCredentials["accept"] = (commands) =>
|
|
141
|
+
locked(
|
|
142
|
+
Effect.gen(function* () {
|
|
143
|
+
const record = yield* readRecord;
|
|
144
|
+
const credentials = { ...record.credentials };
|
|
145
|
+
|
|
146
|
+
for (const command of commands) {
|
|
147
|
+
if (command._tag === "Clear") delete credentials[command.slot];
|
|
148
|
+
else
|
|
149
|
+
credentials[command.slot] = {
|
|
150
|
+
credential: command.credential,
|
|
151
|
+
expiresAtMillis: command.expiresAtMillis,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
yield* writeRecord({ ...record, credentials });
|
|
155
|
+
}),
|
|
156
|
+
).pipe(Effect.mapError(() => OperationHttpError.make({ reason: "credentials" })));
|
|
157
|
+
|
|
158
|
+
return BrowserLogin.Vault.of({
|
|
159
|
+
read,
|
|
160
|
+
accept,
|
|
161
|
+
loadAttempt: locked(readRecord.pipe(Effect.map((record) => record.attempt))),
|
|
162
|
+
saveAttempt: (attempt) =>
|
|
163
|
+
locked(
|
|
164
|
+
Effect.gen(function* () {
|
|
165
|
+
const record = yield* readRecord;
|
|
166
|
+
|
|
167
|
+
yield* writeRecord(
|
|
168
|
+
attempt === undefined
|
|
169
|
+
? { credentials: record.credentials }
|
|
170
|
+
: { ...record, attempt },
|
|
171
|
+
);
|
|
172
|
+
}),
|
|
173
|
+
),
|
|
174
|
+
});
|
|
175
|
+
}),
|
|
176
|
+
);
|