Files
triggerdotdev--trigger.dev/apps/webapp/app/services/apiAuth.server.ts
Chris Arderne 9409ddf9bc feat(webapp): add multiple environment API key management (#4390)
## Summary

Projects can create, inspect, expire, and revoke multiple API keys for
each environment. Plaintext values are shown only at creation; stored
credentials are hashed and the API keys page displays only an obfuscated
suffix afterward.

Self-hosted installations support full-access additional keys by
default. Authorization extensions can provide additional access presets
and optional task selection. Additional keys can also mint scoped public
access tokens through the Trigger.dev API without receiving the
environment signing key.

## Feature notes
- Only admin+ can create API keys (Developer can make in Development
branch).
- JWT self-signing will be a server call when used with new `_ak_` keys.
- JWTs with long expiry can keep working even with api key deleted (gets
priveleges from api key, signed with root key)
- Unfiltered session listings intentionally preserve the existing broad
task-read behavior. Filtered listings enforce task-level scopes for
every requested task.
- Buffered runs without a task identifier are not safely authorizable,
so cancel/replay requests fail closed rather than resolving an unscoped
run.
- Batch and waitpoint endpoints intentionally return server-minted,
narrowly scoped public tokens to all callers. These tokens have bounded
lifetimes and may remain valid until expiry after API-key revocation.

## Deployment notes

Deploy the management UI and public-token endpoint with new key creation
disabled. Enable creation for selected organizations after the
authentication path and released SDK have been verified, then expand
availability gradually.

Revoking an API key prevents new bearer requests and new token minting.
Public tokens already minted by that key remain valid until their own
expiration because they are signed by the environment signing key.

## TODO
- [x] Add "Created by" to the key table
- [x] Document that streamed batch ingestion is non-atomic and may
 partially accept items before a validation or authorization error.

## Follow-ups

- [x] Add an organization-level feature flag for the API key management
UI and creation action.
- [x] Document rollout ordering: enable additional-key lookup before
enabling issuance.
- [x] Add a system-wide gate that can stop new key issuance without
disabling authentication for existing keys.
- [x] Replace the generic SDK compatibility warning with the first
published compatible version. Old SDK will mint an unusable token if
given an `_ak_` key.
- [x] Add public documentation covering creation, storage, expiration,
revocation, SDK compatibility, and public-token lifetime behavior.
- [x] Add observability for key creation, revocation, policy preparation
failures, and public-token mint failures.
- [ ] Exercise create, copy-once display, authenticate, mint, expire,
and revoke flows end to end before broad enablement.
2026-08-06 15:27:10 +01:00

865 lines
24 KiB
TypeScript

import { json } from "@remix-run/server-runtime";
import { SignJWT, errors, jwtVerify } from "jose";
import { z } from "zod";
import { $replica } from "~/db.server";
import { env } from "~/env.server";
import { findProjectByRef } from "~/models/project.server";
import {
authIncludeBase,
authIncludeWithParent,
findEnvironmentByApiKey,
findEnvironmentByApiKeyWithResolution,
findEnvironmentByPublicApiKey,
toAuthenticated,
} from "~/models/runtimeEnvironment.server";
import type { RbacAbility, RbacResource } from "@trigger.dev/rbac";
import { type RuntimeEnvironmentForEnvRepo } from "~/v3/environmentVariables/environmentVariablesRepository.server";
import { logger } from "./logger.server";
import { safeEnvironmentLogFields } from "./safeEnvironmentLog";
import { missingJwtLogContext } from "./safeRequestLogContext";
import {
type PersonalAccessTokenAuthenticationResult,
authenticateApiRequestWithPersonalAccessToken,
isPersonalAccessToken,
} from "./personalAccessToken.server";
import {
type OrganizationAccessTokenAuthenticationResult,
authenticateApiRequestWithOrganizationAccessToken,
isOrganizationAccessToken,
} from "./organizationAccessToken.server";
import { isPublicJWT, validatePublicJwtKey } from "./realtime/jwtAuth.server";
import { isDefaultDevBranch, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch";
import {
authenticateAuthorizeBearerWithTelemetry,
observeLegacyBearerAuthentication,
} from "~/services/authTelemetry.server";
const ClaimsSchema = z.object({
scopes: z.array(z.string()).optional(),
// One-time use token
otu: z.boolean().optional(),
realtime: z
.object({
skipColumns: z.array(z.string()).optional(),
})
.optional(),
});
// Re-export the slim shape defined in @trigger.dev/core. Single source of
// truth across the auth boundary (RBAC plugin contract → webapp handlers).
export type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment";
import type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment";
export type ApiAuthenticationResult =
| ApiAuthenticationResultSuccess
| ApiAuthenticationResultFailure;
export type ApiAuthenticationResultSuccess = {
ok: true;
apiKey: string;
type: "PUBLIC" | "PRIVATE" | "PUBLIC_JWT";
environment: AuthenticatedEnvironment;
oneTimeUse?: boolean;
realtime?: {
skipColumns?: string[];
};
// Present when authentication went through the RBAC bearer controller.
// Legacy direct authentication intentionally omits it and remains fail-closed
// for restricted additional keys.
ability?: RbacAbility;
// Present when the request used a public JWT minted from a PAT/UAT exchange
// that stamped an `act` delegation claim. `actor.sub` is the acting user id,
// used for attribution (e.g. who resolved an error). Absent for plain env
// API keys (no user) and JWTs minted without delegation.
actor?: {
sub: string;
};
};
export type ApiAuthenticationResultFailure = {
ok: false;
error: string;
};
/**
* @deprecated Use `authenticateApiRequestWithFailure` instead.
*/
export async function authenticateApiRequest(
request: Request,
options: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
): Promise<ApiAuthenticationResultSuccess | undefined> {
const { apiKey, branchName } = getApiKeyFromRequest(request);
if (!apiKey) {
return;
}
return observeLegacyBearerAuthentication(request, () =>
authenticateApiKey(apiKey, { ...options, branchName })
);
}
/**
* This method is the same as `authenticateApiRequest` but it returns a failure result instead of undefined.
* It should be used from now on to ensure that the API key is always validated and provide a failure result.
*/
export async function authenticateApiRequestWithFailure(
request: Request,
options: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
): Promise<ApiAuthenticationResult> {
const { apiKey, branchName } = getApiKeyFromRequest(request);
if (!apiKey) {
return {
ok: false,
error: "Invalid API Key",
};
}
return observeLegacyBearerAuthentication(request, () =>
authenticateApiKeyWithFailure(apiKey, { ...options, branchName })
);
}
/**
* @deprecated Use `authenticateApiKeyWithFailure` instead.
*/
export async function authenticateApiKey(
apiKey: string,
options: {
allowPublicKey?: boolean;
allowJWT?: boolean;
branchName?: string;
} = {}
): Promise<ApiAuthenticationResultSuccess | undefined> {
const result = getApiKeyResult(apiKey);
if (!result) {
return;
}
if (!options.allowPublicKey && result.type === "PUBLIC") {
return;
}
if (!options.allowJWT && result.type === "PUBLIC_JWT") {
return;
}
switch (result.type) {
case "PUBLIC": {
const environment = await findEnvironmentByPublicApiKey(result.apiKey, options.branchName);
if (!environment) {
return;
}
return {
ok: true,
...result,
environment,
};
}
case "PRIVATE": {
const environment = await findEnvironmentByApiKey(result.apiKey, options.branchName);
if (!environment) {
return;
}
return {
ok: true,
...result,
environment,
};
}
case "PUBLIC_JWT": {
const validationResults = await validatePublicJwtKey(result.apiKey);
if (!validationResults.ok) {
return;
}
const parsedClaims = ClaimsSchema.safeParse(validationResults.claims);
return {
ok: true,
...result,
environment: validationResults.environment,
oneTimeUse: parsedClaims.success ? parsedClaims.data.otu : false,
realtime: parsedClaims.success ? parsedClaims.data.realtime : undefined,
};
}
}
}
/**
* This method is the same as `authenticateApiKey` but it returns a failure result instead of undefined.
* It should be used from now on to ensure that the API key is always validated and provide a failure result.
*/
async function authenticateApiKeyWithFailure(
apiKey: string,
options: {
allowPublicKey?: boolean;
allowJWT?: boolean;
branchName?: string;
} = {}
): Promise<ApiAuthenticationResult> {
const result = getApiKeyResult(apiKey);
if (!result) {
return {
ok: false,
error: "Invalid API Key",
};
}
if (!options.allowPublicKey && result.type === "PUBLIC") {
return {
ok: false,
error: "Public API keys are not allowed for this request",
};
}
if (!options.allowJWT && result.type === "PUBLIC_JWT") {
return {
ok: false,
error: "Public JWT API keys are not allowed for this request",
};
}
switch (result.type) {
case "PUBLIC": {
const environment = await findEnvironmentByPublicApiKey(result.apiKey, options.branchName);
if (!environment) {
return {
ok: false,
error: "Invalid API Key",
};
}
return {
ok: true,
...result,
environment,
};
}
case "PRIVATE": {
const resolution = await findEnvironmentByApiKeyWithResolution(
result.apiKey,
options.branchName
);
if (!resolution.ok) {
return {
ok: false,
error:
resolution.reason === "restricted"
? "This endpoint does not support restricted API keys. Use an API key with full environment access."
: "Invalid API Key",
};
}
return {
ok: true,
...result,
environment: resolution.environment,
};
}
case "PUBLIC_JWT": {
const validationResults = await validatePublicJwtKey(result.apiKey);
if (!validationResults.ok) {
return validationResults;
}
const parsedClaims = ClaimsSchema.safeParse(validationResults.claims);
return {
ok: true,
...result,
environment: validationResults.environment,
oneTimeUse: parsedClaims.success ? parsedClaims.data.otu : false,
realtime: parsedClaims.success ? parsedClaims.data.realtime : undefined,
};
}
}
}
/**
* Authenticate an API-key request for a legacy (non-apiBuilder) route that
* needs to accept granular additional keys, then enforce that the key's ability
* authorizes `action` on `resource`. Root keys (and grace-window root keys)
* carry the unrestricted `admin` ability, preserving pre-granular behavior.
*
* Only apiKey credentials are accepted (no PAT / org token / public key). Use
* this for routes previously guarded by a bare `authenticateApiRequest` call.
*/
export async function authenticateApiKeyWithScope(
request: Request,
{
action,
resource,
allowJWT = false,
}: { action: string; resource: RbacResource; allowJWT?: boolean },
authorizeBearer: typeof authenticateAuthorizeBearerWithTelemetry = authenticateAuthorizeBearerWithTelemetry
): Promise<
| { ok: true; authentication: ApiAuthenticationResultSuccess }
| { ok: false; status: 401 | 403; error: string }
> {
const apiKey = getApiKeyFromHeader(request.headers.get("Authorization"));
if (!apiKey) {
return { ok: false, status: 401, error: "Invalid or Missing API key" };
}
const result = await authorizeBearer(request, { action, resource }, { allowJWT });
if (!result.ok) {
return result;
}
return {
ok: true,
authentication: {
ok: true,
apiKey,
type: "PRIVATE",
environment: result.environment,
ability: result.ability,
},
};
}
export async function authenticateAuthorizationHeader(
authorization: string,
{
allowPublicKey = false,
allowJWT = false,
}: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
): Promise<ApiAuthenticationResult | undefined> {
const apiKey = getApiKeyFromHeader(authorization);
if (!apiKey) {
return;
}
return authenticateApiKey(apiKey, { allowPublicKey, allowJWT });
}
function isPublicApiKey(key: string) {
return key.startsWith("pk_");
}
function isSecretApiKey(key: string) {
return key.startsWith("tr_");
}
/**
* Reads the branch off the `x-trigger-branch` header and sanitizes it.
* Every server-side reader should go through here so sanitization is applied uniformly.
* The dev `"default"` sentinel is intentionally NOT resolved here:
* that translation is environment type-dependent.
*/
export function branchNameFromRequest(request: Request): string | undefined {
return sanitizeBranchName(request.headers.get("x-trigger-branch")) ?? undefined;
}
function getApiKeyFromRequest(request: Request): {
apiKey: string | undefined;
branchName: string | undefined;
} {
const apiKey = getApiKeyFromHeader(request.headers.get("Authorization"));
const branchName = branchNameFromRequest(request);
return { apiKey, branchName };
}
function getApiKeyFromHeader(authorization?: string | null) {
if (typeof authorization !== "string" || !authorization) {
return;
}
const apiKey = authorization.replace(/^Bearer /, "");
return apiKey;
}
function getApiKeyResult(apiKey: string): {
apiKey: string;
type: "PUBLIC" | "PRIVATE" | "PUBLIC_JWT";
} {
const type = isPublicApiKey(apiKey)
? "PUBLIC"
: isSecretApiKey(apiKey)
? "PRIVATE"
: isPublicJWT(apiKey)
? "PUBLIC_JWT"
: "PRIVATE"; // Fallback to private key
return { apiKey, type };
}
export type AuthenticationResult =
| {
type: "personalAccessToken";
result: PersonalAccessTokenAuthenticationResult;
}
| {
type: "organizationAccessToken";
result: OrganizationAccessTokenAuthenticationResult;
}
| {
type: "apiKey";
result: ApiAuthenticationResult;
};
type AuthenticationMethod = "personalAccessToken" | "organizationAccessToken" | "apiKey";
type AllowedAuthenticationMethods = Record<AuthenticationMethod, boolean> &
({ personalAccessToken: true } | { organizationAccessToken: true } | { apiKey: true });
const defaultAllowedAuthenticationMethods: AllowedAuthenticationMethods = {
personalAccessToken: true,
organizationAccessToken: true,
apiKey: true,
};
type FilteredAuthenticationResult<
T extends AllowedAuthenticationMethods = AllowedAuthenticationMethods,
> =
| (T["personalAccessToken"] extends true
? Extract<AuthenticationResult, { type: "personalAccessToken" }>
: never)
| (T["organizationAccessToken"] extends true
? Extract<AuthenticationResult, { type: "organizationAccessToken" }>
: never)
| (T["apiKey"] extends true ? Extract<AuthenticationResult, { type: "apiKey" }> : never);
/**
* Authenticates an incoming request by checking for various token types.
*
* Supports personal access tokens, organization access tokens, and API keys.
* Returns the appropriate authentication result based on the token type found.
*
* This method currently only allows private keys for the `apiKey` authentication method.
*
* @template T - The allowed authentication methods configuration type
* @param request - The incoming HTTP request containing authentication headers
* @param allowedAuthenticationMethods - Configuration object specifying which authentication methods are allowed.
* At least one method must be set to `true`. Defaults to allowing all methods.
* @returns Authentication result with only the enabled auth method types, or undefined if no valid token found
*
* @example
* ```typescript
* // Only allow personal access tokens
* const result = await authenticateRequest(request, {
* personalAccessToken: true,
* organizationAccessToken: false,
* apiKey: false,
* });
* // result type: { type: "personalAccessToken"; result: PersonalAccessTokenAuthenticationResult } | undefined
* ```
*/
export async function authenticateRequest<
T extends AllowedAuthenticationMethods = AllowedAuthenticationMethods,
>(
request: Request,
allowedAuthenticationMethods?: T
): Promise<FilteredAuthenticationResult<T> | undefined> {
const allowedMethods = allowedAuthenticationMethods ?? defaultAllowedAuthenticationMethods;
const { apiKey, branchName } = getApiKeyFromRequest(request);
if (!apiKey) {
return;
}
if (allowedMethods.personalAccessToken && isPersonalAccessToken(apiKey)) {
const result = await authenticateApiRequestWithPersonalAccessToken(request);
if (!result) {
return;
}
return {
type: "personalAccessToken",
result,
} satisfies Extract<
AuthenticationResult,
{ type: "personalAccessToken" }
> as FilteredAuthenticationResult<T>;
}
if (allowedMethods.organizationAccessToken && isOrganizationAccessToken(apiKey)) {
const result = await authenticateApiRequestWithOrganizationAccessToken(request);
if (!result) {
return;
}
return {
type: "organizationAccessToken",
result,
} satisfies Extract<
AuthenticationResult,
{ type: "organizationAccessToken" }
> as FilteredAuthenticationResult<T>;
}
if (allowedMethods.apiKey) {
const result = await authenticateApiKey(apiKey, {
allowPublicKey: false,
branchName,
});
if (!result) {
return;
}
return {
type: "apiKey",
result,
} satisfies Extract<
AuthenticationResult,
{ type: "apiKey" }
> as FilteredAuthenticationResult<T>;
}
return;
}
export async function authenticatedEnvironmentForAuthentication(
auth: AuthenticationResult,
projectRef: string,
slug: string,
branch?: string
): Promise<AuthenticatedEnvironment> {
if (slug === "staging") {
slug = "stg";
}
// Normalize the requested branch once: sanitize it, then collapse the dev
// `"default"` sentinel to "no branch" so it resolves to the root dev env
// rather than a (non-existent) branch literally named "default".
// TODO this slug check is brittle
const sanitizedBranch = sanitizeBranchName(branch);
const resolvedBranch =
slug === "dev" && isDefaultDevBranch(sanitizedBranch) ? null : sanitizedBranch;
switch (auth.type) {
case "apiKey": {
if (!auth.result.ok) {
throw json({ error: auth.result.error }, { status: 401 });
}
if (auth.result.environment.project.externalRef !== projectRef) {
throw json(
{
error:
"Invalid project ref for this API key. Make sure you are using an API key associated with that project.",
},
{ status: 400 }
);
}
if (
auth.result.environment.slug !== slug &&
auth.result.environment.branchName !== resolvedBranch
) {
throw json(
{
error:
"Invalid environment slug for this API key. Make sure you are using an API key associated with that environment.",
},
{ status: 400 }
);
}
return auth.result.environment;
}
case "personalAccessToken": {
const user = await $replica.user.findUnique({
where: {
id: auth.result.userId,
},
});
if (!user) {
throw json({ error: "Invalid or missing personal access token" }, { status: 401 });
}
const project = await findProjectByRef(projectRef, user.id);
if (!project) {
throw json({ error: "Project not found" }, { status: 404 });
}
if (!resolvedBranch) {
const environment = await $replica.runtimeEnvironment.findFirst({
where: {
projectId: project.id,
slug: slug,
...(slug === "dev"
? {
orgMember: {
userId: user.id,
},
}
: {}),
},
include: authIncludeBase,
});
if (!environment) {
throw json({ error: "Environment not found" }, { status: 404 });
}
return toAuthenticated(environment);
}
const environment = await $replica.runtimeEnvironment.findFirst({
where: {
projectId: project.id,
type: slug === "dev" ? "DEVELOPMENT" : "PREVIEW",
branchName: resolvedBranch,
...(slug === "dev"
? {
orgMember: {
userId: user.id,
},
}
: {}),
archivedAt: null,
},
include: authIncludeWithParent,
});
if (!environment) {
throw json({ error: "Branch not found" }, { status: 404 });
}
if (!environment.parentEnvironment) {
throw json({ error: "Branch not associated with a parent environment" }, { status: 400 });
}
// PREVIEW envs (and DEVELOPMENT branches) reuse the parent's apiKey for downstream auth flows
// (signed JWTs, internal-fetch helpers). Override before mapping so
// the slim shape carries the parent's key.
return toAuthenticated({
...environment,
apiKey: environment.parentEnvironment.apiKey,
});
}
case "organizationAccessToken": {
const organization = await $replica.organization.findUnique({
where: {
id: auth.result.organizationId,
},
});
if (!organization) {
throw json({ error: "Invalid or missing organization access token" }, { status: 401 });
}
const project = await $replica.project.findFirst({
where: {
organizationId: organization.id,
externalRef: projectRef,
},
});
if (!project) {
throw json({ error: "Project not found" }, { status: 404 });
}
if (!resolvedBranch) {
const environment = await $replica.runtimeEnvironment.findFirst({
where: {
projectId: project.id,
slug: slug,
},
include: authIncludeBase,
});
if (!environment) {
throw json({ error: "Environment not found" }, { status: 404 });
}
return toAuthenticated(environment);
}
const environment = await $replica.runtimeEnvironment.findFirst({
where: {
projectId: project.id,
// No Development branches for OAT
type: "PREVIEW",
branchName: resolvedBranch,
archivedAt: null,
},
include: authIncludeWithParent,
});
if (!environment) {
throw json({ error: "Branch not found" }, { status: 404 });
}
if (!environment.parentEnvironment) {
throw json({ error: "Branch not associated with a preview environment" }, { status: 400 });
}
return toAuthenticated({
...environment,
apiKey: environment.parentEnvironment.apiKey,
});
}
default: {
auth satisfies never;
throw json({ error: "Invalid authentication result" }, { status: 401 });
}
}
}
const JWT_SECRET = new TextEncoder().encode(env.SESSION_SECRET);
const JWT_ALGORITHM = "HS256";
const DEFAULT_JWT_EXPIRATION_IN_MS = 1000 * 60 * 60; // 1 hour
export async function generateJWTTokenForEnvironment(
environment: RuntimeEnvironmentForEnvRepo,
payload: Record<string, string>
) {
const jwt = await new SignJWT({
environment_id: environment.id,
org_id: environment.organizationId,
project_id: environment.projectId,
...payload,
})
.setProtectedHeader({ alg: JWT_ALGORITHM })
.setIssuedAt()
.setIssuer("https://id.trigger.dev")
.setAudience("https://api.trigger.dev")
.setExpirationTime(calculateJWTExpiration())
.sign(JWT_SECRET);
return jwt;
}
export async function validateJWTTokenAndRenew<T extends z.ZodTypeAny>(
request: Request,
payloadSchema: T
): Promise<{ payload: z.infer<T>; jwt: string } | undefined> {
try {
const jwt = request.headers.get("x-trigger-jwt");
if (!jwt) {
// Log a safe breadcrumb, not the raw headers (which carry the
// caller's Authorization credential).
logger.debug("Missing JWT token in request", missingJwtLogContext(request));
return;
}
const { payload: rawPayload } = await jwtVerify(jwt, JWT_SECRET, {
issuer: "https://id.trigger.dev",
audience: "https://api.trigger.dev",
});
const payload = payloadSchema.safeParse(rawPayload);
if (!payload.success) {
logger.error("Failed to validate JWT", { payload: rawPayload, issues: payload.error.issues });
return;
}
const renewedJwt = await renewJWTToken(payload.data);
return {
payload: payload.data,
jwt: renewedJwt,
};
} catch (error) {
if (error instanceof errors.JWTExpired) {
// Now we need to try and renew the token using the API key auth
const authenticatedEnv = await authenticateApiRequest(request);
if (!authenticatedEnv) {
logger.error("Failed to renew JWT token, missing or invalid Authorization header", {
error: error.message,
});
return;
}
if (!authenticatedEnv.ok) {
logger.error("Failed to renew JWT token, invalid API key", {
error: error.message,
});
return;
}
const payload = payloadSchema.safeParse(error.payload);
if (!payload.success) {
logger.error("Failed to parse jwt payload after expired", {
payload: error.payload,
issues: payload.error.issues,
});
return;
}
const renewedJwt = await generateJWTTokenForEnvironment(authenticatedEnv.environment, {
...payload.data,
});
// The environment carries secret material; log only non-secret fields.
logger.debug("Renewed JWT token from Authorization header API Key", {
environment: safeEnvironmentLogFields(authenticatedEnv.environment),
payload: payload.data,
});
return {
payload: payload.data,
jwt: renewedJwt,
};
}
logger.error("Failed to validate JWT token", { error });
}
}
async function renewJWTToken(payload: Record<string, string>) {
const jwt = await new SignJWT(payload)
.setProtectedHeader({ alg: JWT_ALGORITHM })
.setIssuedAt()
.setIssuer("https://id.trigger.dev")
.setAudience("https://api.trigger.dev")
.setExpirationTime(calculateJWTExpiration())
.sign(JWT_SECRET);
return jwt;
}
function calculateJWTExpiration() {
if (env.PROD_USAGE_HEARTBEAT_INTERVAL_MS) {
return (
(Date.now() + Math.max(DEFAULT_JWT_EXPIRATION_IN_MS, env.PROD_USAGE_HEARTBEAT_INTERVAL_MS)) /
1000
);
}
return (Date.now() + DEFAULT_JWT_EXPIRATION_IN_MS) / 1000;
}
export async function getOneTimeUseToken(
auth: ApiAuthenticationResultSuccess
): Promise<string | undefined> {
if (auth.type !== "PUBLIC_JWT") {
return;
}
if (!auth.oneTimeUse) {
return;
}
// Hash the API key to make it unique
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(auth.apiKey));
return Buffer.from(hash).toString("hex");
}