Merge pull request #1534 from pikasTech/codex/1520-web-auth-canonical-principal
fix: canonicalize web auth principal
This commit is contained in:
@@ -193,7 +193,8 @@ class AccessController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (request.method === "GET" && url.pathname === "/auth/session") {
|
if (request.method === "GET" && url.pathname === "/auth/session") {
|
||||||
sendJson(response, 200, await this.sessionPayload(request));
|
const payload = await this.sessionPayload(request);
|
||||||
|
sendJson(response, payload.authenticated ? 200 : 401, payload);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,16 +361,17 @@ class AccessController {
|
|||||||
|
|
||||||
async sessionPayload(request) {
|
async sessionPayload(request) {
|
||||||
const auth = await this.authenticate(request, { required: false });
|
const auth = await this.authenticate(request, { required: false });
|
||||||
return {
|
const principal = canonicalPrincipalFromAuth(auth);
|
||||||
authenticated: Boolean(auth.actor),
|
const setupRequired = await this.setupRequired();
|
||||||
actor: auth.actor ? publicActor(auth.actor) : null,
|
if (!principal.authenticated) {
|
||||||
session: auth.session ?? null,
|
return {
|
||||||
authMethod: auth.actor ? publicAuthMethod(auth.authMethod ?? (auth.apiKey ? AUTH_METHOD_API_KEY : AUTH_METHOD_WEB_SESSION)) : null,
|
...principal,
|
||||||
sessionExpiresAt: textOr(auth.session?.expiresAt ?? "", "") || null,
|
setupRequired,
|
||||||
sessionTtlSeconds: SESSION_MAX_AGE_SECONDS,
|
contractVersion: "canonical-principal-v1",
|
||||||
setupRequired: await this.setupRequired(),
|
error: { code: "auth_required", message: "Authentication is required", status: 401 }
|
||||||
contractVersion: "user-access-v1"
|
};
|
||||||
};
|
}
|
||||||
|
return sessionResponseFromAuth(auth, { setupRequired });
|
||||||
}
|
}
|
||||||
|
|
||||||
async accessStatusPayload(request) {
|
async accessStatusPayload(request) {
|
||||||
@@ -623,7 +625,8 @@ class AccessController {
|
|||||||
},
|
},
|
||||||
apiKey: authMethod === AUTH_METHOD_USER_BILLING_API_KEY ? { id: principal.keyId ?? null, keyPrefix: redactedTokenPrefix(secret), name: "user-billing", displaySecret: secret } : null,
|
apiKey: authMethod === AUTH_METHOD_USER_BILLING_API_KEY ? { id: principal.keyId ?? null, keyPrefix: redactedTokenPrefix(secret), name: "user-billing", displaySecret: secret } : null,
|
||||||
authMethod,
|
authMethod,
|
||||||
userBilling: { active: true, principal: { ...principal, keyId: principal.keyId ?? null }, valuesRedacted: true }
|
userBilling: { active: true, token: secret, principal: { ...principal, keyId: principal.keyId ?? null }, valuesRedacted: true },
|
||||||
|
...canonicalPrincipalFromAuth({ actor: synced, authMethod })
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -821,11 +824,7 @@ class AccessController {
|
|||||||
}
|
}
|
||||||
const { token, session } = await this.issueWebSessionForActor(user);
|
const { token, session } = await this.issueWebSessionForActor(user);
|
||||||
setSessionCookie(response, token, SESSION_MAX_AGE_SECONDS, request, this.env);
|
setSessionCookie(response, token, SESSION_MAX_AGE_SECONDS, request, this.env);
|
||||||
return sendJson(response, 200, {
|
return sendJson(response, 200, sessionResponseFromAuth({ actor: user, session: publicSession({ ...session, user }), authMethod: AUTH_METHOD_WEB_SESSION }));
|
||||||
authenticated: true,
|
|
||||||
actor: publicActor(user),
|
|
||||||
session: publicSession({ ...session, user })
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleUserBillingRegister(request, response) {
|
async handleUserBillingRegister(request, response) {
|
||||||
@@ -846,17 +845,10 @@ class AccessController {
|
|||||||
if (!login.ok) {
|
if (!login.ok) {
|
||||||
return sendJson(response, login.status ?? 502, errorPayload(login.error?.code ?? "user_billing_login_failed", login.error?.message ?? "registration completed but login failed", login.status ?? 502));
|
return sendJson(response, login.status ?? 502, errorPayload(login.error?.code ?? "user_billing_login_failed", login.error?.message ?? "registration completed but login failed", login.status ?? 502));
|
||||||
}
|
}
|
||||||
return sendJson(response, 201, {
|
return sendJson(response, 201, sessionResponseFromAuth(login.auth, {
|
||||||
created: true,
|
created: true,
|
||||||
authenticated: true,
|
initialCredits: result.body?.initialCredits ?? null
|
||||||
actor: publicActor(login.auth.actor),
|
}));
|
||||||
user: publicActor(login.auth.actor),
|
|
||||||
session: login.auth.session,
|
|
||||||
authMethod: AUTH_METHOD_USER_BILLING_SESSION,
|
|
||||||
initialCredits: result.body?.initialCredits ?? null,
|
|
||||||
contractVersion: "user-billing-web-session-v1",
|
|
||||||
valuesRedacted: true
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async userBillingLogin(body, request, response) {
|
async userBillingLogin(body, request, response) {
|
||||||
@@ -872,15 +864,7 @@ class AccessController {
|
|||||||
response: sendJson(response, result.status ?? 502, userBillingDependencyErrorPayload(result, "user_billing_login_failed", "user-billing login failed", result.status ?? 502))
|
response: sendJson(response, result.status ?? 502, userBillingDependencyErrorPayload(result, "user_billing_login_failed", "user-billing login failed", result.status ?? 502))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
sendJson(response, 200, {
|
sendJson(response, 200, sessionResponseFromAuth(result.auth));
|
||||||
authenticated: true,
|
|
||||||
actor: publicActor(result.auth.actor),
|
|
||||||
user: publicActor(result.auth.actor),
|
|
||||||
session: result.auth.session,
|
|
||||||
authMethod: AUTH_METHOD_USER_BILLING_SESSION,
|
|
||||||
contractVersion: "user-billing-web-session-v1",
|
|
||||||
valuesRedacted: true
|
|
||||||
});
|
|
||||||
return { handled: true };
|
return { handled: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1463,6 +1447,55 @@ function publicActor(user) { return user ? { id: user.id, username: user.usernam
|
|||||||
function publicAuthMethod(method) {
|
function publicAuthMethod(method) {
|
||||||
return [AUTH_METHOD_API_KEY, AUTH_METHOD_WEB_SESSION, AUTH_METHOD_LEGACY_LOCAL_SESSION, AUTH_METHOD_USER_BILLING_API_KEY, AUTH_METHOD_USER_BILLING_SESSION].includes(method) ? method : AUTH_METHOD_WEB_SESSION;
|
return [AUTH_METHOD_API_KEY, AUTH_METHOD_WEB_SESSION, AUTH_METHOD_LEGACY_LOCAL_SESSION, AUTH_METHOD_USER_BILLING_API_KEY, AUTH_METHOD_USER_BILLING_SESSION].includes(method) ? method : AUTH_METHOD_WEB_SESSION;
|
||||||
}
|
}
|
||||||
|
function canonicalPrincipalFromAuth(auth = {}) {
|
||||||
|
const actor = auth.actor ?? null;
|
||||||
|
const authenticated = Boolean(actor);
|
||||||
|
const authMethod = authenticated ? publicAuthMethod(auth.authMethod ?? (auth.apiKey ? AUTH_METHOD_API_KEY : AUTH_METHOD_WEB_SESSION)) : null;
|
||||||
|
return {
|
||||||
|
authenticated,
|
||||||
|
authMethod,
|
||||||
|
identityAuthority: authenticated ? identityAuthorityForAuthMethod(authMethod) : null,
|
||||||
|
sessionKind: authenticated ? sessionKindForAuthMethod(authMethod) : null,
|
||||||
|
actor: authenticated ? publicActor(actor) : null,
|
||||||
|
capabilities: capabilitySummaryForPrincipal({ actor, authMethod, userBilling: auth.userBilling }),
|
||||||
|
valuesRedacted: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function sessionResponseFromAuth(auth = {}, extra = {}) {
|
||||||
|
const principal = canonicalPrincipalFromAuth(auth);
|
||||||
|
return {
|
||||||
|
...principal,
|
||||||
|
user: principal.actor,
|
||||||
|
session: auth.session ?? null,
|
||||||
|
sessionExpiresAt: textOr(auth.session?.expiresAt ?? "", "") || null,
|
||||||
|
sessionTtlSeconds: SESSION_MAX_AGE_SECONDS,
|
||||||
|
contractVersion: "canonical-principal-v1",
|
||||||
|
...extra
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function identityAuthorityForAuthMethod(authMethod) {
|
||||||
|
if (authMethod === AUTH_METHOD_USER_BILLING_API_KEY || authMethod === AUTH_METHOD_USER_BILLING_SESSION) return "user-billing";
|
||||||
|
if (authMethod === AUTH_METHOD_LEGACY_LOCAL_SESSION) return "bootstrap-recovery";
|
||||||
|
return "hwlab-local";
|
||||||
|
}
|
||||||
|
function sessionKindForAuthMethod(authMethod) {
|
||||||
|
if (authMethod === AUTH_METHOD_API_KEY || authMethod === AUTH_METHOD_USER_BILLING_API_KEY) return "api-key";
|
||||||
|
if (authMethod === AUTH_METHOD_LEGACY_LOCAL_SESSION) return "recovery";
|
||||||
|
return "browser";
|
||||||
|
}
|
||||||
|
function capabilitySummaryForPrincipal({ actor = null, authMethod = null, userBilling = null } = {}) {
|
||||||
|
const authenticated = Boolean(actor);
|
||||||
|
const admin = authenticated && actor?.role === "admin";
|
||||||
|
const billingBacked = authenticated && (Boolean(userBilling?.active) || authMethod === AUTH_METHOD_USER_BILLING_API_KEY || authMethod === AUTH_METHOD_USER_BILLING_SESSION);
|
||||||
|
return {
|
||||||
|
web: authenticated ? "available" : "forbidden",
|
||||||
|
workbench: authenticated ? "available" : "forbidden",
|
||||||
|
codeAgent: authenticated ? "available" : "forbidden",
|
||||||
|
billing: billingBacked ? "available" : authenticated ? "unlinked" : "forbidden",
|
||||||
|
apiKeys: authenticated ? "available" : "forbidden",
|
||||||
|
admin: admin ? "available" : "forbidden"
|
||||||
|
};
|
||||||
|
}
|
||||||
function userBillingActor(principal = {}) {
|
function userBillingActor(principal = {}) {
|
||||||
const id = textOr(principal.userId, "");
|
const id = textOr(principal.userId, "");
|
||||||
const username = textOr(principal.username, "") || localUserBillingUsername(id);
|
const username = textOr(principal.username, "") || localUserBillingUsername(id);
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "bun:test";
|
||||||
|
|
||||||
|
import { createCloudApiServer } from "./server.ts";
|
||||||
|
|
||||||
|
test("/auth/session returns structured unauthenticated canonical result", async () => {
|
||||||
|
const server = createCloudApiServer({ env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" } });
|
||||||
|
await listen(server);
|
||||||
|
try {
|
||||||
|
const { port } = server.address() as { port: number };
|
||||||
|
const response = await fetch(`http://127.0.0.1:${port}/auth/session`);
|
||||||
|
assert.equal(response.status, 401);
|
||||||
|
const body = await response.json();
|
||||||
|
assert.equal(body.authenticated, false);
|
||||||
|
assert.equal(body.authMethod, null);
|
||||||
|
assert.equal(body.identityAuthority, null);
|
||||||
|
assert.equal(body.sessionKind, null);
|
||||||
|
assert.equal(body.actor, null);
|
||||||
|
assert.equal(body.valuesRedacted, true);
|
||||||
|
assert.equal(body.error.code, "auth_required");
|
||||||
|
assert.equal(JSON.stringify(body).includes("hwlab_session="), false);
|
||||||
|
} finally {
|
||||||
|
await close(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("/auth/session returns canonical principal for browser user-billing session", async () => {
|
||||||
|
const sessionToken = "hws_canonical_session_secret";
|
||||||
|
const userBillingClient = {
|
||||||
|
configured: true,
|
||||||
|
async login(body: any = {}) {
|
||||||
|
assert.equal(body.username, "canonical-user");
|
||||||
|
return { ok: true, status: 200, body: { token: sessionToken } };
|
||||||
|
},
|
||||||
|
async introspect(token: string) {
|
||||||
|
assert.equal(token, sessionToken);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
active: true,
|
||||||
|
principal: {
|
||||||
|
userId: "usr_canonical_web",
|
||||||
|
email: "canonical@hwlab.local",
|
||||||
|
username: "canonical-user",
|
||||||
|
role: "user",
|
||||||
|
status: "active",
|
||||||
|
scopes: ["session"],
|
||||||
|
authType: "session"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const server = createCloudApiServer({ env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, userBillingClient });
|
||||||
|
await listen(server);
|
||||||
|
try {
|
||||||
|
const { port } = server.address() as { port: number };
|
||||||
|
const login = await fetch(`http://127.0.0.1:${port}/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ username: "canonical-user", password: "canonical-password" })
|
||||||
|
});
|
||||||
|
assert.equal(login.status, 200);
|
||||||
|
const cookie = login.headers.get("set-cookie")?.split(";")[0] ?? "";
|
||||||
|
assert.match(cookie, /^hwlab_session=hws_/u);
|
||||||
|
const loginBody = await login.json();
|
||||||
|
assert.equal(loginBody.authMethod, "user-billing-session");
|
||||||
|
assert.equal(loginBody.identityAuthority, "user-billing");
|
||||||
|
assert.equal(loginBody.sessionKind, "browser");
|
||||||
|
assert.equal(loginBody.capabilities.billing, "available");
|
||||||
|
assert.equal(JSON.stringify(loginBody).includes(sessionToken), false);
|
||||||
|
|
||||||
|
const response = await fetch(`http://127.0.0.1:${port}/auth/session`, { headers: { cookie } });
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
const body = await response.json();
|
||||||
|
assert.equal(body.authenticated, true);
|
||||||
|
assert.equal(body.authMethod, "user-billing-session");
|
||||||
|
assert.equal(body.identityAuthority, "user-billing");
|
||||||
|
assert.equal(body.sessionKind, "browser");
|
||||||
|
assert.equal(body.actor.id, "usr_canonical_web");
|
||||||
|
assert.equal(body.actor.username, "canonical-user");
|
||||||
|
assert.equal(body.capabilities.web, "available");
|
||||||
|
assert.equal(body.capabilities.workbench, "available");
|
||||||
|
assert.equal(body.capabilities.codeAgent, "available");
|
||||||
|
assert.equal(body.capabilities.billing, "available");
|
||||||
|
assert.equal(body.capabilities.apiKeys, "available");
|
||||||
|
assert.equal(body.capabilities.admin, "forbidden");
|
||||||
|
assert.equal(body.valuesRedacted, true);
|
||||||
|
assert.equal(JSON.stringify(body).includes(sessionToken), false);
|
||||||
|
} finally {
|
||||||
|
await close(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("business billing route consumes authenticated canonical principal instead of re-reading request credential", async () => {
|
||||||
|
const delegatedToken = "hws_canonical_delegated_token";
|
||||||
|
const userBillingClient = {
|
||||||
|
configured: true,
|
||||||
|
async billingSummary(token: string, options: any = {}) {
|
||||||
|
assert.equal(token, delegatedToken);
|
||||||
|
assert.equal(options.limit, 4);
|
||||||
|
return { ok: true, status: 200, body: { contractVersion: "user-billing-summary-v1", credits: { balance: 9 }, valuesRedacted: true } };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const accessController = {
|
||||||
|
required: true,
|
||||||
|
async authenticate() {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
authenticated: true,
|
||||||
|
actor: { id: "usr_canonical_consumer", username: "canonical-consumer", role: "user", status: "active" },
|
||||||
|
session: { id: "uss_canonical_consumer", userId: "usr_canonical_consumer", tokenSource: "user-billing-session", valuesRedacted: true },
|
||||||
|
authMethod: "user-billing-session",
|
||||||
|
identityAuthority: "user-billing",
|
||||||
|
sessionKind: "browser",
|
||||||
|
capabilities: { web: "available", workbench: "available", codeAgent: "available", billing: "available", apiKeys: "available", admin: "forbidden" },
|
||||||
|
userBilling: { active: true, token: delegatedToken, principal: { userId: "usr_canonical_consumer", authType: "session" }, valuesRedacted: true },
|
||||||
|
valuesRedacted: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const server = createCloudApiServer({ env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, accessController, userBillingClient });
|
||||||
|
await listen(server);
|
||||||
|
try {
|
||||||
|
const { port } = server.address() as { port: number };
|
||||||
|
const response = await fetch(`http://127.0.0.1:${port}/v1/billing/summary?limit=4`);
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
const body = await response.json();
|
||||||
|
assert.equal(body.credits.balance, 9);
|
||||||
|
assert.equal(body.proxy.source, "hwlab-user-billing");
|
||||||
|
assert.equal(JSON.stringify(body).includes(delegatedToken), false);
|
||||||
|
} finally {
|
||||||
|
await close(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function listen(server: any) {
|
||||||
|
return new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(server: any) {
|
||||||
|
return new Promise<void>((resolve, reject) => server.close((error: Error | undefined) => (error ? reject(error) : resolve())));
|
||||||
|
}
|
||||||
@@ -1018,6 +1018,7 @@ async function codeAgentOptions(request, response, options, authOptions = {}) {
|
|||||||
authApiKey: auth.apiKey ?? null,
|
authApiKey: auth.apiKey ?? null,
|
||||||
actorApiKeySecret: auth.apiKey?.displaySecret ?? null,
|
actorApiKeySecret: auth.apiKey?.displaySecret ?? null,
|
||||||
userBillingAuth: auth.userBilling ?? null,
|
userBillingAuth: auth.userBilling ?? null,
|
||||||
|
userBillingToken: userBillingTokenFromAuth(auth),
|
||||||
userBillingClient: options.userBillingClient ?? options.accessController?.userBilling ?? null
|
userBillingClient: options.userBillingClient ?? options.accessController?.userBilling ?? null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1027,7 +1028,7 @@ async function handleUsageSummaryHttp(request, response, url, options) {
|
|||||||
const nextOptions = await codeAgentOptions(request, response, options, { required: true });
|
const nextOptions = await codeAgentOptions(request, response, options, { required: true });
|
||||||
if (!nextOptions) return;
|
if (!nextOptions) return;
|
||||||
const client = nextOptions.userBillingClient;
|
const client = nextOptions.userBillingClient;
|
||||||
const token = userBillingTokenFromRequest(request);
|
const token = nextOptions.userBillingToken;
|
||||||
if (nextOptions.userBillingAuth?.active !== true || !token) {
|
if (nextOptions.userBillingAuth?.active !== true || !token) {
|
||||||
sendRestError(request, response, 403, "user_billing_auth_required", "usage summary requires a user-billing session or API key", {
|
sendRestError(request, response, 403, "user_billing_auth_required", "usage summary requires a user-billing session or API key", {
|
||||||
operation: `GET ${route}`,
|
operation: `GET ${route}`,
|
||||||
@@ -1139,7 +1140,7 @@ async function requireUserBillingClient(request, response, options, operation) {
|
|||||||
const nextOptions = await codeAgentOptions(request, response, options, { required: true });
|
const nextOptions = await codeAgentOptions(request, response, options, { required: true });
|
||||||
if (!nextOptions) return null;
|
if (!nextOptions) return null;
|
||||||
const client = nextOptions.userBillingClient;
|
const client = nextOptions.userBillingClient;
|
||||||
const token = userBillingTokenFromRequest(request);
|
const token = nextOptions.userBillingToken;
|
||||||
if (nextOptions.userBillingAuth?.active !== true || !token) {
|
if (nextOptions.userBillingAuth?.active !== true || !token) {
|
||||||
sendRestError(request, response, 403, "user_billing_auth_required", "user-billing session or API key is required", {
|
sendRestError(request, response, 403, "user_billing_auth_required", "user-billing session or API key is required", {
|
||||||
operation,
|
operation,
|
||||||
@@ -1423,11 +1424,12 @@ async function handleAdminBillingUserStatusHttp(request, response, userId, optio
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function maybeHandleUserBillingApiKeysHttp(request, response, url, options) {
|
async function maybeHandleUserBillingApiKeysHttp(request, response, url, options) {
|
||||||
const token = userBillingTokenFromRequest(request);
|
|
||||||
const client = options.userBillingClient ?? options.accessController?.userBilling ?? null;
|
const client = options.userBillingClient ?? options.accessController?.userBilling ?? null;
|
||||||
if (!token || !client?.configured) return false;
|
if (!client?.configured) return false;
|
||||||
const auth = await options.accessController.authenticate(request, { required: true });
|
const auth = await options.accessController.authenticate(request, { required: true });
|
||||||
if (!auth.ok || !String(auth.authMethod ?? "").startsWith("user-billing")) return false;
|
if (!auth.ok || !String(auth.authMethod ?? "").startsWith("user-billing")) return false;
|
||||||
|
const token = userBillingTokenFromAuth(auth);
|
||||||
|
if (!token) return false;
|
||||||
if (url.pathname === "/v1/api-keys/default") {
|
if (url.pathname === "/v1/api-keys/default") {
|
||||||
sendRestError(request, response, 404, "user_billing_default_key_unsupported", "user-billing API keys do not support legacy default-key regeneration", {
|
sendRestError(request, response, 404, "user_billing_default_key_unsupported", "user-billing API keys do not support legacy default-key regeneration", {
|
||||||
operation: `${request.method} /v1/api-keys/default`,
|
operation: `${request.method} /v1/api-keys/default`,
|
||||||
@@ -1466,10 +1468,10 @@ async function maybeHandleUserBillingApiKeysHttp(request, response, url, options
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleUserBillingProfileHttp(request, response, options) {
|
async function handleUserBillingProfileHttp(request, response, options) {
|
||||||
const token = userBillingTokenFromRequest(request);
|
|
||||||
const client = options.userBillingClient ?? options.accessController?.userBilling ?? null;
|
const client = options.userBillingClient ?? options.accessController?.userBilling ?? null;
|
||||||
const auth = await options.accessController.authenticate(request, { required: true });
|
const auth = await options.accessController.authenticate(request, { required: true });
|
||||||
if (!auth.ok) return sendJson(response, auth.status, auth);
|
if (!auth.ok) return sendJson(response, auth.status, auth);
|
||||||
|
const token = userBillingTokenFromAuth(auth);
|
||||||
if (!String(auth.authMethod ?? "").startsWith("user-billing")) {
|
if (!String(auth.authMethod ?? "").startsWith("user-billing")) {
|
||||||
return sendRestError(request, response, 403, "user_billing_auth_required", "profile update requires a user-billing bearer token", { operation: "PATCH /v1/users/me/profile", target: { type: "route", id: "/v1/users/me/profile" } });
|
return sendRestError(request, response, 403, "user_billing_auth_required", "profile update requires a user-billing bearer token", { operation: "PATCH /v1/users/me/profile", target: { type: "route", id: "/v1/users/me/profile" } });
|
||||||
}
|
}
|
||||||
@@ -1483,10 +1485,10 @@ async function handleUserBillingProfileHttp(request, response, options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleUserBillingPasswordHttp(request, response, options) {
|
async function handleUserBillingPasswordHttp(request, response, options) {
|
||||||
const token = userBillingTokenFromRequest(request);
|
|
||||||
const client = options.userBillingClient ?? options.accessController?.userBilling ?? null;
|
const client = options.userBillingClient ?? options.accessController?.userBilling ?? null;
|
||||||
const auth = await options.accessController.authenticate(request, { required: true });
|
const auth = await options.accessController.authenticate(request, { required: true });
|
||||||
if (!auth.ok) return sendJson(response, auth.status, auth);
|
if (!auth.ok) return sendJson(response, auth.status, auth);
|
||||||
|
const token = userBillingTokenFromAuth(auth);
|
||||||
if (auth.authMethod !== "user-billing-session") {
|
if (auth.authMethod !== "user-billing-session") {
|
||||||
return sendRestError(request, response, 403, "user_billing_session_required", "password update requires a user-billing session token", { operation: "POST /v1/users/me/password", target: { type: "route", id: "/v1/users/me/password" } });
|
return sendRestError(request, response, 403, "user_billing_session_required", "password update requires a user-billing session token", { operation: "POST /v1/users/me/password", target: { type: "route", id: "/v1/users/me/password" } });
|
||||||
}
|
}
|
||||||
@@ -1581,6 +1583,11 @@ function userBillingTokenFromRequest(request) {
|
|||||||
return cookieToken.startsWith("hws_") ? cookieToken : "";
|
return cookieToken.startsWith("hws_") ? cookieToken : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function userBillingTokenFromAuth(auth) {
|
||||||
|
const token = String(auth?.userBilling?.token ?? "").trim();
|
||||||
|
return token ? token : "";
|
||||||
|
}
|
||||||
|
|
||||||
function cookieValueFromRequest(request, name) {
|
function cookieValueFromRequest(request, name) {
|
||||||
const target = String(name ?? "");
|
const target = String(name ?? "");
|
||||||
if (!target) return "";
|
if (!target) return "";
|
||||||
|
|||||||
@@ -79,7 +79,10 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
|
|||||||
return json(response, 200, stateSummary());
|
return json(response, 200, stateSummary());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === "/auth/session" || path === "/auth/bootstrap") return json(response, 200, authPayload());
|
if (path === "/auth/session" || path === "/auth/bootstrap") {
|
||||||
|
const payload = authPayload();
|
||||||
|
return json(response, payload.authenticated === true ? 200 : 401, payload);
|
||||||
|
}
|
||||||
if (path === "/auth/login" && method === "POST") return authLoginResponse(response);
|
if (path === "/auth/login" && method === "POST") return authLoginResponse(response);
|
||||||
if (path === "/v1/workbench/events" && method === "GET") return sse(response, url);
|
if (path === "/v1/workbench/events" && method === "GET") return sse(response, url);
|
||||||
if (path === "/v1/workbench/sessions" && method === "GET") {
|
if (path === "/v1/workbench/sessions" && method === "GET") {
|
||||||
@@ -620,8 +623,12 @@ function isLegacyWorkbenchPath(path: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function authPayload(): JsonRecord {
|
function authPayload(): JsonRecord {
|
||||||
if (state.scenarioId === "auth-upstream-unavailable" || state.scenarioId === "auth-invalid-credentials") return { authenticated: false, mode: "server", actor: null, user: null, expiresAt: null };
|
const deniedCapabilities = { web: "forbidden", workbench: "forbidden", codeAgent: "forbidden", billing: "forbidden", apiKeys: "forbidden", admin: "forbidden" };
|
||||||
return { authenticated: true, mode: "server", actor: { id: "usr_e2e_admin", username: "e2e-admin", role: "admin", status: "active" }, user: { id: "usr_e2e_admin", username: "e2e-admin", role: "admin", status: "active" }, expiresAt: null };
|
if (state.scenarioId === "auth-upstream-unavailable" || state.scenarioId === "auth-invalid-credentials") {
|
||||||
|
return { authenticated: false, mode: "server", authMethod: null, identityAuthority: null, sessionKind: null, actor: null, user: null, capabilities: deniedCapabilities, expiresAt: null, valuesRedacted: true, error: { code: "auth_required", message: "Authentication is required", status: 401 } };
|
||||||
|
}
|
||||||
|
const actor = { id: "usr_e2e_admin", username: "e2e-admin", role: "admin", status: "active" };
|
||||||
|
return { authenticated: true, mode: "server", authMethod: "web-session", identityAuthority: "hwlab-local", sessionKind: "browser", actor, user: actor, capabilities: { web: "available", workbench: "available", codeAgent: "available", billing: "unlinked", apiKeys: "available", admin: "available" }, expiresAt: null, valuesRedacted: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
function authLoginResponse(response: ServerResponse): void {
|
function authLoginResponse(response: ServerResponse): void {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { authAPI, HWLAB_WEB_SESSION_COOKIE } from "@/api";
|
import { authAPI, HWLAB_WEB_SESSION_COOKIE } from "@/api";
|
||||||
import type { AuthSession, AuthState, AuthUser } from "@/types";
|
import type { AuthCapabilities, AuthCapabilityStatus, AuthSession, AuthState, AuthUser } from "@/types";
|
||||||
import { asRecord, nonEmptyString } from "@/utils";
|
import { asRecord, nonEmptyString } from "@/utils";
|
||||||
|
|
||||||
export const useAuthStore = defineStore("auth", () => {
|
export const useAuthStore = defineStore("auth", () => {
|
||||||
@@ -12,7 +12,7 @@ export const useAuthStore = defineStore("auth", () => {
|
|||||||
|
|
||||||
const isAuthenticated = computed(() => authState.value === "authenticated" && session.value?.authenticated === true);
|
const isAuthenticated = computed(() => authState.value === "authenticated" && session.value?.authenticated === true);
|
||||||
const user = computed(() => session.value?.user ?? session.value?.actor ?? null);
|
const user = computed(() => session.value?.user ?? session.value?.actor ?? null);
|
||||||
const isAdmin = computed(() => user.value?.role === "admin");
|
const isAdmin = computed(() => session.value?.capabilities?.admin === "available" || user.value?.role === "admin");
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
if (authState.value !== "checking") checked.value = true;
|
if (authState.value !== "checking") checked.value = true;
|
||||||
@@ -73,8 +73,19 @@ export const useAuthStore = defineStore("auth", () => {
|
|||||||
return { authState, checked, session, error, isAuthenticated, user, isAdmin, bootstrap, login, register, logout, cookieName: HWLAB_WEB_SESSION_COOKIE };
|
return { authState, checked, session, error, isAuthenticated, user, isAdmin, bootstrap, login, register, logout, cookieName: HWLAB_WEB_SESSION_COOKIE };
|
||||||
});
|
});
|
||||||
|
|
||||||
function sessionFromPayload(payload: { authenticated?: boolean; user?: unknown; actor?: unknown; expiresAt?: string | null }): AuthSession {
|
function sessionFromPayload(payload: { authenticated?: boolean; authMethod?: unknown; identityAuthority?: unknown; sessionKind?: unknown; user?: unknown; actor?: unknown; capabilities?: unknown; expiresAt?: string | null; sessionExpiresAt?: string | null; valuesRedacted?: unknown }): AuthSession {
|
||||||
return { authenticated: payload.authenticated === true, mode: "server", user: userFromUnknown(payload.user), actor: userFromUnknown(payload.actor), expiresAt: nonEmptyString(payload.expiresAt) };
|
return {
|
||||||
|
authenticated: payload.authenticated === true,
|
||||||
|
mode: "server",
|
||||||
|
authMethod: nonEmptyString(payload.authMethod) ?? null,
|
||||||
|
identityAuthority: nonEmptyString(payload.identityAuthority) ?? null,
|
||||||
|
sessionKind: nonEmptyString(payload.sessionKind) ?? null,
|
||||||
|
user: userFromUnknown(payload.user),
|
||||||
|
actor: userFromUnknown(payload.actor),
|
||||||
|
capabilities: capabilitiesFromUnknown(payload.capabilities),
|
||||||
|
expiresAt: nonEmptyString(payload.expiresAt) ?? nonEmptyString(payload.sessionExpiresAt),
|
||||||
|
valuesRedacted: payload.valuesRedacted === true
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function userFromUnknown(value: unknown): AuthUser | undefined {
|
function userFromUnknown(value: unknown): AuthUser | undefined {
|
||||||
@@ -83,6 +94,17 @@ function userFromUnknown(value: unknown): AuthUser | undefined {
|
|||||||
return { id: nonEmptyString(record.id) ?? undefined, username: nonEmptyString(record.username) ?? undefined, name: nonEmptyString(record.name) ?? undefined, displayName: nonEmptyString(record.displayName) ?? undefined, role: nonEmptyString(record.role) ?? undefined, status: nonEmptyString(record.status) ?? undefined };
|
return { id: nonEmptyString(record.id) ?? undefined, username: nonEmptyString(record.username) ?? undefined, name: nonEmptyString(record.name) ?? undefined, displayName: nonEmptyString(record.displayName) ?? undefined, role: nonEmptyString(record.role) ?? undefined, status: nonEmptyString(record.status) ?? undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function capabilitiesFromUnknown(value: unknown): AuthCapabilities | undefined {
|
||||||
|
const record = asRecord(value);
|
||||||
|
if (!record) return undefined;
|
||||||
|
const output: AuthCapabilities = {};
|
||||||
|
for (const [key, item] of Object.entries(record)) {
|
||||||
|
const status = nonEmptyString(item) as AuthCapabilityStatus | undefined;
|
||||||
|
if (status) output[key] = status;
|
||||||
|
}
|
||||||
|
return Object.keys(output).length > 0 ? output : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function loginFailureMessage(response: Awaited<ReturnType<typeof authAPI.login>>): string {
|
function loginFailureMessage(response: Awaited<ReturnType<typeof authAPI.login>>): string {
|
||||||
const code = loginErrorCode(response.data) ?? nonEmptyString(response.error);
|
const code = loginErrorCode(response.data) ?? nonEmptyString(response.error);
|
||||||
const normalizedCode = code?.trim().toLowerCase().replace(/_/gu, "-") ?? "";
|
const normalizedCode = code?.trim().toLowerCase().replace(/_/gu, "-") ?? "";
|
||||||
|
|||||||
@@ -19,12 +19,29 @@ export interface AuthUser {
|
|||||||
status?: AccessUserStatus;
|
status?: AccessUserStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AuthCapabilityStatus = "available" | "forbidden" | "unlinked" | "unavailable" | string;
|
||||||
|
|
||||||
|
export interface AuthCapabilities {
|
||||||
|
web?: AuthCapabilityStatus;
|
||||||
|
workbench?: AuthCapabilityStatus;
|
||||||
|
codeAgent?: AuthCapabilityStatus;
|
||||||
|
billing?: AuthCapabilityStatus;
|
||||||
|
apiKeys?: AuthCapabilityStatus;
|
||||||
|
admin?: AuthCapabilityStatus;
|
||||||
|
[key: string]: AuthCapabilityStatus | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthSession {
|
export interface AuthSession {
|
||||||
authenticated: boolean;
|
authenticated: boolean;
|
||||||
mode: "server";
|
mode: "server";
|
||||||
|
authMethod?: string | null;
|
||||||
|
identityAuthority?: string | null;
|
||||||
|
sessionKind?: string | null;
|
||||||
user?: AuthUser;
|
user?: AuthUser;
|
||||||
actor?: AuthUser;
|
actor?: AuthUser;
|
||||||
|
capabilities?: AuthCapabilities;
|
||||||
expiresAt?: string | null;
|
expiresAt?: string | null;
|
||||||
|
valuesRedacted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiResult<T> {
|
export interface ApiResult<T> {
|
||||||
|
|||||||
Reference in New Issue
Block a user