@agentxm/registry-auth 0.28.11 → 0.28.13
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/dist/src/auth-client.d.ts +13 -5
- package/dist/src/auth-client.js +80 -39
- package/dist/src/credential-store.d.ts +39 -2
- package/dist/src/credential-store.js +50 -46
- package/dist/src/device-login.d.ts +20 -8
- package/dist/src/device-login.js +9 -4
- package/dist/src/errors.d.ts +37 -12
- package/dist/src/errors.js +20 -0
- package/dist/src/identity.d.ts +46 -0
- package/dist/src/identity.js +49 -0
- package/dist/src/index.d.ts +12 -2
- package/dist/src/index.js +14 -1
- package/dist/src/internal/environment.d.ts +23 -5
- package/dist/src/internal/environment.js +59 -9
- package/dist/src/live.d.ts +1 -0
- package/dist/src/live.js +1 -0
- package/dist/src/login-presenter.d.ts +55 -0
- package/dist/src/login-presenter.js +22 -0
- package/dist/src/login-suggestion.d.ts +18 -0
- package/dist/src/login-suggestion.js +34 -0
- package/dist/src/login.d.ts +80 -0
- package/dist/src/login.js +155 -0
- package/dist/src/logout.d.ts +35 -0
- package/dist/src/logout.js +37 -0
- package/dist/src/loopback-login.js +2 -1
- package/dist/src/loopback-server.d.ts +3 -1
- package/dist/src/loopback-server.js +51 -7
- package/dist/src/pending-publish-authorization-store.d.ts +33 -0
- package/dist/src/pending-publish-authorization-store.js +90 -0
- package/dist/src/publish-authorization-polling.d.ts +13 -0
- package/dist/src/publish-authorization-polling.js +176 -0
- package/dist/src/publish-authorization.d.ts +4 -1
- package/dist/src/publish-authorization.js +24 -5
- package/dist/src/selected-registry.d.ts +25 -0
- package/dist/src/selected-registry.js +18 -0
- package/dist/src/step-up.d.ts +49 -0
- package/dist/src/step-up.js +174 -0
- package/dist/src/testing.d.ts +1 -0
- package/dist/src/testing.js +1 -0
- package/dist/src/tokens.d.ts +57 -0
- package/dist/src/tokens.js +78 -0
- package/package.json +7 -6
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sign-in orchestration: flag coherence, persisted-credential policy, the
|
|
3
|
+
* existing-session decision, strategy selection, and the loopback →
|
|
4
|
+
* device-code fallback chain.
|
|
5
|
+
*
|
|
6
|
+
* The capability decides; the application supplies presentation through
|
|
7
|
+
* `AuthLoginPresenter` and platform integration through
|
|
8
|
+
* `AuthLoginInteraction`. No sign-in decision is taken outside this module.
|
|
9
|
+
*
|
|
10
|
+
* @experimental This API is unstable and may change without notice.
|
|
11
|
+
*/
|
|
12
|
+
import * as Effect from "effect/Effect";
|
|
13
|
+
import * as Option from "effect/Option";
|
|
14
|
+
import { AuthClient } from "./auth-client.js";
|
|
15
|
+
import { CredentialStore, makePersistedCredentialsUnsupportedError } from "./credential-store.js";
|
|
16
|
+
import { initiateDeviceLogin, resumeDeviceLogin, runDeviceLogin, } from "./device-login.js";
|
|
17
|
+
import { RegistryAuthFailed } from "./errors.js";
|
|
18
|
+
import { AuthLoginPresenter } from "./login-presenter.js";
|
|
19
|
+
import { runLoopbackLogin } from "./loopback-login.js";
|
|
20
|
+
import { selectLoginStrategy } from "./login-strategy.js";
|
|
21
|
+
import { loginStrategyEnvironment } from "./internal/environment.js";
|
|
22
|
+
const usage = (detail) => new RegistryAuthFailed({ category: "usage", detail });
|
|
23
|
+
/**
|
|
24
|
+
* The device-login options one sign-in request means. Exported so the option
|
|
25
|
+
* shaping is provable without substituting the flow it is handed to.
|
|
26
|
+
*/
|
|
27
|
+
export const deviceLoginOptions = (request, openBrowser) => ({
|
|
28
|
+
openBrowser,
|
|
29
|
+
restart: request.restart,
|
|
30
|
+
...(request.scopes.length === 0 ? {} : { scopes: [...request.scopes] }),
|
|
31
|
+
});
|
|
32
|
+
/** The resume options one `--wait` request means. */
|
|
33
|
+
export const resumeLoginOptions = (request) => request.timeoutSeconds === undefined ? {} : { timeoutSeconds: request.timeoutSeconds };
|
|
34
|
+
/**
|
|
35
|
+
* How a loopback sign-in that could not complete is classified.
|
|
36
|
+
*
|
|
37
|
+
* A failed bind is recoverable — device-code sign-in replaces it — while an
|
|
38
|
+
* expired wait or a rejected callback is terminal and leaves credentials
|
|
39
|
+
* untouched.
|
|
40
|
+
*/
|
|
41
|
+
export const classifyLoopbackFailure = (error) => error._tag === "LoopbackLoginFallback"
|
|
42
|
+
? new RegistryAuthFailed({
|
|
43
|
+
category: "auth",
|
|
44
|
+
detail: "Browser sign-in expired after 5 minutes. No credentials were changed.",
|
|
45
|
+
suggestions: [
|
|
46
|
+
{ description: "Try browser sign-in again.", cmd: "axm login" },
|
|
47
|
+
{
|
|
48
|
+
description: "Use device-code sign-in on a remote or headless machine.",
|
|
49
|
+
cmd: "axm login --device-code",
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
cause: error,
|
|
53
|
+
})
|
|
54
|
+
: new RegistryAuthFailed({
|
|
55
|
+
category: "auth",
|
|
56
|
+
detail: error.reason === "access_denied"
|
|
57
|
+
? "Sign-in was cancelled. No credentials were changed."
|
|
58
|
+
: "The authorization callback was invalid and sign-in could not be completed. Run `axm login` to try again.",
|
|
59
|
+
suggestions: [{ description: "Try signing in again.", cmd: "axm login" }],
|
|
60
|
+
cause: error,
|
|
61
|
+
});
|
|
62
|
+
const rejectIncoherentFlags = (request) => request.wait && request.deviceCode
|
|
63
|
+
? Option.some(usage("--wait resumes an existing device sign-in and cannot be combined with --device-code."))
|
|
64
|
+
: request.wait && request.restart
|
|
65
|
+
? Option.some(usage("--restart starts a replacement device sign-in and cannot be combined with --wait."))
|
|
66
|
+
: request.restart && !request.deviceCode
|
|
67
|
+
? Option.some(usage("--restart requires --device-code."))
|
|
68
|
+
: !request.wait && request.timeoutSeconds !== undefined
|
|
69
|
+
? Option.some(usage("--timeout requires --wait."))
|
|
70
|
+
: Option.none();
|
|
71
|
+
/**
|
|
72
|
+
* Decide whether a session that is still valid should be replaced.
|
|
73
|
+
*
|
|
74
|
+
* Preapproval answers the one question a valid session raises, so a new
|
|
75
|
+
* sign-in starts in every mode. Without it, a mode that cannot ask keeps the
|
|
76
|
+
* session; a mode that can, asks.
|
|
77
|
+
*/
|
|
78
|
+
const shouldReplaceValidSession = (request, handle) => Effect.gen(function* () {
|
|
79
|
+
const presenter = yield* AuthLoginPresenter;
|
|
80
|
+
if (request.yes) {
|
|
81
|
+
if (!request.machineOutput)
|
|
82
|
+
yield* presenter.noteExistingSession(handle);
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
if (request.nonInteractive || request.machineOutput)
|
|
86
|
+
return false;
|
|
87
|
+
yield* presenter.noteExistingSession(handle);
|
|
88
|
+
const decision = yield* presenter.confirmSessionReplacement("Log in with a different account?");
|
|
89
|
+
return decision === "replace";
|
|
90
|
+
});
|
|
91
|
+
export const login = Effect.fn("Login.run")(function* (request, registryUrl) {
|
|
92
|
+
const credentials = yield* CredentialStore;
|
|
93
|
+
const presenter = yield* AuthLoginPresenter;
|
|
94
|
+
const registryHost = new URL(registryUrl).host;
|
|
95
|
+
const incoherent = rejectIncoherentFlags(request);
|
|
96
|
+
if (Option.isSome(incoherent))
|
|
97
|
+
return yield* Effect.fail(incoherent.value);
|
|
98
|
+
if (!credentials.allowsPersistedCredentials) {
|
|
99
|
+
return yield* makePersistedCredentialsUnsupportedError();
|
|
100
|
+
}
|
|
101
|
+
if (request.wait) {
|
|
102
|
+
yield* resumeDeviceLogin(registryUrl, resumeLoginOptions(request));
|
|
103
|
+
return { _tag: "PendingSignInResumed" };
|
|
104
|
+
}
|
|
105
|
+
const existing = yield* credentials.load(registryUrl);
|
|
106
|
+
if (Option.isSome(existing)) {
|
|
107
|
+
const authClient = yield* AuthClient;
|
|
108
|
+
const identity = yield* presenter.withProgress({ _tag: "CheckingRegistrySession", registryHost }, () => authClient.getMe(existing.value.access_token).pipe(Effect.option));
|
|
109
|
+
if (Option.isSome(identity)) {
|
|
110
|
+
if (!(yield* shouldReplaceValidSession(request, identity.value.userHandle))) {
|
|
111
|
+
return {
|
|
112
|
+
_tag: "SessionRetained",
|
|
113
|
+
registryHost,
|
|
114
|
+
handle: identity.value.userHandle,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
else if (!request.machineOutput) {
|
|
119
|
+
yield* presenter.noteRejectedStoredCredentials;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const strategy = selectLoginStrategy({ deviceCode: request.deviceCode, nonInteractive: request.nonInteractive }, yield* loginStrategyEnvironment);
|
|
123
|
+
const scopeOptions = request.scopes.length === 0 ? {} : { scopes: [...request.scopes] };
|
|
124
|
+
const deviceOptions = (openBrowser) => deviceLoginOptions(request, openBrowser);
|
|
125
|
+
if (strategy === "device-code") {
|
|
126
|
+
if (!request.deviceCode)
|
|
127
|
+
yield* presenter.noteDeviceCodeFallback("remote-or-headless");
|
|
128
|
+
if (request.nonInteractive) {
|
|
129
|
+
yield* initiateDeviceLogin(registryUrl, deviceOptions(false));
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
yield* runDeviceLogin(registryUrl, deviceOptions(false));
|
|
133
|
+
}
|
|
134
|
+
return { _tag: "SignInAttempted" };
|
|
135
|
+
}
|
|
136
|
+
if (request.nonInteractive) {
|
|
137
|
+
return yield* new RegistryAuthFailed({
|
|
138
|
+
category: "auth",
|
|
139
|
+
detail: "Loopback browser sign-in requires an interactive terminal. Use device-code sign-in instead.",
|
|
140
|
+
suggestions: [
|
|
141
|
+
{
|
|
142
|
+
description: "Use device-code sign-in on a remote or headless machine.",
|
|
143
|
+
cmd: "axm login --device-code",
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
yield* Effect.suspend(() => runLoopbackLogin(registryUrl, { ...scopeOptions })).pipe(Effect.catchTag("LoopbackLoginFallback", (error) => error.reason === "bind_failed"
|
|
149
|
+
? presenter
|
|
150
|
+
.noteDeviceCodeFallback("loopback-bind-failed")
|
|
151
|
+
.pipe(Effect.andThen(runDeviceLogin(registryUrl, deviceOptions(true))))
|
|
152
|
+
: Effect.fail(classifyLoopbackFailure(error))), Effect.catchTag("LoopbackCallbackRejected", (error) => Effect.fail(classifyLoopbackFailure(error))));
|
|
153
|
+
return { _tag: "SignInAttempted" };
|
|
154
|
+
});
|
|
155
|
+
//# sourceMappingURL=login.js.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sign-out: revoke the session at the Registry, then erase the selected
|
|
3
|
+
* Registry's stored credentials.
|
|
4
|
+
*
|
|
5
|
+
* The local erase is unconditional. A remote revoke that fails leaves a token
|
|
6
|
+
* that expires on its own, and the outcome says so rather than reporting a
|
|
7
|
+
* clean sign-out.
|
|
8
|
+
*
|
|
9
|
+
* @experimental This API is unstable and may change without notice.
|
|
10
|
+
*/
|
|
11
|
+
import * as Effect from "effect/Effect";
|
|
12
|
+
import { AuthClient } from "./auth-client.js";
|
|
13
|
+
import { CredentialStore } from "./credential-store.js";
|
|
14
|
+
import { AuthLoginPresenter } from "./login-presenter.js";
|
|
15
|
+
/** What one `logout` invocation settled on. */
|
|
16
|
+
export type LogoutOutcome = {
|
|
17
|
+
readonly _tag: "NotSignedIn";
|
|
18
|
+
readonly registryHost: string;
|
|
19
|
+
} | {
|
|
20
|
+
readonly _tag: "SignedOut";
|
|
21
|
+
readonly registryHost: string;
|
|
22
|
+
readonly handle?: string;
|
|
23
|
+
/** False when the Registry did not confirm the revoke; the token expires on its own. */
|
|
24
|
+
readonly revokedRemotely: boolean;
|
|
25
|
+
};
|
|
26
|
+
export declare const logout: (registryUrl: string) => Effect.Effect<{
|
|
27
|
+
readonly _tag: "NotSignedIn";
|
|
28
|
+
readonly registryHost: string;
|
|
29
|
+
} | {
|
|
30
|
+
readonly revokedRemotely: boolean;
|
|
31
|
+
readonly handle?: string & import("effect/Brand").Brand<"Handle">;
|
|
32
|
+
readonly _tag: "SignedOut";
|
|
33
|
+
readonly registryHost: string;
|
|
34
|
+
}, import("./errors.ts").RegistryAuthFailed, AuthClient | CredentialStore | AuthLoginPresenter>;
|
|
35
|
+
//# sourceMappingURL=logout.d.ts.map
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sign-out: revoke the session at the Registry, then erase the selected
|
|
3
|
+
* Registry's stored credentials.
|
|
4
|
+
*
|
|
5
|
+
* The local erase is unconditional. A remote revoke that fails leaves a token
|
|
6
|
+
* that expires on its own, and the outcome says so rather than reporting a
|
|
7
|
+
* clean sign-out.
|
|
8
|
+
*
|
|
9
|
+
* @experimental This API is unstable and may change without notice.
|
|
10
|
+
*/
|
|
11
|
+
import * as Effect from "effect/Effect";
|
|
12
|
+
import * as Option from "effect/Option";
|
|
13
|
+
import { AuthClient } from "./auth-client.js";
|
|
14
|
+
import { CredentialStore } from "./credential-store.js";
|
|
15
|
+
import { AuthLoginPresenter } from "./login-presenter.js";
|
|
16
|
+
/** `normalizeHandle("@unknown")` — the anonymous credential sentinel. */
|
|
17
|
+
const ANONYMOUS_HANDLE = "@unknown";
|
|
18
|
+
export const logout = Effect.fn("Logout.run")(function* (registryUrl) {
|
|
19
|
+
const authClient = yield* AuthClient;
|
|
20
|
+
const credentials = yield* CredentialStore;
|
|
21
|
+
const presenter = yield* AuthLoginPresenter;
|
|
22
|
+
const registryHost = new URL(registryUrl).host;
|
|
23
|
+
const existing = yield* credentials.load(registryUrl);
|
|
24
|
+
if (Option.isNone(existing)) {
|
|
25
|
+
return { _tag: "NotSignedIn", registryHost };
|
|
26
|
+
}
|
|
27
|
+
const revoked = yield* presenter.withProgress({ _tag: "RevokingRegistrySession", registryHost }, () => authClient.revokeToken(existing.value.refresh_token).pipe(Effect.option));
|
|
28
|
+
yield* credentials.clear(registryUrl);
|
|
29
|
+
const handle = existing.value.handle;
|
|
30
|
+
return {
|
|
31
|
+
_tag: "SignedOut",
|
|
32
|
+
registryHost,
|
|
33
|
+
...(handle === ANONYMOUS_HANDLE ? {} : { handle }),
|
|
34
|
+
revokedRemotely: Option.isSome(revoked),
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
//# sourceMappingURL=logout.js.map
|
|
@@ -52,7 +52,7 @@ export const runLoopbackLogin = (registryUrl, options = {}) => Effect.scoped(Eff
|
|
|
52
52
|
const verifier = makePkceVerifier();
|
|
53
53
|
const challenge = makePkceChallenge(verifier);
|
|
54
54
|
const state = makeOAuthState();
|
|
55
|
-
const server = yield* startLoopbackServer(state);
|
|
55
|
+
const server = yield* startLoopbackServer(state, "login");
|
|
56
56
|
const authorizeUrl = authClient.buildAuthorizeUrl({
|
|
57
57
|
challenge,
|
|
58
58
|
expiresAt: DateTime.addDuration(yield* DateTime.now, LOOPBACK_TIMEOUT),
|
|
@@ -86,6 +86,7 @@ export const runLoopbackLogin = (registryUrl, options = {}) => Effect.scoped(Eff
|
|
|
86
86
|
});
|
|
87
87
|
return yield* persistLoginCredentials(registryUrl, token);
|
|
88
88
|
}));
|
|
89
|
+
yield* server.complete;
|
|
89
90
|
yield* emitLoginSuccess(registryUrl, handle);
|
|
90
91
|
}));
|
|
91
92
|
//# sourceMappingURL=loopback-login.js.map
|
|
@@ -29,11 +29,13 @@ export declare class LoopbackCallbackRejected extends LoopbackCallbackRejected_b
|
|
|
29
29
|
export interface LoopbackServer {
|
|
30
30
|
readonly port: number;
|
|
31
31
|
readonly redirectUri: string;
|
|
32
|
+
readonly complete: Effect.Effect<void>;
|
|
32
33
|
readonly awaitCallback: (timeoutMs: number) => Effect.Effect<LoopbackCallback, LoopbackLoginFallback | LoopbackCallbackRejected>;
|
|
33
34
|
}
|
|
34
|
-
export declare const startLoopbackServer: (expectedState: string) => Effect.Effect<{
|
|
35
|
+
export declare const startLoopbackServer: (expectedState: string, purpose: "login" | "publish") => Effect.Effect<{
|
|
35
36
|
port: number;
|
|
36
37
|
redirectUri: string;
|
|
38
|
+
complete: Effect.Effect<void, never, never>;
|
|
37
39
|
awaitCallback: (timeoutMs: number) => Effect.Effect<LoopbackCallback, LoopbackLoginFallback | LoopbackCallbackRejected, never>;
|
|
38
40
|
}, LoopbackLoginFallback, import("effect/Scope").Scope>;
|
|
39
41
|
export {};
|
|
@@ -7,14 +7,14 @@ import * as Data from "effect/Data";
|
|
|
7
7
|
import * as Deferred from "effect/Deferred";
|
|
8
8
|
import * as Duration from "effect/Duration";
|
|
9
9
|
import * as Effect from "effect/Effect";
|
|
10
|
+
import * as Option from "effect/Option";
|
|
10
11
|
export class LoopbackLoginFallback extends Data.TaggedError("LoopbackLoginFallback") {
|
|
11
12
|
}
|
|
12
13
|
export class LoopbackCallbackRejected extends Data.TaggedError("LoopbackCallbackRejected") {
|
|
13
14
|
}
|
|
14
15
|
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
|
|
16
|
-
const
|
|
17
|
-
const errorPage = page("AXM sign-in could not be completed", "Return to your terminal for details and recovery instructions.");
|
|
16
|
+
const cancellationPage = page("Authorization was denied", "Return to your terminal for recovery instructions.");
|
|
17
|
+
const errorPage = page("AXM authorization could not be completed", "Return to your terminal for details and recovery instructions.");
|
|
18
18
|
const writeHtml = (response, statusCode, body) => {
|
|
19
19
|
response.writeHead(statusCode, {
|
|
20
20
|
"content-type": "text/html; charset=utf-8",
|
|
@@ -79,7 +79,7 @@ const makeCallbackOutcome = (request, expectedState) => {
|
|
|
79
79
|
callback: { code, state, iss },
|
|
80
80
|
};
|
|
81
81
|
};
|
|
82
|
-
export const startLoopbackServer = (expectedState) => Effect.gen(function* () {
|
|
82
|
+
export const startLoopbackServer = (expectedState, purpose) => Effect.gen(function* () {
|
|
83
83
|
const http = yield* Effect.tryPromise({
|
|
84
84
|
try: () => import("node:http"),
|
|
85
85
|
catch: (cause) => new LoopbackLoginFallback({
|
|
@@ -89,16 +89,58 @@ export const startLoopbackServer = (expectedState) => Effect.gen(function* () {
|
|
|
89
89
|
}),
|
|
90
90
|
});
|
|
91
91
|
const callback = yield* Deferred.make();
|
|
92
|
+
const browserResponse = yield* Deferred.make();
|
|
93
|
+
const finish = (completed) => Effect.gen(function* () {
|
|
94
|
+
const pending = yield* Deferred.poll(browserResponse);
|
|
95
|
+
if (Option.isNone(pending))
|
|
96
|
+
return;
|
|
97
|
+
const response = yield* pending.value;
|
|
98
|
+
if (response.writableEnded || response.destroyed)
|
|
99
|
+
return;
|
|
100
|
+
const title = completed
|
|
101
|
+
? purpose === "login"
|
|
102
|
+
? "You’re signed in to AgentXM.ai"
|
|
103
|
+
: "Publish authorization received"
|
|
104
|
+
: "AXM authorization could not be completed";
|
|
105
|
+
const content = completed
|
|
106
|
+
? purpose === "login"
|
|
107
|
+
? "Your credentials have been saved. Return to your terminal to continue. You can close this tab."
|
|
108
|
+
: "AXM received permission for the reviewed publication. Return to your terminal to check the publish result. You can close this tab."
|
|
109
|
+
: "Return to your terminal for details and recovery instructions.";
|
|
110
|
+
yield* Effect.callback((resume) => {
|
|
111
|
+
const done = () => resume(Effect.void);
|
|
112
|
+
response.once("close", done);
|
|
113
|
+
response.end(`<style>#receipt{display:none}</style><section role="status"><h1>${title}</h1><p>${content}</p></section></main></body></html>`, done);
|
|
114
|
+
return Effect.sync(() => {
|
|
115
|
+
response.off("close", done);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|
|
92
119
|
const listener = yield* Effect.acquireRelease(Effect.callback((resume) => {
|
|
93
120
|
let acquired = false;
|
|
94
121
|
const server = http.createServer((request, response) => {
|
|
122
|
+
if (Deferred.isDoneUnsafe(callback)) {
|
|
123
|
+
writeHtml(response, 409, page("Callback already received", "Return to the original tab or your terminal to check the result."));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
95
126
|
const outcome = makeCallbackOutcome(request, expectedState);
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
127
|
+
if (outcome._tag === "success") {
|
|
128
|
+
response.writeHead(200, {
|
|
129
|
+
"content-type": "text/html; charset=utf-8",
|
|
130
|
+
"cache-control": "no-store",
|
|
131
|
+
"referrer-policy": "no-referrer",
|
|
132
|
+
"x-content-type-options": "nosniff",
|
|
133
|
+
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'",
|
|
134
|
+
});
|
|
135
|
+
response.write('<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>AXM authorization</title></head><body><main style="font-family:system-ui,sans-serif;margin:3rem auto;max-width:34rem;padding:1rem"><section id="receipt" role="status"><h1>Callback received</h1><p>AXM is finishing authorization. Check your terminal if this page stops updating.</p></section>');
|
|
136
|
+
Deferred.doneUnsafe(browserResponse, Effect.succeed(response));
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
writeHtml(response, 400, outcome.error._tag === "LoopbackCallbackRejected" &&
|
|
99
140
|
outcome.error.reason === "access_denied"
|
|
100
141
|
? cancellationPage
|
|
101
142
|
: errorPage);
|
|
143
|
+
}
|
|
102
144
|
Deferred.doneUnsafe(callback, outcome._tag === "success"
|
|
103
145
|
? Effect.succeed(outcome.callback)
|
|
104
146
|
: Effect.fail(outcome.error));
|
|
@@ -146,6 +188,7 @@ export const startLoopbackServer = (expectedState) => Effect.gen(function* () {
|
|
|
146
188
|
},
|
|
147
189
|
catch: () => undefined,
|
|
148
190
|
}).pipe(Effect.ignore));
|
|
191
|
+
yield* Effect.addFinalizer(() => finish(false));
|
|
149
192
|
listener.server.on("error", (cause) => {
|
|
150
193
|
Deferred.doneUnsafe(callback, Effect.fail(new LoopbackLoginFallback({
|
|
151
194
|
reason: "bind_failed",
|
|
@@ -156,6 +199,7 @@ export const startLoopbackServer = (expectedState) => Effect.gen(function* () {
|
|
|
156
199
|
return {
|
|
157
200
|
port: listener.port,
|
|
158
201
|
redirectUri: `http://127.0.0.1:${listener.port}/callback`,
|
|
202
|
+
complete: finish(true),
|
|
159
203
|
awaitCallback: (timeoutMs) => Deferred.await(callback).pipe(Effect.timeoutOrElse({
|
|
160
204
|
duration: Duration.millis(timeoutMs),
|
|
161
205
|
orElse: () => Effect.fail(new LoopbackLoginFallback({
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as Context from "effect/Context";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as FileSystem from "effect/FileSystem";
|
|
4
|
+
import * as Layer from "effect/Layer";
|
|
5
|
+
import * as Option from "effect/Option";
|
|
6
|
+
import * as Path from "effect/Path";
|
|
7
|
+
import * as Schema from "effect/Schema";
|
|
8
|
+
import { RegistryAuthFailed } from "./errors.js";
|
|
9
|
+
export declare const PendingPublishAuthorizationSchema: Schema.Struct<{
|
|
10
|
+
readonly version: Schema.Literal<1>;
|
|
11
|
+
readonly purpose: Schema.Literal<"publish">;
|
|
12
|
+
readonly registryUrl: Schema.String;
|
|
13
|
+
readonly requestRef: Schema.String;
|
|
14
|
+
readonly requestId: Schema.String;
|
|
15
|
+
readonly authorizationUrl: Schema.String;
|
|
16
|
+
readonly expiresAt: Schema.decodeTo<Schema.DateTimeUtc, Schema.String, never, never>;
|
|
17
|
+
readonly interval: Schema.Int;
|
|
18
|
+
readonly publicationSetDigest: Schema.String;
|
|
19
|
+
readonly initiatorProof: Schema.String;
|
|
20
|
+
}>;
|
|
21
|
+
export type PendingPublishAuthorization = typeof PendingPublishAuthorizationSchema.Type;
|
|
22
|
+
export interface PendingPublishAuthorizationStoreService {
|
|
23
|
+
readonly save: (pending: PendingPublishAuthorization) => Effect.Effect<void, RegistryAuthFailed>;
|
|
24
|
+
readonly load: (requestRef: string) => Effect.Effect<Option.Option<PendingPublishAuthorization>, RegistryAuthFailed>;
|
|
25
|
+
readonly clear: (requestRef: string) => Effect.Effect<void, RegistryAuthFailed>;
|
|
26
|
+
}
|
|
27
|
+
declare const PendingPublishAuthorizationStore_base: Context.ServiceClass<PendingPublishAuthorizationStore, "@agentxm/registry-auth/PendingPublishAuthorizationStore", PendingPublishAuthorizationStoreService>;
|
|
28
|
+
export declare class PendingPublishAuthorizationStore extends PendingPublishAuthorizationStore_base {
|
|
29
|
+
}
|
|
30
|
+
export declare const PendingPublishAuthorizationStoreLive: Layer.Layer<PendingPublishAuthorizationStore, never, FileSystem.FileSystem | Path.Path>;
|
|
31
|
+
export declare const PendingPublishAuthorizationStoreTest: (initial?: ReadonlyArray<PendingPublishAuthorization>) => Layer.Layer<PendingPublishAuthorizationStore, never, never>;
|
|
32
|
+
export {};
|
|
33
|
+
//# sourceMappingURL=pending-publish-authorization-store.d.ts.map
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import * as Context from "effect/Context";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as FileSystem from "effect/FileSystem";
|
|
5
|
+
import * as Layer from "effect/Layer";
|
|
6
|
+
import * as Option from "effect/Option";
|
|
7
|
+
import * as Path from "effect/Path";
|
|
8
|
+
import * as Schema from "effect/Schema";
|
|
9
|
+
import { DateTimeUtcSchema } from "@agentxm/extension-model/unstable/date-time";
|
|
10
|
+
import { RegistryAuthFailed } from "./errors.js";
|
|
11
|
+
import { envOption } from "./internal/environment.js";
|
|
12
|
+
export const PendingPublishAuthorizationSchema = Schema.Struct({
|
|
13
|
+
version: Schema.Literal(1),
|
|
14
|
+
purpose: Schema.Literal("publish"),
|
|
15
|
+
registryUrl: Schema.String,
|
|
16
|
+
requestRef: Schema.String,
|
|
17
|
+
requestId: Schema.String,
|
|
18
|
+
authorizationUrl: Schema.String,
|
|
19
|
+
expiresAt: DateTimeUtcSchema,
|
|
20
|
+
interval: Schema.Int.check(Schema.isGreaterThan(0)),
|
|
21
|
+
publicationSetDigest: Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/)),
|
|
22
|
+
initiatorProof: Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]{43,128}$/)),
|
|
23
|
+
});
|
|
24
|
+
export class PendingPublishAuthorizationStore extends Context.Service()("@agentxm/registry-auth/PendingPublishAuthorizationStore") {
|
|
25
|
+
}
|
|
26
|
+
const storageError = (operation) => new RegistryAuthFailed({
|
|
27
|
+
category: "auth",
|
|
28
|
+
detail: `Could not ${operation} the private publish authorization record. Its public request URL cannot replace the missing proof.`,
|
|
29
|
+
});
|
|
30
|
+
export const PendingPublishAuthorizationStoreLive = Layer.effect(PendingPublishAuthorizationStore, Effect.gen(function* () {
|
|
31
|
+
const fs = yield* FileSystem.FileSystem;
|
|
32
|
+
const path = yield* Path.Path;
|
|
33
|
+
const homes = [
|
|
34
|
+
yield* envOption("AXM_USER_HOME"),
|
|
35
|
+
yield* envOption("HOME"),
|
|
36
|
+
yield* envOption("USERPROFILE"),
|
|
37
|
+
];
|
|
38
|
+
const home = homes.find(Option.isSome);
|
|
39
|
+
const storageDirectory = home === undefined
|
|
40
|
+
? Effect.fail(storageError("locate"))
|
|
41
|
+
: Effect.succeed(path.join(home.value, ".axm", "publish-authorizations"));
|
|
42
|
+
const filename = (directory, reference) => path.join(directory, `${createHash("sha256").update(reference).digest("hex")}.json`);
|
|
43
|
+
return {
|
|
44
|
+
save: (pending) => Effect.gen(function* () {
|
|
45
|
+
const directory = yield* storageDirectory;
|
|
46
|
+
yield* fs.makeDirectory(directory, { recursive: true, mode: 0o700 });
|
|
47
|
+
yield* fs.chmod(directory, 0o700);
|
|
48
|
+
const file = filename(directory, pending.requestRef);
|
|
49
|
+
const encoded = yield* Schema.encodeEffect(PendingPublishAuthorizationSchema)(pending);
|
|
50
|
+
yield* Effect.acquireUseRelease(fs.makeTempDirectory({ directory, prefix: "publish-" }), (temporaryDirectory) => Effect.gen(function* () {
|
|
51
|
+
const temporary = path.join(temporaryDirectory, "proof.json");
|
|
52
|
+
yield* fs.writeFileString(temporary, JSON.stringify(encoded), {
|
|
53
|
+
mode: 0o600,
|
|
54
|
+
flag: "wx",
|
|
55
|
+
});
|
|
56
|
+
yield* fs.link(temporary, file);
|
|
57
|
+
}), (temporaryDirectory) => fs
|
|
58
|
+
.remove(temporaryDirectory, { recursive: true, force: true })
|
|
59
|
+
.pipe(Effect.catch(() => Effect.void)));
|
|
60
|
+
}).pipe(Effect.mapError(() => storageError("save"))),
|
|
61
|
+
load: (reference) => Effect.gen(function* () {
|
|
62
|
+
const directory = yield* storageDirectory;
|
|
63
|
+
const file = filename(directory, reference);
|
|
64
|
+
if (!(yield* fs.exists(file)))
|
|
65
|
+
return Option.none();
|
|
66
|
+
const content = yield* fs.readFileString(file);
|
|
67
|
+
const pending = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(PendingPublishAuthorizationSchema))(content);
|
|
68
|
+
if (pending.requestRef !== reference)
|
|
69
|
+
return yield* storageError("validate");
|
|
70
|
+
return Option.some(pending);
|
|
71
|
+
}).pipe(Effect.mapError(() => storageError("read"))),
|
|
72
|
+
clear: (reference) => Effect.gen(function* () {
|
|
73
|
+
const directory = yield* storageDirectory;
|
|
74
|
+
yield* fs.remove(filename(directory, reference), { force: true });
|
|
75
|
+
}).pipe(Effect.mapError(() => storageError("remove"))),
|
|
76
|
+
};
|
|
77
|
+
}));
|
|
78
|
+
export const PendingPublishAuthorizationStoreTest = (initial = []) => {
|
|
79
|
+
const records = new Map(initial.map((record) => [record.requestRef, record]));
|
|
80
|
+
return Layer.succeed(PendingPublishAuthorizationStore, {
|
|
81
|
+
save: (pending) => Effect.sync(() => {
|
|
82
|
+
records.set(pending.requestRef, pending);
|
|
83
|
+
}),
|
|
84
|
+
load: (reference) => Effect.sync(() => Option.fromNullishOr(records.get(reference))),
|
|
85
|
+
clear: (reference) => Effect.sync(() => {
|
|
86
|
+
records.delete(reference);
|
|
87
|
+
}),
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
//# sourceMappingURL=pending-publish-authorization-store.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { DeviceLoginInteraction } from "./device-login.js";
|
|
2
|
+
import { AuthLoginPresenter } from "./login-presenter.js";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import { AuthClient, type PublishAuthorizationExchangeResponse } from "./auth-client.js";
|
|
5
|
+
import { RegistryAuthFailed } from "./errors.js";
|
|
6
|
+
import { PendingPublishAuthorizationStore } from "./pending-publish-authorization-store.js";
|
|
7
|
+
import type { PublishAuthorizationInput } from "./publish-authorization.js";
|
|
8
|
+
export declare const readPublishAuthorizationReference: (reference: string, registryUrl: string) => Effect.Effect<{
|
|
9
|
+
requestId: string;
|
|
10
|
+
requestRef: string;
|
|
11
|
+
}, RegistryAuthFailed, never>;
|
|
12
|
+
export declare const runPollingPublishAuthorization: (input: PublishAuthorizationInput) => Effect.Effect<PublishAuthorizationExchangeResponse, import("./errors.js").AuthError, AuthClient | AuthLoginPresenter | DeviceLoginInteraction | PendingPublishAuthorizationStore>;
|
|
13
|
+
//# sourceMappingURL=publish-authorization-polling.d.ts.map
|