@vizamodo/modo-dispatcher 1.1.77
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/README.md +43 -0
- package/dist/artifacts/builders.d.ts +32 -0
- package/dist/artifacts/builders.js +72 -0
- package/dist/artifacts/downloader.d.ts +28 -0
- package/dist/artifacts/downloader.js +102 -0
- package/dist/artifacts/normalizer.d.ts +22 -0
- package/dist/artifacts/normalizer.js +65 -0
- package/dist/artifacts/types.d.ts +94 -0
- package/dist/artifacts/types.js +1 -0
- package/dist/auth/identity/extract-identity.d.ts +8 -0
- package/dist/auth/identity/extract-identity.js +15 -0
- package/dist/auth/identity/normalize-teams.d.ts +1 -0
- package/dist/auth/identity/normalize-teams.js +5 -0
- package/dist/auth/identity/validate-claims.d.ts +2 -0
- package/dist/auth/identity/validate-claims.js +5 -0
- package/dist/auth/jwt/parse.d.ts +2 -0
- package/dist/auth/jwt/parse.js +16 -0
- package/dist/auth/jwt/verify.d.ts +1 -0
- package/dist/auth/jwt/verify.js +9 -0
- package/dist/auth/oauth/auth-error.d.ts +12 -0
- package/dist/auth/oauth/auth-error.js +47 -0
- package/dist/auth/oauth/auth-session-store.d.ts +30 -0
- package/dist/auth/oauth/auth-session-store.js +46 -0
- package/dist/auth/oauth/callback-server.d.ts +21 -0
- package/dist/auth/oauth/callback-server.js +319 -0
- package/dist/auth/oauth/github.d.ts +14 -0
- package/dist/auth/oauth/github.js +100 -0
- package/dist/auth/oauth/sse.d.ts +11 -0
- package/dist/auth/oauth/sse.js +67 -0
- package/dist/auth/oauth/templates/failed.html +629 -0
- package/dist/auth/oauth/templates/pending.html +620 -0
- package/dist/auth/oauth/templates/success.html +577 -0
- package/dist/auth/session/access-token.d.ts +15 -0
- package/dist/auth/session/access-token.js +64 -0
- package/dist/auth/session/login.d.ts +18 -0
- package/dist/auth/session/login.js +144 -0
- package/dist/auth/session/logout.d.ts +3 -0
- package/dist/auth/session/logout.js +21 -0
- package/dist/auth/session/refresh-token.d.ts +8 -0
- package/dist/auth/session/refresh-token.js +16 -0
- package/dist/auth/session/refresh.d.ts +2 -0
- package/dist/auth/session/refresh.js +61 -0
- package/dist/auth/session/rotate.d.ts +4 -0
- package/dist/auth/session/rotate.js +4 -0
- package/dist/auth/session/session-manager.d.ts +16 -0
- package/dist/auth/session/session-manager.js +54 -0
- package/dist/auth/session/token-state.d.ts +20 -0
- package/dist/auth/session/token-state.js +55 -0
- package/dist/auth/session/types.d.ts +35 -0
- package/dist/auth/session/types.js +1 -0
- package/dist/auth/storage/keychain.d.ts +16 -0
- package/dist/auth/storage/keychain.js +107 -0
- package/dist/auth/storage/memory.d.ts +10 -0
- package/dist/auth/storage/memory.js +15 -0
- package/dist/auth/storage/types.d.ts +5 -0
- package/dist/auth/storage/types.js +1 -0
- package/dist/config/defaults.d.ts +94 -0
- package/dist/config/defaults.js +116 -0
- package/dist/core/auth-bootstrap.d.ts +15 -0
- package/dist/core/auth-bootstrap.js +33 -0
- package/dist/core/bootstrap.d.ts +73 -0
- package/dist/core/bootstrap.js +248 -0
- package/dist/core/dispatcher.d.ts +2 -0
- package/dist/core/dispatcher.js +28 -0
- package/dist/core/ensure-bootstrap.d.ts +3 -0
- package/dist/core/ensure-bootstrap.js +25 -0
- package/dist/core/errors.d.ts +69 -0
- package/dist/core/errors.js +121 -0
- package/dist/core/runtime-orchestrator.d.ts +17 -0
- package/dist/core/runtime-orchestrator.js +47 -0
- package/dist/core/runtime.d.ts +16 -0
- package/dist/core/runtime.js +52 -0
- package/dist/core/validation.d.ts +2 -0
- package/dist/core/validation.js +21 -0
- package/dist/crypto/encrypt.d.ts +12 -0
- package/dist/crypto/encrypt.js +80 -0
- package/dist/flows/email-otp-flow.d.ts +7 -0
- package/dist/flows/email-otp-flow.js +249 -0
- package/dist/gateway/auth-header.d.ts +2 -0
- package/dist/gateway/auth-header.js +21 -0
- package/dist/gateway/client.d.ts +7 -0
- package/dist/gateway/client.js +24 -0
- package/dist/gateway/dispatch-with-bootstrap-retry.d.ts +25 -0
- package/dist/gateway/dispatch-with-bootstrap-retry.js +157 -0
- package/dist/gateway/dispatch.d.ts +23 -0
- package/dist/gateway/dispatch.js +110 -0
- package/dist/gateway/session-waiter.d.ts +11 -0
- package/dist/gateway/session-waiter.js +126 -0
- package/dist/gateway/session.d.ts +21 -0
- package/dist/gateway/session.js +74 -0
- package/dist/gateway/types.d.ts +113 -0
- package/dist/gateway/types.js +19 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +21 -0
- package/dist/runtime/runtime-context.d.ts +6 -0
- package/dist/runtime/runtime-context.js +1 -0
- package/dist/types/dispatcher.d.ts +275 -0
- package/dist/types/dispatcher.js +1 -0
- package/dist/types/payload.d.ts +5 -0
- package/dist/types/payload.js +2 -0
- package/dist/types/runtime-env.d.ts +2 -0
- package/dist/types/runtime-env.js +5 -0
- package/dist/ui/banner.d.ts +4 -0
- package/dist/ui/banner.js +20 -0
- package/dist/utils/request-dedup.d.ts +75 -0
- package/dist/utils/request-dedup.js +102 -0
- package/dist/utils/type-guards.d.ts +173 -0
- package/dist/utils/type-guards.js +232 -0
- package/package.json +43 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { randomBytes, webcrypto } from "node:crypto";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { exchangeGithubCodeForTokens } from "../oauth/github.js";
|
|
5
|
+
import { createOAuthCallbackServer } from "../oauth/callback-server.js";
|
|
6
|
+
import { AuthError, toStructuredAuthError } from "../oauth/auth-error.js";
|
|
7
|
+
import { rotateTokens } from "./rotate.js";
|
|
8
|
+
import { extractIdentity } from "../identity/extract-identity.js";
|
|
9
|
+
import { persistBootstrapMap } from "../../core/bootstrap.js";
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
const VIZA_LOGIN_URL = "https://auth.viza.io.vn/auth/login";
|
|
12
|
+
const DEFAULT_OAUTH_CALLBACK_PORT = 39123;
|
|
13
|
+
function base64UrlEncode(bytes) {
|
|
14
|
+
return Buffer.from(bytes)
|
|
15
|
+
.toString("base64")
|
|
16
|
+
.replace(/\+/g, "-")
|
|
17
|
+
.replace(/\//g, "_")
|
|
18
|
+
.replace(/=+$/, "");
|
|
19
|
+
}
|
|
20
|
+
async function sha256(input) {
|
|
21
|
+
const digest = await webcrypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
|
|
22
|
+
return base64UrlEncode(new Uint8Array(digest));
|
|
23
|
+
}
|
|
24
|
+
export async function performInteractiveLogin(deps, options) {
|
|
25
|
+
if (!options.loginEndpoint) {
|
|
26
|
+
throw new Error("Missing token endpoint for login flow.");
|
|
27
|
+
}
|
|
28
|
+
const state = randomBytes(12).toString("hex");
|
|
29
|
+
const codeVerifier = base64UrlEncode(randomBytes(32));
|
|
30
|
+
const codeChallenge = await sha256(codeVerifier);
|
|
31
|
+
const callbackPath = "/callback";
|
|
32
|
+
// Start the local callback server first so we know the actual port.
|
|
33
|
+
// If the preferred port is in use the server will bind to a random one.
|
|
34
|
+
const srv = await createOAuthCallbackServer(DEFAULT_OAUTH_CALLBACK_PORT, callbackPath);
|
|
35
|
+
const redirectUri = `http://127.0.0.1:${srv.port}${callbackPath}`;
|
|
36
|
+
const url = new URL(VIZA_LOGIN_URL);
|
|
37
|
+
url.searchParams.set("state", state);
|
|
38
|
+
url.searchParams.set("redirect_uri", redirectUri);
|
|
39
|
+
url.searchParams.set("local_port", String(srv.port));
|
|
40
|
+
url.searchParams.set("code_challenge", codeChallenge);
|
|
41
|
+
await openBrowser(url.toString());
|
|
42
|
+
let callback;
|
|
43
|
+
try {
|
|
44
|
+
callback = await Promise.race([
|
|
45
|
+
srv.waitForCallback(),
|
|
46
|
+
new Promise((_, reject) => {
|
|
47
|
+
setTimeout(() => {
|
|
48
|
+
reject(new AuthError({
|
|
49
|
+
code: "oauth_timeout",
|
|
50
|
+
message: "Authentication timed out while waiting for OAuth callback.",
|
|
51
|
+
suggestion: "Retry login and complete authorization in the same browser session.",
|
|
52
|
+
}));
|
|
53
|
+
}, 1000 * 60 * 5);
|
|
54
|
+
}),
|
|
55
|
+
]);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
try {
|
|
59
|
+
await srv.close();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// ignore close failures
|
|
63
|
+
}
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
if (callback.state && callback.state !== state) {
|
|
68
|
+
await callback.notifyAuthResult({
|
|
69
|
+
status: "failed",
|
|
70
|
+
error: {
|
|
71
|
+
code: "state_mismatch",
|
|
72
|
+
message: "Authentication session expired. Please retry login.",
|
|
73
|
+
suggestion: "Retry login and complete authorization in the same browser session.",
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
throw new AuthError({
|
|
77
|
+
code: "state_mismatch",
|
|
78
|
+
message: "Authentication session expired. Please retry login.",
|
|
79
|
+
suggestion: "Retry login and complete authorization in the same browser session.",
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
const exchangeResult = await exchangeGithubCodeForTokens({
|
|
83
|
+
exchangeEndpoint: options.exchangeEndpoint ??
|
|
84
|
+
"https://auth.viza.io.vn/auth/exchange",
|
|
85
|
+
code: callback.code,
|
|
86
|
+
redirectUri,
|
|
87
|
+
state,
|
|
88
|
+
codeVerifier,
|
|
89
|
+
});
|
|
90
|
+
await rotateTokens(exchangeResult.tokenPair, deps.accessTokenService, deps.refreshTokenService);
|
|
91
|
+
// Persist bootstrap configs received from the gateway exchange
|
|
92
|
+
if (exchangeResult.bootstrap) {
|
|
93
|
+
persistBootstrapMap(exchangeResult.bootstrap);
|
|
94
|
+
const envs = Object.keys(exchangeResult.bootstrap).join(", ");
|
|
95
|
+
console.log(`\n[auth] bootstrap config(s) received for: ${envs}`);
|
|
96
|
+
}
|
|
97
|
+
const claims = deps.accessTokenService.getClaims();
|
|
98
|
+
const identity = extractIdentity(claims);
|
|
99
|
+
await callback.notifyAuthResult({
|
|
100
|
+
status: "success",
|
|
101
|
+
login: identity.login,
|
|
102
|
+
email: identity.email,
|
|
103
|
+
avatarUrl: identity.avatarUrl,
|
|
104
|
+
teams: identity.teams,
|
|
105
|
+
});
|
|
106
|
+
return {
|
|
107
|
+
bootstrapReceived: !!exchangeResult.bootstrap,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
try {
|
|
112
|
+
await callback.notifyAuthResult({
|
|
113
|
+
status: "failed",
|
|
114
|
+
error: toStructuredAuthError(error),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// ignore local callback notification failures
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
await callback.close();
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// ignore close failures
|
|
125
|
+
}
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async function openBrowser(url) {
|
|
130
|
+
try {
|
|
131
|
+
if (process.platform === "darwin") {
|
|
132
|
+
await execFileAsync("open", [url]);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (process.platform === "win32") {
|
|
136
|
+
await execFileAsync("cmd", ["/c", "start", "", url]);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
await execFileAsync("xdg-open", [url]);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
throw new Error(`Failed to open browser automatically. Please open this URL manually:\n${url}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { AccessTokenService } from "./access-token.js";
|
|
2
|
+
import { RefreshTokenService } from "./refresh-token.js";
|
|
3
|
+
export declare function logoutSession(accessTokenService: AccessTokenService, refreshTokenService: RefreshTokenService, revokeEndpoint?: string): Promise<void>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { TokenStateMachine } from "./token-state.js";
|
|
2
|
+
export async function logoutSession(accessTokenService, refreshTokenService, revokeEndpoint) {
|
|
3
|
+
const refreshToken = await refreshTokenService.get();
|
|
4
|
+
if (revokeEndpoint && refreshToken) {
|
|
5
|
+
try {
|
|
6
|
+
await fetch(revokeEndpoint, {
|
|
7
|
+
method: "POST",
|
|
8
|
+
headers: {
|
|
9
|
+
Authorization: `Bearer ${refreshToken}`,
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
// best-effort revoke
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
// Invalidate runtime auth/session state before clearing persisted tokens.
|
|
18
|
+
TokenStateMachine.invalidate();
|
|
19
|
+
accessTokenService.clear();
|
|
20
|
+
await refreshTokenService.clear();
|
|
21
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { SecureStore } from "../storage/types.js";
|
|
2
|
+
export declare class RefreshTokenService {
|
|
3
|
+
private readonly secureStore;
|
|
4
|
+
constructor(secureStore: SecureStore);
|
|
5
|
+
get(): Promise<string | null>;
|
|
6
|
+
set(token: string): Promise<void>;
|
|
7
|
+
clear(): Promise<void>;
|
|
8
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const REFRESH_TOKEN_KEY = "viza.refresh-token";
|
|
2
|
+
export class RefreshTokenService {
|
|
3
|
+
secureStore;
|
|
4
|
+
constructor(secureStore) {
|
|
5
|
+
this.secureStore = secureStore;
|
|
6
|
+
}
|
|
7
|
+
get() {
|
|
8
|
+
return this.secureStore.get(REFRESH_TOKEN_KEY);
|
|
9
|
+
}
|
|
10
|
+
set(token) {
|
|
11
|
+
return this.secureStore.set(REFRESH_TOKEN_KEY, token);
|
|
12
|
+
}
|
|
13
|
+
clear() {
|
|
14
|
+
return this.secureStore.remove(REFRESH_TOKEN_KEY);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export async function executeRefresh(refreshToken, refreshEndpoint, timeoutMs = 12000) {
|
|
2
|
+
const controller = new AbortController();
|
|
3
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
4
|
+
let res;
|
|
5
|
+
try {
|
|
6
|
+
res = await fetch(refreshEndpoint, {
|
|
7
|
+
method: "POST",
|
|
8
|
+
headers: { "Content-Type": "application/json" },
|
|
9
|
+
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
10
|
+
signal: controller.signal,
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
if (err?.name === "AbortError") {
|
|
15
|
+
throw new Error("Token refresh timed out");
|
|
16
|
+
}
|
|
17
|
+
throw err;
|
|
18
|
+
}
|
|
19
|
+
finally {
|
|
20
|
+
clearTimeout(timeout);
|
|
21
|
+
}
|
|
22
|
+
if (!res.ok) {
|
|
23
|
+
let errorCode;
|
|
24
|
+
try {
|
|
25
|
+
const errorJson = (await res.json());
|
|
26
|
+
errorCode =
|
|
27
|
+
typeof errorJson.code === "string"
|
|
28
|
+
? errorJson.code
|
|
29
|
+
: typeof errorJson.error === "string"
|
|
30
|
+
? errorJson.error
|
|
31
|
+
: undefined;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Ignore non-JSON error bodies.
|
|
35
|
+
}
|
|
36
|
+
if (errorCode) {
|
|
37
|
+
throw new Error(errorCode);
|
|
38
|
+
}
|
|
39
|
+
throw new Error(`Token refresh failed: HTTP ${res.status}`);
|
|
40
|
+
}
|
|
41
|
+
const json = (await res.json());
|
|
42
|
+
const pair = {
|
|
43
|
+
accessToken: typeof json.access_token === "string"
|
|
44
|
+
? json.access_token
|
|
45
|
+
: (json.accessToken ?? ""),
|
|
46
|
+
refreshToken: typeof json.refresh_token === "string"
|
|
47
|
+
? json.refresh_token
|
|
48
|
+
: (json.refreshToken ?? ""),
|
|
49
|
+
tokenType: "Bearer",
|
|
50
|
+
...(typeof json.accessTokenExpiresAt === "number"
|
|
51
|
+
? { accessTokenExpiresAt: json.accessTokenExpiresAt }
|
|
52
|
+
: {}),
|
|
53
|
+
...(typeof json.refreshTokenExpiresAt === "number"
|
|
54
|
+
? { refreshTokenExpiresAt: json.refreshTokenExpiresAt }
|
|
55
|
+
: {}),
|
|
56
|
+
};
|
|
57
|
+
if (!pair.accessToken || !pair.refreshToken) {
|
|
58
|
+
throw new Error("Token refresh response is invalid");
|
|
59
|
+
}
|
|
60
|
+
return pair;
|
|
61
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { AccessTokenService } from "./access-token.js";
|
|
2
|
+
import { RefreshTokenService } from "./refresh-token.js";
|
|
3
|
+
import { TokenPair } from "./types.js";
|
|
4
|
+
export declare function rotateTokens(pair: TokenPair, accessTokenService: AccessTokenService, refreshTokenService: RefreshTokenService): Promise<void>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { AccessTokenService } from "./access-token.js";
|
|
2
|
+
import { RefreshTokenService } from "./refresh-token.js";
|
|
3
|
+
import { TokenStateMachine } from "./token-state.js";
|
|
4
|
+
import { RuntimeContext } from "../../runtime/runtime-context.js";
|
|
5
|
+
export interface SessionManagerOptions {
|
|
6
|
+
loginEndpoint?: string;
|
|
7
|
+
exchangeEndpoint?: string;
|
|
8
|
+
refreshEndpoint?: string;
|
|
9
|
+
logoutEndpoint?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface SessionManagerDeps {
|
|
12
|
+
accessTokenService: AccessTokenService;
|
|
13
|
+
refreshTokenService: RefreshTokenService;
|
|
14
|
+
tokenState: TokenStateMachine;
|
|
15
|
+
}
|
|
16
|
+
export declare function ensureAuthenticated(deps: SessionManagerDeps, options: SessionManagerOptions): Promise<RuntimeContext>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { performInteractiveLogin } from "./login.js";
|
|
2
|
+
import { extractIdentity } from "../identity/extract-identity.js";
|
|
3
|
+
let inFlightAuthentication = null;
|
|
4
|
+
export async function ensureAuthenticated(deps, options) {
|
|
5
|
+
await ensureAuthenticationSession(deps, options);
|
|
6
|
+
const accessToken = await deps.tokenState.getValidAccessToken();
|
|
7
|
+
const claims = deps.accessTokenService.getClaims();
|
|
8
|
+
const identity = extractIdentity(claims);
|
|
9
|
+
return {
|
|
10
|
+
teams: identity.teams,
|
|
11
|
+
accessToken,
|
|
12
|
+
claims,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
async function ensureAuthenticationSession(deps, options) {
|
|
16
|
+
if (inFlightAuthentication) {
|
|
17
|
+
return inFlightAuthentication;
|
|
18
|
+
}
|
|
19
|
+
inFlightAuthentication = (async () => {
|
|
20
|
+
const refreshToken = await deps.refreshTokenService.get();
|
|
21
|
+
if (!refreshToken) {
|
|
22
|
+
await performInteractiveLogin(deps, options);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
await deps.tokenState.getValidAccessToken();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
const message = error instanceof Error
|
|
31
|
+
? error.message
|
|
32
|
+
: String(error);
|
|
33
|
+
// IMPORTANT:
|
|
34
|
+
// Only interactive-login when the refresh token itself is invalid.
|
|
35
|
+
//
|
|
36
|
+
// Access-token expiry / JWT expiry must silently refresh.
|
|
37
|
+
// Gateway 401/403 must NOT destroy the session.
|
|
38
|
+
const isRefreshTokenRejected = message.includes("invalid_refresh_token") ||
|
|
39
|
+
message.includes("refresh_token_revoked") ||
|
|
40
|
+
message.includes("refresh_token_expired");
|
|
41
|
+
if (!isRefreshTokenRejected) {
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
await deps.refreshTokenService.clear();
|
|
45
|
+
}
|
|
46
|
+
await performInteractiveLogin(deps, options);
|
|
47
|
+
})();
|
|
48
|
+
try {
|
|
49
|
+
await inFlightAuthentication;
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
inFlightAuthentication = null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { AccessTokenService } from "./access-token.js";
|
|
2
|
+
import { RefreshTokenService } from "./refresh-token.js";
|
|
3
|
+
export interface TokenStateOptions {
|
|
4
|
+
refreshEndpoint: string;
|
|
5
|
+
timeoutMs?: number;
|
|
6
|
+
}
|
|
7
|
+
export declare class TokenStateMachine {
|
|
8
|
+
private readonly accessTokenService;
|
|
9
|
+
private readonly refreshTokenService;
|
|
10
|
+
private readonly options;
|
|
11
|
+
private static invalidationGeneration;
|
|
12
|
+
private inFlight;
|
|
13
|
+
private generation;
|
|
14
|
+
constructor(accessTokenService: AccessTokenService, refreshTokenService: RefreshTokenService, options: TokenStateOptions);
|
|
15
|
+
static invalidate(): void;
|
|
16
|
+
getValidAccessToken(): Promise<string>;
|
|
17
|
+
forceExpireAccessToken(): void;
|
|
18
|
+
refreshAccessToken(): Promise<string>;
|
|
19
|
+
private refreshOnce;
|
|
20
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { executeRefresh } from "./refresh.js";
|
|
2
|
+
import { rotateTokens } from "./rotate.js";
|
|
3
|
+
export class TokenStateMachine {
|
|
4
|
+
accessTokenService;
|
|
5
|
+
refreshTokenService;
|
|
6
|
+
options;
|
|
7
|
+
static invalidationGeneration = 0;
|
|
8
|
+
inFlight;
|
|
9
|
+
generation = 0;
|
|
10
|
+
constructor(accessTokenService, refreshTokenService, options) {
|
|
11
|
+
this.accessTokenService = accessTokenService;
|
|
12
|
+
this.refreshTokenService = refreshTokenService;
|
|
13
|
+
this.options = options;
|
|
14
|
+
}
|
|
15
|
+
static invalidate() {
|
|
16
|
+
TokenStateMachine.invalidationGeneration += 1;
|
|
17
|
+
}
|
|
18
|
+
async getValidAccessToken() {
|
|
19
|
+
await this.accessTokenService.hydrate();
|
|
20
|
+
if (this.accessTokenService.isValid()) {
|
|
21
|
+
return this.accessTokenService.get();
|
|
22
|
+
}
|
|
23
|
+
return this.refreshAccessToken();
|
|
24
|
+
}
|
|
25
|
+
forceExpireAccessToken() {
|
|
26
|
+
this.accessTokenService.clear();
|
|
27
|
+
this.generation += 1;
|
|
28
|
+
}
|
|
29
|
+
async refreshAccessToken() {
|
|
30
|
+
if (!this.inFlight) {
|
|
31
|
+
this.inFlight = this.refreshOnce().finally(() => {
|
|
32
|
+
this.inFlight = undefined;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return this.inFlight;
|
|
36
|
+
}
|
|
37
|
+
async refreshOnce() {
|
|
38
|
+
const startGeneration = this.generation;
|
|
39
|
+
const startInvalidationGeneration = TokenStateMachine.invalidationGeneration;
|
|
40
|
+
const refreshToken = await this.refreshTokenService.get();
|
|
41
|
+
if (!refreshToken) {
|
|
42
|
+
throw new Error("Missing refresh token. Please login again.");
|
|
43
|
+
}
|
|
44
|
+
const pair = await executeRefresh(refreshToken, this.options.refreshEndpoint, this.options.timeoutMs);
|
|
45
|
+
// Guard against stale refresh completion writing over newer state.
|
|
46
|
+
if (startGeneration !== this.generation ||
|
|
47
|
+
startInvalidationGeneration !==
|
|
48
|
+
TokenStateMachine.invalidationGeneration) {
|
|
49
|
+
return this.accessTokenService.get() ?? pair.accessToken;
|
|
50
|
+
}
|
|
51
|
+
await rotateTokens(pair, this.accessTokenService, this.refreshTokenService);
|
|
52
|
+
this.generation += 1;
|
|
53
|
+
return pair.accessToken;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { RuntimeEnv } from "../../types/runtime-env.js";
|
|
2
|
+
export interface TokenPair {
|
|
3
|
+
accessToken: string;
|
|
4
|
+
refreshToken: string;
|
|
5
|
+
accessTokenExpiresAt?: number;
|
|
6
|
+
refreshTokenExpiresAt?: number;
|
|
7
|
+
tokenType?: "Bearer";
|
|
8
|
+
}
|
|
9
|
+
export interface TokenClaims {
|
|
10
|
+
sub?: string;
|
|
11
|
+
login?: string;
|
|
12
|
+
teams?: string[];
|
|
13
|
+
email?: string;
|
|
14
|
+
avatarUrl?: string;
|
|
15
|
+
exp?: number;
|
|
16
|
+
iat?: number;
|
|
17
|
+
[key: string]: unknown;
|
|
18
|
+
}
|
|
19
|
+
export interface SessionState {
|
|
20
|
+
accessToken?: string;
|
|
21
|
+
refreshToken?: string;
|
|
22
|
+
accessTokenExpiresAt?: number;
|
|
23
|
+
refreshTokenExpiresAt?: number;
|
|
24
|
+
claims?: TokenClaims;
|
|
25
|
+
}
|
|
26
|
+
export interface SessionAuthOptions {
|
|
27
|
+
targetEnv: RuntimeEnv;
|
|
28
|
+
}
|
|
29
|
+
export interface SessionBootstrapConfig {
|
|
30
|
+
auth: {
|
|
31
|
+
loginEndpoint: string;
|
|
32
|
+
refreshEndpoint: string;
|
|
33
|
+
revokeEndpoint?: string;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { SecureStore } from "./types.js";
|
|
2
|
+
export interface KeychainStoreOptions {
|
|
3
|
+
service?: string;
|
|
4
|
+
account?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare class KeychainStore implements SecureStore {
|
|
7
|
+
private readonly service;
|
|
8
|
+
private readonly account;
|
|
9
|
+
constructor(options?: KeychainStoreOptions);
|
|
10
|
+
get(key: string): Promise<string | null>;
|
|
11
|
+
set(key: string, value: string): Promise<void>;
|
|
12
|
+
remove(key: string): Promise<void>;
|
|
13
|
+
private accountFor;
|
|
14
|
+
private assertPlatformSupported;
|
|
15
|
+
private escapePs;
|
|
16
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
export class KeychainStore {
|
|
5
|
+
service;
|
|
6
|
+
account;
|
|
7
|
+
constructor(options = {}) {
|
|
8
|
+
this.service = options.service ?? "viza-cli-dispatcher";
|
|
9
|
+
this.account = options.account ?? "refresh-token";
|
|
10
|
+
this.assertPlatformSupported();
|
|
11
|
+
}
|
|
12
|
+
async get(key) {
|
|
13
|
+
if (process.platform === "darwin") {
|
|
14
|
+
try {
|
|
15
|
+
const { stdout } = await execFileAsync("security", [
|
|
16
|
+
"find-generic-password",
|
|
17
|
+
"-a",
|
|
18
|
+
this.accountFor(key),
|
|
19
|
+
"-s",
|
|
20
|
+
this.service,
|
|
21
|
+
"-w",
|
|
22
|
+
]);
|
|
23
|
+
const value = stdout.trim();
|
|
24
|
+
return value.length > 0 ? value : null;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const script = [
|
|
31
|
+
"$vault = New-Object Windows.Security.Credentials.PasswordVault",
|
|
32
|
+
`$cred = $vault.Retrieve('${this.escapePs(this.service)}','${this.escapePs(this.accountFor(key))}')`,
|
|
33
|
+
"$cred.RetrievePassword()",
|
|
34
|
+
"Write-Output $cred.Password",
|
|
35
|
+
].join(";");
|
|
36
|
+
try {
|
|
37
|
+
const { stdout } = await execFileAsync("powershell", ["-NoProfile", "-Command", script]);
|
|
38
|
+
const value = stdout.trim();
|
|
39
|
+
return value.length > 0 ? value : null;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async set(key, value) {
|
|
46
|
+
if (process.platform === "darwin") {
|
|
47
|
+
await execFileAsync("security", [
|
|
48
|
+
"add-generic-password",
|
|
49
|
+
"-U",
|
|
50
|
+
"-a",
|
|
51
|
+
this.accountFor(key),
|
|
52
|
+
"-s",
|
|
53
|
+
this.service,
|
|
54
|
+
"-w",
|
|
55
|
+
value,
|
|
56
|
+
]);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const script = [
|
|
60
|
+
"$vault = New-Object Windows.Security.Credentials.PasswordVault",
|
|
61
|
+
`$cred = New-Object Windows.Security.Credentials.PasswordCredential('${this.escapePs(this.service)}','${this.escapePs(this.accountFor(key))}','${this.escapePs(value)}')`,
|
|
62
|
+
"$vault.Add($cred)",
|
|
63
|
+
].join(";");
|
|
64
|
+
await execFileAsync("powershell", ["-NoProfile", "-Command", script]);
|
|
65
|
+
}
|
|
66
|
+
async remove(key) {
|
|
67
|
+
if (process.platform === "darwin") {
|
|
68
|
+
try {
|
|
69
|
+
await execFileAsync("security", [
|
|
70
|
+
"delete-generic-password",
|
|
71
|
+
"-a",
|
|
72
|
+
this.accountFor(key),
|
|
73
|
+
"-s",
|
|
74
|
+
this.service,
|
|
75
|
+
]);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// noop
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const script = [
|
|
83
|
+
"$vault = New-Object Windows.Security.Credentials.PasswordVault",
|
|
84
|
+
`$cred = $vault.Retrieve('${this.escapePs(this.service)}','${this.escapePs(this.accountFor(key))}')`,
|
|
85
|
+
"$vault.Remove($cred)",
|
|
86
|
+
].join(";");
|
|
87
|
+
try {
|
|
88
|
+
await execFileAsync("powershell", ["-NoProfile", "-Command", script]);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// noop
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
accountFor(key) {
|
|
95
|
+
return `${this.account}:${key}`;
|
|
96
|
+
}
|
|
97
|
+
assertPlatformSupported() {
|
|
98
|
+
if (process.platform === "darwin" || process.platform === "win32") {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
throw new Error("Secure refresh-token storage is unsupported on this platform. " +
|
|
102
|
+
"Linux requires an explicit secure backend integration.");
|
|
103
|
+
}
|
|
104
|
+
escapePs(value) {
|
|
105
|
+
return value.replace(/'/g, "''");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare class MemoryTokenStore {
|
|
2
|
+
private accessToken;
|
|
3
|
+
private accessTokenExpiresAt;
|
|
4
|
+
setAccessToken(token?: string, expiresAt?: number): void;
|
|
5
|
+
getAccessToken(): {
|
|
6
|
+
token: string | undefined;
|
|
7
|
+
expiresAt: number | undefined;
|
|
8
|
+
};
|
|
9
|
+
clear(): void;
|
|
10
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export class MemoryTokenStore {
|
|
2
|
+
accessToken;
|
|
3
|
+
accessTokenExpiresAt;
|
|
4
|
+
setAccessToken(token, expiresAt) {
|
|
5
|
+
this.accessToken = token;
|
|
6
|
+
this.accessTokenExpiresAt = expiresAt;
|
|
7
|
+
}
|
|
8
|
+
getAccessToken() {
|
|
9
|
+
return { token: this.accessToken, expiresAt: this.accessTokenExpiresAt };
|
|
10
|
+
}
|
|
11
|
+
clear() {
|
|
12
|
+
this.accessToken = undefined;
|
|
13
|
+
this.accessTokenExpiresAt = undefined;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|