@agentxm/registry-auth 0.28.4-bootstrap.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +110 -0
- package/dist/src/auth-client.d.ts +199 -0
- package/dist/src/auth-client.js +638 -0
- package/dist/src/auth-middleware.d.ts +28 -0
- package/dist/src/auth-middleware.js +105 -0
- package/dist/src/credential-store.d.ts +77 -0
- package/dist/src/credential-store.js +443 -0
- package/dist/src/device-login.d.ts +114 -0
- package/dist/src/device-login.js +300 -0
- package/dist/src/errors.d.ts +141 -0
- package/dist/src/errors.js +84 -0
- package/dist/src/guard.d.ts +24 -0
- package/dist/src/guard.js +62 -0
- package/dist/src/index.d.ts +36 -0
- package/dist/src/index.js +31 -0
- package/dist/src/internal/environment.d.ts +23 -0
- package/dist/src/internal/environment.js +45 -0
- package/dist/src/live.d.ts +13 -0
- package/dist/src/live.js +13 -0
- package/dist/src/login-interaction.d.ts +41 -0
- package/dist/src/login-interaction.js +108 -0
- package/dist/src/login-output.d.ts +22 -0
- package/dist/src/login-output.js +32 -0
- package/dist/src/login-presenter.d.ts +95 -0
- package/dist/src/login-presenter.js +58 -0
- package/dist/src/login-strategy.d.ts +21 -0
- package/dist/src/login-strategy.js +25 -0
- package/dist/src/loopback-login.d.ts +19 -0
- package/dist/src/loopback-login.js +91 -0
- package/dist/src/loopback-server.d.ts +40 -0
- package/dist/src/loopback-server.js +168 -0
- package/dist/src/oauth-contract.d.ts +7 -0
- package/dist/src/oauth-contract.js +2 -0
- package/dist/src/pending-device-login-store.d.ts +37 -0
- package/dist/src/pending-device-login-store.js +122 -0
- package/dist/src/publish-authorization.d.ts +11 -0
- package/dist/src/publish-authorization.js +79 -0
- package/dist/src/schema.d.ts +78 -0
- package/dist/src/schema.js +55 -0
- package/dist/src/testing.d.ts +14 -0
- package/dist/src/testing.js +14 -0
- package/dist/src/token-resolution.d.ts +79 -0
- package/dist/src/token-resolution.js +190 -0
- package/package.json +62 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authorization Code + PKCE login over a loopback redirect.
|
|
3
|
+
*
|
|
4
|
+
* @experimental This API is unstable and may change without notice.
|
|
5
|
+
*/
|
|
6
|
+
import * as Effect from "effect/Effect";
|
|
7
|
+
import { AuthClient } from "./auth-client.js";
|
|
8
|
+
import { CredentialStore } from "./credential-store.js";
|
|
9
|
+
import { DeviceLoginInteraction } from "./device-login.js";
|
|
10
|
+
import { AuthLoginPresenter } from "./login-presenter.js";
|
|
11
|
+
import { LoopbackCallbackRejected } from "./loopback-server.js";
|
|
12
|
+
export interface RunLoopbackLoginOptions {
|
|
13
|
+
readonly scopes?: ReadonlyArray<string>;
|
|
14
|
+
}
|
|
15
|
+
export declare const makePkceVerifier: () => string;
|
|
16
|
+
export declare const makePkceChallenge: (verifier: string) => string;
|
|
17
|
+
export declare const makeOAuthState: () => string;
|
|
18
|
+
export declare const runLoopbackLogin: (registryUrl: string, options?: RunLoopbackLoginOptions) => Effect.Effect<undefined, import("./errors.ts").AuthError | import("./loopback-server.js").LoopbackLoginFallback | LoopbackCallbackRejected, AuthClient | CredentialStore | AuthLoginPresenter | DeviceLoginInteraction>;
|
|
19
|
+
//# sourceMappingURL=loopback-login.d.ts.map
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authorization Code + PKCE login over a loopback redirect.
|
|
3
|
+
*
|
|
4
|
+
* @experimental This API is unstable and may change without notice.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
7
|
+
import * as DateTime from "effect/DateTime";
|
|
8
|
+
import * as Duration from "effect/Duration";
|
|
9
|
+
import * as Effect from "effect/Effect";
|
|
10
|
+
import * as Option from "effect/Option";
|
|
11
|
+
import { normalizeHandle } from "@agentxm/extension-model/unstable/extensions/handle";
|
|
12
|
+
import { AuthClient } from "./auth-client.js";
|
|
13
|
+
import { CredentialStore, makePersistedCredentialsUnsupportedError } from "./credential-store.js";
|
|
14
|
+
import { DeviceLoginInteraction } from "./device-login.js";
|
|
15
|
+
import { emitLoginSuccess } from "./login-output.js";
|
|
16
|
+
import { AuthLoginPresenter } from "./login-presenter.js";
|
|
17
|
+
import { LoopbackCallbackRejected, startLoopbackServer } from "./loopback-server.js";
|
|
18
|
+
const UNKNOWN_HANDLE = normalizeHandle("@unknown");
|
|
19
|
+
// Human approval window for the browser/login round trip. The issued
|
|
20
|
+
// authorization code remains shorter-lived on the registry side.
|
|
21
|
+
const LOOPBACK_TIMEOUT_MINUTES = 5;
|
|
22
|
+
const LOOPBACK_TIMEOUT = Duration.minutes(LOOPBACK_TIMEOUT_MINUTES);
|
|
23
|
+
export const makePkceVerifier = () => randomBytes(64).toString("base64url");
|
|
24
|
+
export const makePkceChallenge = (verifier) => createHash("sha256").update(verifier).digest("base64url");
|
|
25
|
+
export const makeOAuthState = () => randomBytes(32).toString("base64url");
|
|
26
|
+
const persistLoginCredentials = (registryUrl, token) => Effect.gen(function* () {
|
|
27
|
+
const authClient = yield* AuthClient;
|
|
28
|
+
const credStore = yield* CredentialStore;
|
|
29
|
+
const meResult = yield* authClient
|
|
30
|
+
.getMe(token.access_token)
|
|
31
|
+
.pipe(Effect.retry({ times: 1 }), Effect.option);
|
|
32
|
+
const handle = Option.match(meResult, {
|
|
33
|
+
onNone: () => UNKNOWN_HANDLE,
|
|
34
|
+
onSome: (me) => me.userHandle,
|
|
35
|
+
});
|
|
36
|
+
yield* credStore.save(registryUrl, handle, {
|
|
37
|
+
access_token: token.access_token,
|
|
38
|
+
refresh_token: token.refresh_token,
|
|
39
|
+
expires_at: token.expires_at,
|
|
40
|
+
});
|
|
41
|
+
return Option.map(meResult, (me) => me.userHandle);
|
|
42
|
+
});
|
|
43
|
+
export const runLoopbackLogin = (registryUrl, options = {}) => Effect.scoped(Effect.gen(function* () {
|
|
44
|
+
const authClient = yield* AuthClient;
|
|
45
|
+
const credStore = yield* CredentialStore;
|
|
46
|
+
const presenter = yield* AuthLoginPresenter;
|
|
47
|
+
const interaction = yield* DeviceLoginInteraction;
|
|
48
|
+
const registryHost = new URL(registryUrl).host;
|
|
49
|
+
if (!credStore.allowsPersistedCredentials) {
|
|
50
|
+
return yield* makePersistedCredentialsUnsupportedError();
|
|
51
|
+
}
|
|
52
|
+
const verifier = makePkceVerifier();
|
|
53
|
+
const challenge = makePkceChallenge(verifier);
|
|
54
|
+
const state = makeOAuthState();
|
|
55
|
+
const server = yield* startLoopbackServer(state);
|
|
56
|
+
const authorizeUrl = authClient.buildAuthorizeUrl({
|
|
57
|
+
challenge,
|
|
58
|
+
expiresAt: DateTime.addDuration(yield* DateTime.now, LOOPBACK_TIMEOUT),
|
|
59
|
+
state,
|
|
60
|
+
redirectUri: server.redirectUri,
|
|
61
|
+
...(options.scopes === undefined ? {} : { scopes: options.scopes }),
|
|
62
|
+
});
|
|
63
|
+
yield* presenter.presentLoopbackStart({
|
|
64
|
+
redirectUri: server.redirectUri,
|
|
65
|
+
authorizeUrl,
|
|
66
|
+
});
|
|
67
|
+
const openedBrowser = yield* interaction.openBrowser(authorizeUrl);
|
|
68
|
+
yield* presenter.noteLoopbackBrowserOutcome(openedBrowser);
|
|
69
|
+
const callback = yield* presenter.withProgress({
|
|
70
|
+
_tag: "WaitingForLoopbackAuthorization",
|
|
71
|
+
registryHost,
|
|
72
|
+
timeoutMinutes: LOOPBACK_TIMEOUT_MINUTES,
|
|
73
|
+
}, () => server.awaitCallback(Duration.toMillis(LOOPBACK_TIMEOUT)));
|
|
74
|
+
const expectedIssuer = authClient.getAuthorizationIssuer();
|
|
75
|
+
if (callback.iss !== expectedIssuer) {
|
|
76
|
+
return yield* new LoopbackCallbackRejected({
|
|
77
|
+
reason: "invalid_callback",
|
|
78
|
+
message: "Authorization callback issuer did not match.",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
const handle = yield* presenter.withProgress({ _tag: "CompletingSignIn", registryHost }, () => Effect.gen(function* () {
|
|
82
|
+
const token = yield* authClient.exchangePkceCode({
|
|
83
|
+
code: callback.code,
|
|
84
|
+
verifier,
|
|
85
|
+
redirectUri: server.redirectUri,
|
|
86
|
+
});
|
|
87
|
+
return yield* persistLoginCredentials(registryUrl, token);
|
|
88
|
+
}));
|
|
89
|
+
yield* emitLoginSuccess(registryUrl, handle);
|
|
90
|
+
}));
|
|
91
|
+
//# sourceMappingURL=loopback-login.js.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loopback callback listener for OAuth authorization-code login.
|
|
3
|
+
*
|
|
4
|
+
* @experimental This API is unstable and may change without notice.
|
|
5
|
+
*/
|
|
6
|
+
import * as Effect from "effect/Effect";
|
|
7
|
+
export interface LoopbackCallback {
|
|
8
|
+
readonly code: string;
|
|
9
|
+
readonly state: string;
|
|
10
|
+
readonly iss: string;
|
|
11
|
+
}
|
|
12
|
+
declare const LoopbackLoginFallback_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
|
|
13
|
+
readonly _tag: "LoopbackLoginFallback";
|
|
14
|
+
} & Readonly<A>;
|
|
15
|
+
export declare class LoopbackLoginFallback extends LoopbackLoginFallback_base<{
|
|
16
|
+
readonly reason: "bind_failed" | "timeout";
|
|
17
|
+
readonly message: string;
|
|
18
|
+
readonly cause?: unknown;
|
|
19
|
+
}> {
|
|
20
|
+
}
|
|
21
|
+
declare const LoopbackCallbackRejected_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => import("effect/Cause").YieldableError & {
|
|
22
|
+
readonly _tag: "LoopbackCallbackRejected";
|
|
23
|
+
} & Readonly<A>;
|
|
24
|
+
export declare class LoopbackCallbackRejected extends LoopbackCallbackRejected_base<{
|
|
25
|
+
readonly reason: "access_denied" | "invalid_callback";
|
|
26
|
+
readonly message: string;
|
|
27
|
+
}> {
|
|
28
|
+
}
|
|
29
|
+
export interface LoopbackServer {
|
|
30
|
+
readonly port: number;
|
|
31
|
+
readonly redirectUri: string;
|
|
32
|
+
readonly awaitCallback: (timeoutMs: number) => Effect.Effect<LoopbackCallback, LoopbackLoginFallback | LoopbackCallbackRejected>;
|
|
33
|
+
}
|
|
34
|
+
export declare const startLoopbackServer: (expectedState: string) => Effect.Effect<{
|
|
35
|
+
port: number;
|
|
36
|
+
redirectUri: string;
|
|
37
|
+
awaitCallback: (timeoutMs: number) => Effect.Effect<LoopbackCallback, LoopbackLoginFallback | LoopbackCallbackRejected, never>;
|
|
38
|
+
}, LoopbackLoginFallback, import("effect/Scope").Scope>;
|
|
39
|
+
export {};
|
|
40
|
+
//# sourceMappingURL=loopback-server.d.ts.map
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loopback callback listener for OAuth authorization-code login.
|
|
3
|
+
*
|
|
4
|
+
* @experimental This API is unstable and may change without notice.
|
|
5
|
+
*/
|
|
6
|
+
import * as Data from "effect/Data";
|
|
7
|
+
import * as Deferred from "effect/Deferred";
|
|
8
|
+
import * as Duration from "effect/Duration";
|
|
9
|
+
import * as Effect from "effect/Effect";
|
|
10
|
+
export class LoopbackLoginFallback extends Data.TaggedError("LoopbackLoginFallback") {
|
|
11
|
+
}
|
|
12
|
+
export class LoopbackCallbackRejected extends Data.TaggedError("LoopbackCallbackRejected") {
|
|
13
|
+
}
|
|
14
|
+
const page = (title, content) => `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title></head><body><main style="font-family: system-ui, sans-serif; margin: 3rem auto; max-width: 34rem;"><h1>${title}</h1><p>${content}</p></main></body></html>`;
|
|
15
|
+
const successPage = page("You’re signed in to AgentXM.ai", 'Return to your terminal to continue. You can close this tab. <a href="https://agentxm.ai">AgentXM.ai</a>');
|
|
16
|
+
const cancellationPage = page("Sign-in was cancelled", "No credentials were changed. Return to your terminal to try again.");
|
|
17
|
+
const errorPage = page("AXM sign-in could not be completed", "Return to your terminal for details and recovery instructions.");
|
|
18
|
+
const writeHtml = (response, statusCode, body) => {
|
|
19
|
+
response.writeHead(statusCode, {
|
|
20
|
+
"content-type": "text/html; charset=utf-8",
|
|
21
|
+
"cache-control": "no-store",
|
|
22
|
+
});
|
|
23
|
+
response.end(body);
|
|
24
|
+
};
|
|
25
|
+
const makeCallbackOutcome = (request, expectedState) => {
|
|
26
|
+
if (request.method !== "GET") {
|
|
27
|
+
return {
|
|
28
|
+
_tag: "failure",
|
|
29
|
+
error: new LoopbackCallbackRejected({
|
|
30
|
+
reason: "invalid_callback",
|
|
31
|
+
message: "Unexpected callback method.",
|
|
32
|
+
}),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const host = request.headers.host ?? "127.0.0.1";
|
|
36
|
+
const url = new URL(request.url ?? "/", `http://${host}`);
|
|
37
|
+
if (url.pathname !== "/callback") {
|
|
38
|
+
return {
|
|
39
|
+
_tag: "failure",
|
|
40
|
+
error: new LoopbackCallbackRejected({
|
|
41
|
+
reason: "invalid_callback",
|
|
42
|
+
message: "Unexpected callback path.",
|
|
43
|
+
}),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const state = url.searchParams.get("state");
|
|
47
|
+
if (state !== expectedState) {
|
|
48
|
+
return {
|
|
49
|
+
_tag: "failure",
|
|
50
|
+
error: new LoopbackCallbackRejected({
|
|
51
|
+
reason: "invalid_callback",
|
|
52
|
+
message: "OAuth state did not match.",
|
|
53
|
+
}),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const error = url.searchParams.get("error");
|
|
57
|
+
if (error !== null) {
|
|
58
|
+
return {
|
|
59
|
+
_tag: "failure",
|
|
60
|
+
error: new LoopbackCallbackRejected({
|
|
61
|
+
reason: error === "access_denied" ? "access_denied" : "invalid_callback",
|
|
62
|
+
message: `Authorization failed: ${error}.`,
|
|
63
|
+
}),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const code = url.searchParams.get("code");
|
|
67
|
+
const iss = url.searchParams.get("iss");
|
|
68
|
+
if (code === null || iss === null) {
|
|
69
|
+
return {
|
|
70
|
+
_tag: "failure",
|
|
71
|
+
error: new LoopbackCallbackRejected({
|
|
72
|
+
reason: "invalid_callback",
|
|
73
|
+
message: "Authorization callback was incomplete.",
|
|
74
|
+
}),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
_tag: "success",
|
|
79
|
+
callback: { code, state, iss },
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
export const startLoopbackServer = (expectedState) => Effect.gen(function* () {
|
|
83
|
+
const http = yield* Effect.tryPromise({
|
|
84
|
+
try: () => import("node:http"),
|
|
85
|
+
catch: (cause) => new LoopbackLoginFallback({
|
|
86
|
+
reason: "bind_failed",
|
|
87
|
+
message: "Could not load the local HTTP server.",
|
|
88
|
+
cause,
|
|
89
|
+
}),
|
|
90
|
+
});
|
|
91
|
+
const callback = yield* Deferred.make();
|
|
92
|
+
const listener = yield* Effect.acquireRelease(Effect.callback((resume) => {
|
|
93
|
+
let acquired = false;
|
|
94
|
+
const server = http.createServer((request, response) => {
|
|
95
|
+
const outcome = makeCallbackOutcome(request, expectedState);
|
|
96
|
+
writeHtml(response, outcome._tag === "success" ? 200 : 400, outcome._tag === "success"
|
|
97
|
+
? successPage
|
|
98
|
+
: outcome.error._tag === "LoopbackCallbackRejected" &&
|
|
99
|
+
outcome.error.reason === "access_denied"
|
|
100
|
+
? cancellationPage
|
|
101
|
+
: errorPage);
|
|
102
|
+
Deferred.doneUnsafe(callback, outcome._tag === "success"
|
|
103
|
+
? Effect.succeed(outcome.callback)
|
|
104
|
+
: Effect.fail(outcome.error));
|
|
105
|
+
});
|
|
106
|
+
const onBindError = (cause) => {
|
|
107
|
+
if (acquired)
|
|
108
|
+
return;
|
|
109
|
+
acquired = true;
|
|
110
|
+
resume(Effect.fail(new LoopbackLoginFallback({
|
|
111
|
+
reason: "bind_failed",
|
|
112
|
+
message: "Could not bind a local callback port.",
|
|
113
|
+
cause,
|
|
114
|
+
})));
|
|
115
|
+
};
|
|
116
|
+
server.once("error", onBindError);
|
|
117
|
+
server.listen({ host: "127.0.0.1", port: 0, exclusive: process.platform === "win32" }, () => {
|
|
118
|
+
if (acquired)
|
|
119
|
+
return;
|
|
120
|
+
const address = server.address();
|
|
121
|
+
if (typeof address !== "object" || address === null) {
|
|
122
|
+
acquired = true;
|
|
123
|
+
server.close();
|
|
124
|
+
resume(Effect.fail(new LoopbackLoginFallback({
|
|
125
|
+
reason: "bind_failed",
|
|
126
|
+
message: "Could not determine the local callback port.",
|
|
127
|
+
})));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
acquired = true;
|
|
131
|
+
server.off("error", onBindError);
|
|
132
|
+
resume(Effect.succeed({
|
|
133
|
+
server,
|
|
134
|
+
port: address.port,
|
|
135
|
+
}));
|
|
136
|
+
});
|
|
137
|
+
return Effect.sync(() => {
|
|
138
|
+
if (!acquired)
|
|
139
|
+
server.close();
|
|
140
|
+
});
|
|
141
|
+
}), ({ server }) => Effect.try({
|
|
142
|
+
try: () => {
|
|
143
|
+
if (server.listening)
|
|
144
|
+
server.close();
|
|
145
|
+
server.closeAllConnections();
|
|
146
|
+
},
|
|
147
|
+
catch: () => undefined,
|
|
148
|
+
}).pipe(Effect.ignore));
|
|
149
|
+
listener.server.on("error", (cause) => {
|
|
150
|
+
Deferred.doneUnsafe(callback, Effect.fail(new LoopbackLoginFallback({
|
|
151
|
+
reason: "bind_failed",
|
|
152
|
+
message: "The local callback server failed.",
|
|
153
|
+
cause,
|
|
154
|
+
})));
|
|
155
|
+
});
|
|
156
|
+
return {
|
|
157
|
+
port: listener.port,
|
|
158
|
+
redirectUri: `http://127.0.0.1:${listener.port}/callback`,
|
|
159
|
+
awaitCallback: (timeoutMs) => Deferred.await(callback).pipe(Effect.timeoutOrElse({
|
|
160
|
+
duration: Duration.millis(timeoutMs),
|
|
161
|
+
orElse: () => Effect.fail(new LoopbackLoginFallback({
|
|
162
|
+
reason: "timeout",
|
|
163
|
+
message: "Timed out waiting for the browser callback.",
|
|
164
|
+
})),
|
|
165
|
+
})),
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
//# sourceMappingURL=loopback-server.js.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Restricted local persistence for resumable OAuth device authorization.
|
|
3
|
+
*
|
|
4
|
+
* @experimental This API is unstable and may change without notice.
|
|
5
|
+
*/
|
|
6
|
+
import * as FileSystem from "effect/FileSystem";
|
|
7
|
+
import * as Path from "effect/Path";
|
|
8
|
+
import * as ServiceMap from "effect/Context";
|
|
9
|
+
import * as Effect from "effect/Effect";
|
|
10
|
+
import * as Layer from "effect/Layer";
|
|
11
|
+
import * as Option from "effect/Option";
|
|
12
|
+
import * as Schema from "effect/Schema";
|
|
13
|
+
import { RegistryAuthFailed } from "./errors.js";
|
|
14
|
+
export declare const PendingDeviceLoginSchema: Schema.Struct<{
|
|
15
|
+
readonly version: Schema.Literal<2>;
|
|
16
|
+
readonly registryUrl: Schema.String;
|
|
17
|
+
readonly deviceCode: Schema.String;
|
|
18
|
+
readonly userCode: Schema.String;
|
|
19
|
+
readonly verificationUri: Schema.String;
|
|
20
|
+
readonly verificationUriComplete: Schema.String;
|
|
21
|
+
readonly requestedScopes: Schema.$Array<Schema.String>;
|
|
22
|
+
readonly interval: Schema.Number;
|
|
23
|
+
readonly expiresAt: Schema.decodeTo<Schema.DateTimeUtc, Schema.String, never, never>;
|
|
24
|
+
}>;
|
|
25
|
+
export type PendingDeviceLogin = typeof PendingDeviceLoginSchema.Type;
|
|
26
|
+
export interface PendingDeviceLoginStoreService {
|
|
27
|
+
readonly save: (pending: PendingDeviceLogin) => Effect.Effect<void, RegistryAuthFailed>;
|
|
28
|
+
readonly load: () => Effect.Effect<Option.Option<PendingDeviceLogin>, RegistryAuthFailed>;
|
|
29
|
+
readonly clear: () => Effect.Effect<void, RegistryAuthFailed>;
|
|
30
|
+
}
|
|
31
|
+
declare const PendingDeviceLoginStore_base: ServiceMap.ServiceClass<PendingDeviceLoginStore, "@agentxm/registry-auth/pending-device-login-store/PendingDeviceLoginStore", PendingDeviceLoginStoreService>;
|
|
32
|
+
export declare class PendingDeviceLoginStore extends PendingDeviceLoginStore_base {
|
|
33
|
+
}
|
|
34
|
+
export declare const PendingDeviceLoginStoreLive: Layer.Layer<PendingDeviceLoginStore, never, FileSystem.FileSystem | Path.Path>;
|
|
35
|
+
export declare const PendingDeviceLoginStoreTest: (initial?: PendingDeviceLogin) => Layer.Layer<PendingDeviceLoginStore, never, never>;
|
|
36
|
+
export {};
|
|
37
|
+
//# sourceMappingURL=pending-device-login-store.d.ts.map
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Restricted local persistence for resumable OAuth device authorization.
|
|
3
|
+
*
|
|
4
|
+
* @experimental This API is unstable and may change without notice.
|
|
5
|
+
*/
|
|
6
|
+
import * as FileSystem from "effect/FileSystem";
|
|
7
|
+
import * as Path from "effect/Path";
|
|
8
|
+
import * as ServiceMap from "effect/Context";
|
|
9
|
+
import * as Effect from "effect/Effect";
|
|
10
|
+
import * as Layer from "effect/Layer";
|
|
11
|
+
import * as Option from "effect/Option";
|
|
12
|
+
import * as Schema from "effect/Schema";
|
|
13
|
+
import { DateTimeUtcSchema } from "@agentxm/extension-model/unstable/date-time";
|
|
14
|
+
import { RegistryAuthFailed } from "./errors.js";
|
|
15
|
+
import { envOption } from "./internal/environment.js";
|
|
16
|
+
export const PendingDeviceLoginSchema = Schema.Struct({
|
|
17
|
+
version: Schema.Literal(2),
|
|
18
|
+
registryUrl: Schema.String,
|
|
19
|
+
deviceCode: Schema.String,
|
|
20
|
+
userCode: Schema.String,
|
|
21
|
+
verificationUri: Schema.String,
|
|
22
|
+
verificationUriComplete: Schema.String,
|
|
23
|
+
requestedScopes: Schema.Array(Schema.String),
|
|
24
|
+
interval: Schema.Number,
|
|
25
|
+
expiresAt: DateTimeUtcSchema,
|
|
26
|
+
}).annotate({
|
|
27
|
+
identifier: "PendingDeviceLogin",
|
|
28
|
+
title: "Pending Device Login",
|
|
29
|
+
description: "One resumable OAuth device authorization flow.",
|
|
30
|
+
});
|
|
31
|
+
export class PendingDeviceLoginStore extends ServiceMap.Service()("@agentxm/registry-auth/pending-device-login-store/PendingDeviceLoginStore") {
|
|
32
|
+
}
|
|
33
|
+
const PENDING_LOGIN_FILENAME = "pending-login.json";
|
|
34
|
+
const DIR_PERMISSIONS = 0o700;
|
|
35
|
+
const FILE_PERMISSIONS = 0o600;
|
|
36
|
+
const resolveHomeDir = (values) => {
|
|
37
|
+
for (const value of values) {
|
|
38
|
+
if (Option.isSome(value))
|
|
39
|
+
return value.value;
|
|
40
|
+
}
|
|
41
|
+
return "/tmp";
|
|
42
|
+
};
|
|
43
|
+
const storeError = (detail, cause) => new RegistryAuthFailed({ category: "auth", detail, cause });
|
|
44
|
+
export const PendingDeviceLoginStoreLive = Layer.effect(PendingDeviceLoginStore, Effect.gen(function* () {
|
|
45
|
+
const fs = yield* FileSystem.FileSystem;
|
|
46
|
+
const path = yield* Path.Path;
|
|
47
|
+
const homeDir = resolveHomeDir([
|
|
48
|
+
yield* envOption("AXM_USER_HOME"),
|
|
49
|
+
yield* envOption("HOME"),
|
|
50
|
+
yield* envOption("USERPROFILE"),
|
|
51
|
+
yield* envOption("HOMEPATH"),
|
|
52
|
+
]);
|
|
53
|
+
const directory = path.join(homeDir, ".axm");
|
|
54
|
+
const filePath = path.join(directory, PENDING_LOGIN_FILENAME);
|
|
55
|
+
const temporaryPath = path.join(directory, `${PENDING_LOGIN_FILENAME}.tmp`);
|
|
56
|
+
const ensureDirectory = Effect.gen(function* () {
|
|
57
|
+
yield* fs
|
|
58
|
+
.makeDirectory(directory, { recursive: true })
|
|
59
|
+
.pipe(Effect.mapError((error) => storeError("Could not create pending login storage", error)));
|
|
60
|
+
yield* fs.chmod(directory, DIR_PERMISSIONS).pipe(Effect.catch(() => Effect.void));
|
|
61
|
+
});
|
|
62
|
+
const save = Effect.fn("PendingDeviceLoginStore.save")(function* (pending) {
|
|
63
|
+
yield* ensureDirectory;
|
|
64
|
+
const encoded = yield* Schema.encodeEffect(PendingDeviceLoginSchema)(pending).pipe(Effect.mapError((error) => storeError("Could not encode pending login", error)));
|
|
65
|
+
yield* fs.chmod(temporaryPath, FILE_PERMISSIONS).pipe(Effect.catch(() => Effect.void));
|
|
66
|
+
yield* fs
|
|
67
|
+
.writeFileString(temporaryPath, JSON.stringify(encoded, null, 2), {
|
|
68
|
+
mode: FILE_PERMISSIONS,
|
|
69
|
+
})
|
|
70
|
+
.pipe(Effect.mapError((error) => storeError("Could not persist pending login", error)), Effect.tapError(() => fs.remove(temporaryPath).pipe(Effect.catch(() => Effect.void))));
|
|
71
|
+
yield* fs.chmod(temporaryPath, FILE_PERMISSIONS).pipe(Effect.catch(() => Effect.void));
|
|
72
|
+
yield* fs.rename(temporaryPath, filePath).pipe(Effect.mapError((error) => storeError("Could not persist pending login", error)), Effect.tapError(() => fs.remove(temporaryPath).pipe(Effect.catch(() => Effect.void))));
|
|
73
|
+
yield* fs.chmod(filePath, FILE_PERMISSIONS).pipe(Effect.catch(() => Effect.void));
|
|
74
|
+
});
|
|
75
|
+
const load = Effect.fn("PendingDeviceLoginStore.load")(function* () {
|
|
76
|
+
const exists = yield* fs.exists(filePath).pipe(Effect.catch(() => Effect.succeed(false)));
|
|
77
|
+
if (!exists)
|
|
78
|
+
return Option.none();
|
|
79
|
+
const content = yield* fs
|
|
80
|
+
.readFileString(filePath)
|
|
81
|
+
.pipe(Effect.mapError((error) => storeError("Could not read pending login", error)));
|
|
82
|
+
return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(PendingDeviceLoginSchema))(content).pipe(Effect.map(Option.some), Effect.mapError((error) => new RegistryAuthFailed({
|
|
83
|
+
category: "auth",
|
|
84
|
+
detail: "Pending login storage was invalid and has been removed. Start a new device sign-in.",
|
|
85
|
+
suggestions: [
|
|
86
|
+
{
|
|
87
|
+
description: "Start a new device sign-in.",
|
|
88
|
+
cmd: "axm login --device-code --json",
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
cause: error,
|
|
92
|
+
})), Effect.tapError(() => fs.remove(filePath).pipe(Effect.catch(() => Effect.void))));
|
|
93
|
+
});
|
|
94
|
+
const clear = Effect.fn("PendingDeviceLoginStore.clear")(function* () {
|
|
95
|
+
const exists = yield* fs.exists(filePath).pipe(Effect.catch(() => Effect.succeed(false)));
|
|
96
|
+
if (exists) {
|
|
97
|
+
yield* fs
|
|
98
|
+
.remove(filePath)
|
|
99
|
+
.pipe(Effect.mapError((error) => storeError("Could not clear pending login", error)));
|
|
100
|
+
}
|
|
101
|
+
const temporaryExists = yield* fs
|
|
102
|
+
.exists(temporaryPath)
|
|
103
|
+
.pipe(Effect.catch(() => Effect.succeed(false)));
|
|
104
|
+
if (temporaryExists) {
|
|
105
|
+
yield* fs.remove(temporaryPath).pipe(Effect.catch(() => Effect.void));
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
return { save, load, clear };
|
|
109
|
+
}));
|
|
110
|
+
export const PendingDeviceLoginStoreTest = (initial) => {
|
|
111
|
+
let value = initial === undefined ? Option.none() : Option.some(initial);
|
|
112
|
+
return Layer.succeed(PendingDeviceLoginStore, {
|
|
113
|
+
save: (pending) => Effect.sync(() => {
|
|
114
|
+
value = Option.some(pending);
|
|
115
|
+
}),
|
|
116
|
+
load: () => Effect.sync(() => value),
|
|
117
|
+
clear: () => Effect.sync(() => {
|
|
118
|
+
value = Option.none();
|
|
119
|
+
}),
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
//# sourceMappingURL=pending-device-login-store.js.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import type { PreviewPublicationSetRequest } from "@agentxm/registry-protocol/unstable/registry/publication-set";
|
|
3
|
+
import { AuthClient, type PublishAuthorizationExchangeResponse } from "./auth-client.js";
|
|
4
|
+
import { DeviceLoginInteraction } from "./device-login.js";
|
|
5
|
+
import { AuthLoginPresenter } from "./login-presenter.js";
|
|
6
|
+
export interface PublishAuthorizationInput {
|
|
7
|
+
readonly registryUrl: string;
|
|
8
|
+
readonly publicationSet: PreviewPublicationSetRequest;
|
|
9
|
+
}
|
|
10
|
+
export declare const runPublishAuthorization: (input: PublishAuthorizationInput) => Effect.Effect<PublishAuthorizationExchangeResponse, import("./errors.js").AuthError, AuthClient | AuthLoginPresenter | DeviceLoginInteraction>;
|
|
11
|
+
//# sourceMappingURL=publish-authorization.d.ts.map
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import { RegistryAuthFailed } from "./errors.js";
|
|
3
|
+
import { AuthClient } from "./auth-client.js";
|
|
4
|
+
import { DeviceLoginInteraction } from "./device-login.js";
|
|
5
|
+
import { AuthLoginPresenter } from "./login-presenter.js";
|
|
6
|
+
import { LoopbackCallbackRejected, LoopbackLoginFallback, startLoopbackServer, } from "./loopback-server.js";
|
|
7
|
+
import { makeOAuthState, makePkceChallenge, makePkceVerifier } from "./loopback-login.js";
|
|
8
|
+
const PUBLISH_AUTHORIZATION_TIMEOUT_MS = 10 * 60_000;
|
|
9
|
+
const loopbackFailureToAuthFailure = (error) => {
|
|
10
|
+
if (error._tag === "LoopbackCallbackRejected" && error.reason === "access_denied") {
|
|
11
|
+
return new RegistryAuthFailed({
|
|
12
|
+
category: "auth",
|
|
13
|
+
detail: "Publish authorization was denied",
|
|
14
|
+
suggestions: [{ description: "Rerun publish when you are ready to review it again." }],
|
|
15
|
+
cause: error,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
if (error._tag === "LoopbackLoginFallback" && error.reason === "timeout") {
|
|
19
|
+
return new RegistryAuthFailed({
|
|
20
|
+
category: "auth",
|
|
21
|
+
detail: "Publish authorization expired before approval",
|
|
22
|
+
suggestions: [{ description: "Rerun publish to create a new request." }],
|
|
23
|
+
cause: error,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
return new RegistryAuthFailed({
|
|
27
|
+
category: "auth",
|
|
28
|
+
detail: `Publish loopback callback failed: ${error.message}`,
|
|
29
|
+
suggestions: [
|
|
30
|
+
{
|
|
31
|
+
description: "Check that local loopback connections to 127.0.0.1 are allowed, then rerun publish.",
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
cause: error,
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
export const runPublishAuthorization = Effect.fn("Auth.runPublishAuthorization")(function* (input) {
|
|
38
|
+
return yield* Effect.scoped(Effect.gen(function* () {
|
|
39
|
+
const authClient = yield* AuthClient;
|
|
40
|
+
const presenter = yield* AuthLoginPresenter;
|
|
41
|
+
const interaction = yield* DeviceLoginInteraction;
|
|
42
|
+
const verifier = makePkceVerifier();
|
|
43
|
+
const challenge = makePkceChallenge(verifier);
|
|
44
|
+
const state = makeOAuthState();
|
|
45
|
+
const server = yield* startLoopbackServer(state).pipe(Effect.mapError(loopbackFailureToAuthFailure));
|
|
46
|
+
const request = yield* authClient.createPublishAuthorizationRequest({
|
|
47
|
+
registryUrl: input.registryUrl,
|
|
48
|
+
redirectUri: server.redirectUri,
|
|
49
|
+
state,
|
|
50
|
+
codeChallenge: challenge,
|
|
51
|
+
publicationSet: input.publicationSet,
|
|
52
|
+
});
|
|
53
|
+
const openedBrowser = yield* interaction.openBrowser(request.authorizationUrl);
|
|
54
|
+
yield* presenter.notePublishReview({
|
|
55
|
+
browserOpened: openedBrowser,
|
|
56
|
+
candidateCount: input.publicationSet.candidates.length,
|
|
57
|
+
authorizationUrl: request.authorizationUrl,
|
|
58
|
+
});
|
|
59
|
+
const callback = yield* server
|
|
60
|
+
.awaitCallback(PUBLISH_AUTHORIZATION_TIMEOUT_MS)
|
|
61
|
+
.pipe(Effect.mapError(loopbackFailureToAuthFailure));
|
|
62
|
+
const expectedIssuer = new URL(request.authorizationUrl).origin;
|
|
63
|
+
if (callback.iss !== expectedIssuer) {
|
|
64
|
+
return yield* new RegistryAuthFailed({
|
|
65
|
+
category: "auth",
|
|
66
|
+
detail: "Publish authorization callback issuer did not match",
|
|
67
|
+
cause: { expectedIssuer, receivedIssuer: callback.iss },
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const capability = yield* authClient.exchangePublishAuthorizationCode({
|
|
71
|
+
registryUrl: input.registryUrl,
|
|
72
|
+
code: callback.code,
|
|
73
|
+
verifier,
|
|
74
|
+
redirectUri: server.redirectUri,
|
|
75
|
+
});
|
|
76
|
+
return capability;
|
|
77
|
+
}));
|
|
78
|
+
}, Effect.satisfiesSuccessType());
|
|
79
|
+
//# sourceMappingURL=publish-authorization.js.map
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema definitions for credential storage and token sources.
|
|
3
|
+
*
|
|
4
|
+
* @experimental This API is unstable and may change without notice.
|
|
5
|
+
*/
|
|
6
|
+
import type * as DateTime from "effect/DateTime";
|
|
7
|
+
import * as Schema from "effect/Schema";
|
|
8
|
+
import { type Handle } from "@agentxm/extension-model/unstable/extensions/handle";
|
|
9
|
+
export declare const CredentialEntrySchema: Schema.Struct<{
|
|
10
|
+
readonly access_token: Schema.String;
|
|
11
|
+
readonly refresh_token: Schema.String;
|
|
12
|
+
readonly expires_at: Schema.decodeTo<Schema.DateTimeUtc, Schema.String, never, never>;
|
|
13
|
+
readonly active: Schema.Boolean;
|
|
14
|
+
}>;
|
|
15
|
+
export type CredentialEntry = Schema.Schema.Type<typeof CredentialEntrySchema>;
|
|
16
|
+
export declare const RegistryAccountsSchema: Schema.Struct<{
|
|
17
|
+
readonly accounts: Schema.$Record<Schema.brand<Schema.String, "Handle">, Schema.Struct<{
|
|
18
|
+
readonly access_token: Schema.String;
|
|
19
|
+
readonly refresh_token: Schema.String;
|
|
20
|
+
readonly expires_at: Schema.decodeTo<Schema.DateTimeUtc, Schema.String, never, never>;
|
|
21
|
+
readonly active: Schema.Boolean;
|
|
22
|
+
}>>;
|
|
23
|
+
}>;
|
|
24
|
+
export type RegistryAccounts = Schema.Schema.Type<typeof RegistryAccountsSchema>;
|
|
25
|
+
export declare const CredentialFileSchema: Schema.Struct<{
|
|
26
|
+
readonly version: Schema.Literal<1>;
|
|
27
|
+
readonly registries: Schema.$Record<Schema.String, Schema.Struct<{
|
|
28
|
+
readonly accounts: Schema.$Record<Schema.brand<Schema.String, "Handle">, Schema.Struct<{
|
|
29
|
+
readonly access_token: Schema.String;
|
|
30
|
+
readonly refresh_token: Schema.String;
|
|
31
|
+
readonly expires_at: Schema.decodeTo<Schema.DateTimeUtc, Schema.String, never, never>;
|
|
32
|
+
readonly active: Schema.Boolean;
|
|
33
|
+
}>>;
|
|
34
|
+
}>>;
|
|
35
|
+
}>;
|
|
36
|
+
export type CredentialFile = Schema.Schema.Type<typeof CredentialFileSchema>;
|
|
37
|
+
declare const EnvVarTokenSource_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => Readonly<A> & {
|
|
38
|
+
readonly _tag: "EnvVar";
|
|
39
|
+
} & import("effect/Pipeable").Pipeable;
|
|
40
|
+
export declare class EnvVarTokenSource extends EnvVarTokenSource_base<{
|
|
41
|
+
readonly token: string;
|
|
42
|
+
}> {
|
|
43
|
+
}
|
|
44
|
+
declare const FlagTokenSource_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => Readonly<A> & {
|
|
45
|
+
readonly _tag: "Flag";
|
|
46
|
+
} & import("effect/Pipeable").Pipeable;
|
|
47
|
+
export declare class FlagTokenSource extends FlagTokenSource_base<{
|
|
48
|
+
readonly token: string;
|
|
49
|
+
}> {
|
|
50
|
+
}
|
|
51
|
+
declare const FileTokenSource_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => Readonly<A> & {
|
|
52
|
+
readonly _tag: "File";
|
|
53
|
+
} & import("effect/Pipeable").Pipeable;
|
|
54
|
+
export declare class FileTokenSource extends FileTokenSource_base<{
|
|
55
|
+
readonly token: string;
|
|
56
|
+
readonly path: string;
|
|
57
|
+
}> {
|
|
58
|
+
}
|
|
59
|
+
declare const CredentialStoreTokenSource_base: new <A extends Record<string, any> = {}>(args: import("effect/Types").VoidIfEmpty<{ readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }>) => Readonly<A> & {
|
|
60
|
+
readonly _tag: "CredentialStore";
|
|
61
|
+
} & import("effect/Pipeable").Pipeable;
|
|
62
|
+
export declare class CredentialStoreTokenSource extends CredentialStoreTokenSource_base<{
|
|
63
|
+
readonly token: string;
|
|
64
|
+
readonly refresh_token: string;
|
|
65
|
+
readonly expires_at: DateTime.Utc;
|
|
66
|
+
readonly registryUrl: string;
|
|
67
|
+
}> {
|
|
68
|
+
}
|
|
69
|
+
export type TokenSource = EnvVarTokenSource | FileTokenSource | FlagTokenSource | CredentialStoreTokenSource;
|
|
70
|
+
export interface StoredCredentials {
|
|
71
|
+
readonly handle: Handle;
|
|
72
|
+
readonly access_token: string;
|
|
73
|
+
readonly refresh_token: string;
|
|
74
|
+
readonly expires_at: DateTime.Utc;
|
|
75
|
+
}
|
|
76
|
+
export type StorageTier = "keychain" | "restricted-file" | "plaintext-file";
|
|
77
|
+
export {};
|
|
78
|
+
//# sourceMappingURL=schema.d.ts.map
|