e4981d1b11
## Summary
Consolidates the webapp's authentication and authorization into a small
set of route helpers, replacing the ad-hoc `requireUser` /
`requireUserId` / `authenticatedEnvironmentForAuthentication` calls
scattered across routes. Same security model, but the per-request flow
(authenticate → authorize → load) now lives in one place per route
family.
Introduces a plugin seam (`@trigger.dev/plugins`) that lets the cloud
build install a richer RBAC implementation without touching webapp code.
The OSS fallback keeps the pre-RBAC permissive behaviour intact, so
self-hosted deployments work unchanged.
Adds a comprehensive end-to-end auth test suite that didn't exist before
— 193 `it()` blocks (vitest reports ~199 after `it.each` expansion)
covering API key, PAT and JWT auth across the public API surface, plus
dashboard session auth for admin pages.
## Changes
### Plugin contract — `@trigger.dev/plugins`
`RoleBaseAccessController` interface authoritative for both OSS
(fallback) and cloud (enterprise plugin):
- `authenticateBearer(request, { allowJWT? })` — API-key / public-JWT
auth, returns env + ability
- `authenticateSession(request, { userId, organizationId?, projectId?
})` — dashboard auth, caller resolves `userId` from the session cookie
and passes it in (no `helpers.getSessionUserId` callback — decouples the
plugin host from session-cookie code)
- `authenticatePat(request, { organizationId?, projectId? })` — PAT
auth, returns identity + `lastAccessedAt` so the host can throttle the
per-request update
- `authenticateAuthorize*` variants for the auth-and-check-in-one-call
cases
- `isUsingPlugin(): Promise<boolean>` — capability flag for UI /
branching where plugin-present-ness matters; replaces the
sentinel-string coupling that had `personalAccessToken.server` matching
`"RBAC plugin not installed"` literally
### Dashboard auth (started, partial rollout)
Admin and settings pages migrated to a unified `dashboardLoader` /
`dashboardAction` helper that authenticates the session, runs an
authorization check, and exposes the result to the route. Other
dashboard routes still on the old pattern; remaining migration tracked
in TRI-8730.
Migrated routes:
- `admin.*` (14 admin / back-office / feature-flags / LLM-models /
notifications / orgs / concurrency pages)
- `_app.orgs.$organizationSlug.settings.team`
- `_app.orgs.$organizationSlug.settings.roles`
### API / realtime / engine auth (complete for the migrated families)
71 routes migrated to a unified `apiBuilder` that centralizes Bearer /
PAT / Public-JWT authentication and applies the per-route authorization
check before the handler runs. Includes:
- `api.v1.*` and `api.v2.*` and `api.v3.*` — tasks, runs, batches,
queues, prompts, deployments, query, sessions, waitpoints, packets,
workers, idempotency keys
- `realtime.v1.*` — runs, batches, sessions, streams
- `engine.v1.*` — dev / worker-action protocols
29 routes still on the legacy `authenticateApiRequest*` helpers —
tracked as a post-deploy follow-up in TRI-9228.
Multi-resource auth direction is now explicit at the call site via
`anyResource(...)` (OR) and `everyResource(...)` (AND). Bare arrays no
longer typecheck — fixes a class of bug where a JWT scoped to one
resource could implicitly access others under OR semantics.
PAT auth path consolidated: was three DB queries per request (legacy
`authenticateApiRequestWithPersonalAccessToken` findFirst +
`rbac.authenticatePat` join + `lastAccessedAt` update). Now one query in
the steady state — plugin returns `lastAccessedAt`, host smart-skips the
update via JS-side throttle when fresh.
Side effect: action aliases preserved historic JWT scope semantics where
the new model is stricter (e.g. a `write:tasks` JWT now also satisfies
`trigger` / `batchTrigger` / `update` actions on the same resource —
matched at the auth boundary, not in the route handler).
### Backwards-compat fixes
The strict-match model regressed several real-world JWT shapes. Each
preserved via explicit `anyResource(...)` entries in the route's authz
block:
- **Batch retrieve routes** (`api.v1.batches.$batchId`, `api.v2.*`,
`realtime.v1.batches.*`) accept `read:runs` JWTs again (pre-RBAC
literal-match superScope behaviour)
- **Runs list routes** (`api.v1.runs`, `realtime.v1.runs`) accept
type-level `read:tasks` / `read:tags` on unfiltered queries (matched the
legacy `Object.keys` iteration semantic)
- **PAT/OAT auth shape** normalized through `toAuthenticated` so all
auth methods return the same slim `AuthenticatedEnvironment` (was:
API-key returned the slim shape but PAT/OAT returned raw Prisma
`Decimal` / no `orgMember`)
- **Scope `:` preservation** in resource ids — `read:tags:env:staging`
now correctly identifies the tag id as `env:staging`, not `env`
### Slim `AuthenticatedEnvironment`
Extracted to `@trigger.dev/core/v3/auth/environment` — a structural
shape independent of `@trigger.dev/database`. The plugin contract
returns this; webapp consumers import from there; the cloud plugin
(Drizzle) returns the same shape without Prisma's `Decimal` class
leaking into the public surface. Lets internal-packages (run-engine,
etc.) refer to `AuthenticatedEnvironment` without pulling Prisma in.
### Auth test suite (new — `*.e2e.full.test.ts`)
193 e2e tests run against a real spawned webapp + Postgres (no mocks).
Coverage matrix:
- **API key auth** — read / write / trigger / batchTrigger / deploy
actions across runs, batches, deployments, prompts, queues, query,
sessions, input-streams, waitpoints, tasks, idempotency keys; multi-key
resources (a run carries batch / tag / task identifiers — auth must
accept any matching scope)
- **Personal Access Token auth** — comprehensive matrix: scope match,
scope mismatch, missing scope, expired token, malformed token
- **Public JWT auth** — sub-vs-URL environment resolution, expired JWTs,
signature verification, scope checking, otu (one-time-use) token
semantics, branch-environment signing-key fallback
- **Dashboard session auth** — admin-only pages reject non-admins;
per-action gating
- **Cross-cutting edge cases** — revoked API key grace window, JWT
cross-environment isolation, MissingResource branch behaviour
### Hygiene cleanups
- Deleted dead `app/services/authorization.server.ts` (legacy
`checkAuthorization` + types — no live consumers post-migration) and its
orphaned test
- Dropped the never-populated `scopes` field from
`ApiAuthenticationResultSuccess`
- `scheduleEmail` moved out of `email.server.ts` into its own module —
breaks a `commonWorker → marqs/V1` import chain that was poisoning the
auth test graph
- OSS Roles page shows a deployment-aware empty state ("Roles aren't
available in this self-hosted deployment" vs the plan-upsell copy) via
`rbac.isUsingPlugin()`
- Team action handler: explicit per-intent ability gates
(`manage:billing` for purchase-seats, `manage:members` for set-role +
remove-member with self-leave carve-out)
### Cross-repo coordination
All public-package contract changes paired in `triggerdotdev/cloud#763`
(rbac-packages branch) — the enterprise plugin implements the same
`RoleBaseAccessController` interface against Drizzle.
## Test plan
- [x] `pnpm run typecheck --filter webapp` clean
- [x] `pnpm --filter webapp exec vitest run --config
vitest.e2e.full.config.ts` — 193/193 pass (requires Docker for
testcontainers)
- [x] Spot-check an authed API endpoint with a valid + invalid API key
against a local stack
- [x] Spot-check the migrated admin pages render and gate non-admins
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
379 lines
9.0 KiB
TypeScript
379 lines
9.0 KiB
TypeScript
import { SecretReference, User, type PrismaClient } from "@trigger.dev/database";
|
|
import { prisma } from "~/db.server";
|
|
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
|
import { createRandomStringGenerator } from "@better-auth/utils/random";
|
|
import { getSecretStore } from "~/services/secrets/secretStore.server";
|
|
import { createHash } from "@better-auth/utils/hash";
|
|
import { createOTP } from "@better-auth/utils/otp";
|
|
import { base32 } from "@better-auth/utils/base32";
|
|
import { z } from "zod";
|
|
import { scheduleEmail } from "../scheduleEmail.server";
|
|
|
|
const generateRandomString = createRandomStringGenerator("A-Z", "0-9");
|
|
|
|
const SecretSchema = z.object({
|
|
secret: z.string(),
|
|
});
|
|
|
|
export class MultiFactorAuthenticationService {
|
|
#prismaClient: PrismaClient;
|
|
|
|
constructor(prismaClient: PrismaClient = prisma) {
|
|
this.#prismaClient = prismaClient;
|
|
}
|
|
|
|
public async disableTotp(userId: string, params: { totpCode?: string; recoveryCode?: string }) {
|
|
const user = await this.#prismaClient.user.findFirst({
|
|
where: { id: userId },
|
|
include: {
|
|
mfaSecretReference: true,
|
|
},
|
|
});
|
|
|
|
if (!user) {
|
|
return {
|
|
success: false,
|
|
};
|
|
}
|
|
|
|
if (!user.mfaEnabledAt) {
|
|
return {
|
|
success: false,
|
|
};
|
|
}
|
|
|
|
if (!user.mfaSecretReference) {
|
|
return {
|
|
success: false,
|
|
};
|
|
}
|
|
|
|
// validate the TOTP code
|
|
const secretStore = getSecretStore(user.mfaSecretReference.provider);
|
|
const secretResult = await secretStore.getSecret(SecretSchema, user.mfaSecretReference.key);
|
|
|
|
if (!secretResult) {
|
|
return {
|
|
success: false,
|
|
};
|
|
}
|
|
|
|
const isValid = await this.#verifyTotpCodeOrRecoveryCode(
|
|
user,
|
|
user.mfaSecretReference,
|
|
params.totpCode,
|
|
params.recoveryCode
|
|
);
|
|
|
|
if (!isValid) {
|
|
return {
|
|
success: false,
|
|
};
|
|
}
|
|
|
|
// Delete the MFA secret
|
|
await secretStore.deleteSecret(user.mfaSecretReference.key);
|
|
|
|
// Delete the MFA backup codes
|
|
await this.#prismaClient.mfaBackupCode.deleteMany({
|
|
where: {
|
|
userId,
|
|
},
|
|
});
|
|
|
|
await this.#prismaClient.user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
mfaEnabledAt: null,
|
|
mfaSecretReference: {
|
|
delete: true,
|
|
},
|
|
},
|
|
});
|
|
|
|
await scheduleEmail({
|
|
email: "mfa-disabled",
|
|
to: user.email,
|
|
userEmail: user.email,
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
};
|
|
}
|
|
|
|
public async enableTotp(userId: string) {
|
|
const user = await this.#prismaClient.user.findFirst({
|
|
where: { id: userId },
|
|
});
|
|
|
|
if (!user) {
|
|
throw new ServiceValidationError("User not found");
|
|
}
|
|
|
|
const secretStore = getSecretStore("DATABASE");
|
|
|
|
// Generate a new secret
|
|
const secret = generateRandomString(24);
|
|
const secretKey = `mfa:${userId}:${generateRandomString(8)}`;
|
|
|
|
// Store the secret in the SecretStore
|
|
await secretStore.setSecret(secretKey, {
|
|
secret,
|
|
});
|
|
|
|
// Update the user's secret reference to the secret store
|
|
await this.#prismaClient.user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
mfaSecretReference: {
|
|
create: {
|
|
provider: "DATABASE",
|
|
key: secretKey,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Return the secret and the recovery codes
|
|
const otpAuthUrl = createOTP(secret).url("trigger.dev", user.email);
|
|
|
|
const displaySecret = base32.encode(secret, {
|
|
padding: false,
|
|
});
|
|
|
|
return {
|
|
secret: displaySecret,
|
|
otpAuthUrl,
|
|
};
|
|
}
|
|
|
|
public async validateTotpSetup(userId: string, totpCode: string) {
|
|
const user = await this.#prismaClient.user.findFirst({
|
|
where: { id: userId },
|
|
include: {
|
|
mfaSecretReference: true,
|
|
},
|
|
});
|
|
|
|
if (!user) {
|
|
throw new ServiceValidationError("User not found");
|
|
}
|
|
|
|
if (!user.mfaSecretReference) {
|
|
throw new ServiceValidationError("User has not enabled MFA");
|
|
}
|
|
|
|
const secretStore = getSecretStore(user.mfaSecretReference.provider);
|
|
const secretResult = await secretStore.getSecret(SecretSchema, user.mfaSecretReference.key);
|
|
|
|
if (!secretResult) {
|
|
throw new ServiceValidationError("User has not enabled MFA");
|
|
}
|
|
|
|
const secret = secretResult.secret;
|
|
|
|
const otp = createOTP(secret, {
|
|
digits: 6,
|
|
period: 30,
|
|
});
|
|
|
|
const isValid = await otp.verify(totpCode);
|
|
|
|
if (!isValid) {
|
|
// Return the secret and the recovery codes
|
|
const otpAuthUrl = createOTP(secret).url("trigger.dev", user.email);
|
|
|
|
const displaySecret = base32.encode(secret, {
|
|
padding: false,
|
|
});
|
|
|
|
return {
|
|
success: false,
|
|
otpAuthUrl,
|
|
secret: displaySecret,
|
|
};
|
|
}
|
|
|
|
// Now that we've validated the TOTP code, we can enable MFA for the user
|
|
await this.#prismaClient.user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
mfaEnabledAt: new Date(),
|
|
},
|
|
});
|
|
|
|
// Generate a new set of recovery codes
|
|
const recoveryCodes = Array.from({ length: 9 }, () => generateRandomString(16, "a-z", "0-9"));
|
|
|
|
// Delete any existing recovery codes
|
|
await this.#prismaClient.mfaBackupCode.deleteMany({
|
|
where: {
|
|
userId,
|
|
},
|
|
});
|
|
|
|
// Hash and store the recovery codes
|
|
for (const code of recoveryCodes) {
|
|
const hashedCode = await createHash("SHA-512", "hex").digest(code);
|
|
await this.#prismaClient.mfaBackupCode.create({
|
|
data: {
|
|
userId,
|
|
code: hashedCode,
|
|
},
|
|
});
|
|
}
|
|
|
|
await scheduleEmail({
|
|
email: "mfa-enabled",
|
|
to: user.email,
|
|
userEmail: user.email,
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
recoveryCodes,
|
|
};
|
|
}
|
|
|
|
async #verifyTotpCodeOrRecoveryCode(
|
|
user: User,
|
|
secretReference: SecretReference,
|
|
totpCode?: string,
|
|
recoveryCode?: string
|
|
) {
|
|
if (!totpCode && !recoveryCode) {
|
|
return false;
|
|
}
|
|
|
|
if (typeof totpCode === "string" && totpCode.length === 6) {
|
|
return this.#verifyTotpCode(user, secretReference, totpCode);
|
|
}
|
|
|
|
if (typeof recoveryCode === "string") {
|
|
return this.#verifyRecoveryCode(user, recoveryCode);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
async #verifyTotpCode(user: User, secretReference: SecretReference, totpCode: string) {
|
|
const secretStore = getSecretStore(secretReference.provider);
|
|
const secretResult = await secretStore.getSecret(SecretSchema, secretReference.key);
|
|
|
|
if (!secretResult) {
|
|
return false;
|
|
}
|
|
|
|
const secret = secretResult.secret;
|
|
|
|
const isValid = await createOTP(secret, {
|
|
digits: 6,
|
|
period: 30,
|
|
}).verify(totpCode);
|
|
|
|
return isValid;
|
|
}
|
|
|
|
async #verifyRecoveryCode(user: User, recoveryCode: string) {
|
|
const hashedCode = await createHash("SHA-512", "hex").digest(recoveryCode);
|
|
|
|
const backupCode = await this.#prismaClient.mfaBackupCode.findFirst({
|
|
where: { userId: user.id, code: hashedCode, usedAt: null },
|
|
});
|
|
|
|
return !!backupCode;
|
|
}
|
|
|
|
// Public methods for login flow with security measures
|
|
public async verifyTotpForLogin(userId: string, totpCode: string) {
|
|
const user = await this.#prismaClient.user.findFirst({
|
|
where: { id: userId },
|
|
include: {
|
|
mfaSecretReference: true,
|
|
},
|
|
});
|
|
|
|
if (!user || !user.mfaEnabledAt || !user.mfaSecretReference) {
|
|
return {
|
|
success: false,
|
|
error: "Invalid authentication code",
|
|
};
|
|
}
|
|
|
|
// Check for replay attack - if this code was already used
|
|
const hashedCode = await createHash("SHA-512", "hex").digest(totpCode);
|
|
if (user.mfaLastUsedCode === hashedCode) {
|
|
return {
|
|
success: false,
|
|
error: "Invalid authentication code",
|
|
};
|
|
}
|
|
|
|
// Verify the TOTP code
|
|
const isValid = await this.#verifyTotpCode(user, user.mfaSecretReference, totpCode);
|
|
|
|
if (!isValid) {
|
|
return {
|
|
success: false,
|
|
error: "Invalid authentication code",
|
|
};
|
|
}
|
|
|
|
// Mark this code as used to prevent replay
|
|
await this.#prismaClient.user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
mfaLastUsedCode: hashedCode,
|
|
},
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
};
|
|
}
|
|
|
|
public async verifyRecoveryCodeForLogin(userId: string, recoveryCode: string) {
|
|
const user = await this.#prismaClient.user.findFirst({
|
|
where: { id: userId },
|
|
});
|
|
|
|
if (!user || !user.mfaEnabledAt) {
|
|
return {
|
|
success: false,
|
|
error: "Invalid authentication code",
|
|
};
|
|
}
|
|
|
|
const hashedCode = await createHash("SHA-512", "hex").digest(recoveryCode);
|
|
|
|
// Find an unused recovery code
|
|
const backupCode = await this.#prismaClient.mfaBackupCode.findFirst({
|
|
where: {
|
|
userId: user.id,
|
|
code: hashedCode,
|
|
usedAt: null,
|
|
},
|
|
});
|
|
|
|
if (!backupCode) {
|
|
return {
|
|
success: false,
|
|
error: "Invalid authentication code",
|
|
};
|
|
}
|
|
|
|
// Mark this recovery code as used
|
|
await this.#prismaClient.mfaBackupCode.update({
|
|
where: { id: backupCode.id },
|
|
data: {
|
|
usedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
};
|
|
}
|
|
}
|