1import { CanActivate, ExecutionContext, Inject, Injectable } from "@nestjs/common";
2import { v4 as uuid } from "uuid";
3import type { Request } from "express";
4import { sha256hex } from "../../lib/crypto.js";
5import { verifyToken, TokenError } from "../../lib/jwt.js";
6import { loadEnv } from "../../config/env.js";
7import { ApiError } from "../errors.js";
8import { PrismaService } from "../prisma.service.js";
9import { RedisService } from "../redis.service.js";
10import { isJtiRevoked } from "../../modules/oauth/token-keys.js";
11import type { AuthContext } from "../types.js";
12
13
28@Injectable()
29export class AuthGuard implements CanActivate {
30 constructor(
31 @Inject(PrismaService) private readonly prisma: PrismaService,
32 @Inject(RedisService) private readonly redis: RedisService
33 ) {}
34
35 async canActivate(context: ExecutionContext): Promise<boolean> {
36 const req = context.switchToHttp().getRequest<Request>();
37 if (!req.id) req.id = uuid();
38 if (this.isPublic(req)) return true;
39
40 const header = req.headers.authorization;
41 if (!header || !header.startsWith("Bearer ")) {
42 throw ApiError.unauthorized("Missing bearer token.", [{ reason: "token_missing" }]);
43 }
44 const token = header.slice(7).trim();
45 if (!token) throw ApiError.unauthorized("Missing bearer token.", [{ reason: "token_missing" }]);
46
47 req.auth = token.startsWith("fk_")
48 ? await this.resolveApiKey(token, req)
49 : await this.resolveJwt(token, req);
50 return true;
51 }
52
53 private isPublic(req: Request): boolean {
54 const path = req.path;
55
56
57
58 if (path.startsWith("/health")) return true;
59 if (path.startsWith("/api/v1/health")) return true;
60
61
62
63 if (path.startsWith("/internal/")) return true;
64 if (path.startsWith("/api/v1/auth/login")) return true;
65 if (path.startsWith("/api/v1/auth/register")) return true;
66 if (path.startsWith("/api/v1/auth/refresh")) return true;
67
68
69
70 if (path.startsWith("/api/v1/auth/2fa/")) return true;
71 if (path.startsWith("/api/v1/auth/reset-password")) return true;
72
73
74
75 if (path.startsWith("/api/v1/auth/sso/options")) return true;
76
77
78
79
80 if (path.startsWith("/saml/")) return true;
81 if (path.startsWith("/sso/")) return true;
82
83
84
85
86 if (path.startsWith("/scim/")) return true;
87
88 if (/^\/api\/v1\/share\/[^/]+$/.test(path)) return true;
89 if (/^\/api\/v1\/f\/[^/]+\/?$/.test(path)) return true;
90
91
92
93
94
95 if (path.startsWith("/api/v1/public/forms/")) return true;
96
97
98
99
100 if (path === "/api/v1/debug/events") return true;
101 if (path.startsWith("/api/v1/oauth/")) return true;
102
103
104
105
106
107 if (path.startsWith("/api/v1/webhooks/incoming/")) return true;
108
109
110
111 if (path.startsWith("/api/v1/docs")) return true;
112 if (path === "/docs" || path.startsWith("/docs/")) return true;
113 return false;
114 }
115
116 private async resolveJwt(token: string, req: Request): Promise<AuthContext> {
117 let claims;
118 try {
119 claims = verifyToken(token, loadEnv().JWT_SECRET);
120 } catch (err) {
121 if (err instanceof TokenError) {
122 throw new ApiError(err.code, 401, err.message, [
123 { reason: err.code === "TOKEN_EXPIRED" ? "token_expired" : "token_malformed" }
124 ]);
125 }
126 throw err;
127 }
128 if (!claims.sub) throw ApiError.unauthorized("Token has no subject.", [{ reason: "token_malformed" }]);
129
130
131
132
133 if (claims.kind === "oauth_consent") {
134 throw ApiError.unauthorized("Token is not an access token.", [{ reason: "token_malformed" }]);
135 }
136
137
138
139
140
141
142
143
144
145 if (await this.isUserSuspended(claims.sub)) {
146 this.auditSuspendedBlock(claims.sub, req);
147 throw ApiError.forbidden("Account suspended.", [{ reason: "account_suspended" }]);
148 }
149
150
151
152
153
154
155
156
157 if (claims.type === "oauth") {
158 if (await isJtiRevoked(this.redis.getRedis(), typeof claims.jti === "string" ? claims.jti : undefined)) {
159 throw ApiError.unauthorized("Access token revoked.", [{ reason: "token_revoked" }]);
160 }
161 return {
162 kind: "user",
163 userId: claims.sub,
164 oauthScopes: Array.isArray(claims.scope) ? (claims.scope as string[]) : (typeof claims.scope === "string" ? (claims.scope as string).split(" ").filter(Boolean) : []),
165 oauthClientId: typeof claims.client_id === "string" ? claims.client_id : undefined,
166 workspaceId: (req.headers["x-factory-workspace"] as string | undefined)
167 };
168 }
169
170 const headerWs = req.headers["x-factory-workspace"] as string | undefined;
171 return {
172 kind: "user",
173 userId: claims.sub,
174 workspaces: Array.isArray(claims.workspaces) ? (claims.workspaces as string[]) : undefined,
175 sessionId: typeof claims.sid === "string" ? claims.sid : undefined,
176 workspaceId: headerWs ?? (claims.workspaces?.length === 1 ? claims.workspaces[0] : undefined)
177 };
178 }
179
180 private async resolveApiKey(rawKey: string, req: Request): Promise<AuthContext> {
181 const keyHash = sha256hex(rawKey);
182 const row = await this.prisma.apiKey.findUnique({ where: { keyHash } });
183 if (!row) throw ApiError.unauthorized("Unknown API key.", [{ reason: "api_key_invalid" }]);
184 if (row.revokedAt) {
185 await this.recordAuthFailure(row.id, "api_key_revoked");
186 throw ApiError.unauthorized("API key revoked.", [{ reason: "api_key_revoked" }]);
187 }
188 if (row.expiresAt && row.expiresAt.getTime() < Date.now()) {
189 await this.recordAuthFailure(row.id, "api_key_expired");
190 throw ApiError.unauthorized("API key expired.", [{ reason: "api_key_expired" }]);
191 }
192 const headerWs = req.headers["x-factory-workspace"] as string | undefined;
193 if (headerWs && headerWs !== row.workspaceId) {
194 throw ApiError.forbidden("API key does not belong to the requested workspace.", [
195 { reason: "workspace_mismatch" }
196 ]);
197 }
198 const scopeList: string[] = [...row.scopes];
199 if (scopeList.includes("ADMIN")) scopeList.push("WRITE", "READ");
200 if (scopeList.includes("WRITE")) scopeList.push("READ");
201
202
203
204
205 const creator = await this.prisma.user
206 .findUnique({ where: { id: row.createdById }, select: { id: true } })
207 .catch(() => null);
208
209
210
211
212 if (!row.lastUsedAt || Date.now() - row.lastUsedAt.getTime() > 60_000) {
213 void this.prisma
214 .apiKey.update({ where: { id: row.id }, data: { lastUsedAt: new Date() } })
215 .catch(() => undefined);
216 }
217 return {
218 kind: "api",
219 workspaceId: row.workspaceId,
220 scopes: scopeList,
221 apiKeyId: row.id,
222 userId: creator?.id,
223 rateLimit: row.rateLimit
224 };
225 }
226
227
235
241 private async isUserSuspended(sub: string): Promise<boolean> {
242 try {
243 const row = await this.prisma.user.findUnique({
244 where: { id: sub },
245 select: { status: true }
246 });
247 return row?.status === "SUSPENDED";
248 } catch {
249 return false;
250 }
251 }
252
253
259 private auditSuspendedBlock(userId: string, req: Request): void {
260 void this.prisma.auditLog
261 .create({
262 data: {
263 actorType: "USER",
264 actorId: userId,
265 resourceType: "user",
266 resourceId: userId,
267 action: "auth.suspended_blocked",
268 ipAddress: req.ip ?? null,
269 userAgent: (req.headers["user-agent"] as string | undefined) ?? null,
270 metadata: { path: req.path } as object
271 }
272 })
273 .catch(() => {
274
275 });
276 }
277
278 private async recordAuthFailure(apiKeyId: string, reason: string): Promise<void> {
279 let passed = false;
280 try {
281 passed = (await this.redis.getRedis().set(`apikey:authfail:${apiKeyId}`, "1", "EX", 3600, "NX")) !== null;
282 } catch {
283 passed = true;
284 }
285 if (!passed) return;
286 try {
287 const row = await this.prisma.apiKey.findUnique({
288 where: { id: apiKeyId },
289 select: { workspaceId: true }
290 });
291 if (!row) return;
292 await this.prisma.auditLog.create({
293 data: {
294 actorType: "API_KEY",
295 actorId: `api:${apiKeyId}`,
296 workspaceId: row.workspaceId,
297 resourceType: "api_key",
298 resourceId: apiKeyId,
299 action: "api_key.auth_failure",
300 metadata: { reason } as object
301 }
302 });
303 } catch {
304
305 }
306 }
307}
308