@usequark/quark-create-app 1.25.13 → 1.25.14
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/package.json +1 -1
- package/templates/base-project/apps/web/src/lib/auth.js +81 -2
- package/templates/base-project/apps/web/src/lib/proxy-auth.js +0 -3
- package/templates/base-project/packages/db/prisma/migrations/20260202061128_initial/migration.sql +1 -12
- package/templates/base-project/packages/db/prisma/schema.prisma +10 -4
- package/templates/base-project/packages/db/src/queries.js +1 -0
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@ import { PrismaAdapter } from "@auth/prisma-adapter";
|
|
|
2
2
|
import { createAuthConfig, verifyPassword } from "@usequark/quark-core/auth";
|
|
3
3
|
import { createLogger } from "@usequark/quark-core/core";
|
|
4
4
|
import { AppError } from "@usequark/quark-core/errors";
|
|
5
|
-
import { Prisma, prisma, user } from "@usequark/quark-db";
|
|
5
|
+
import { Prisma, prisma, user as userQueries } from "@usequark/quark-db";
|
|
6
6
|
import NextAuth from "next-auth";
|
|
7
7
|
import CredentialsProvider from "next-auth/providers/credentials";
|
|
8
8
|
import GithubProvider from "next-auth/providers/github";
|
|
@@ -66,7 +66,7 @@ const providers = [
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
try {
|
|
69
|
-
const existingUser = await
|
|
69
|
+
const existingUser = await userQueries.findByEmail(credentials.email);
|
|
70
70
|
|
|
71
71
|
if (!existingUser?.password) {
|
|
72
72
|
return null;
|
|
@@ -156,6 +156,64 @@ if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
|
|
|
156
156
|
);
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Re-validate the session against the database on every request.
|
|
161
|
+
*
|
|
162
|
+
* Web sessions are signed JWTs: a credentials provider forces the JWT
|
|
163
|
+
* strategy (Auth.js rejects `strategy: "database"` when every provider is
|
|
164
|
+
* credentials, and scaffolds always ship one), and the cookie would otherwise
|
|
165
|
+
* stay valid for its full 30 days no matter what happened to the account
|
|
166
|
+
* behind it. That was the review's "logins can't be revoked" finding — banning
|
|
167
|
+
* a user, demoting an admin, or changing a password did nothing until the
|
|
168
|
+
* token expired on its own.
|
|
169
|
+
*
|
|
170
|
+
* The revocation check is a single indexed primary-key read, which is the
|
|
171
|
+
* same cost Auth.js itself pays for database sessions — but it works for
|
|
172
|
+
* credentials apps too:
|
|
173
|
+
*
|
|
174
|
+
* - user gone (deleted / banned by deletion) → reject, cookie cleared
|
|
175
|
+
* - `passwordChangedAt` after the token's iat → reject, cookie cleared
|
|
176
|
+
* - role or profile changed → refreshed into the token,
|
|
177
|
+
* so `requireRole` reads the current role, not the sign-in-time one
|
|
178
|
+
*
|
|
179
|
+
* Returning null from the `jwt` callback is Auth.js's documented "invalidate
|
|
180
|
+
* this session" signal: it clears the cookie and the request continues
|
|
181
|
+
* unauthenticated.
|
|
182
|
+
*/
|
|
183
|
+
async function validateSessionToken(token) {
|
|
184
|
+
if (typeof token.id !== "string" || token.id.length === 0) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
const account = await userQueries.findById(token.id);
|
|
190
|
+
|
|
191
|
+
if (!account) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (
|
|
196
|
+
account.passwordChangedAt &&
|
|
197
|
+
typeof token.iat === "number" &&
|
|
198
|
+
account.passwordChangedAt.getTime() / 1000 > token.iat
|
|
199
|
+
) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
token.email = account.email;
|
|
204
|
+
token.name = account.name;
|
|
205
|
+
token.role = account.role;
|
|
206
|
+
return token;
|
|
207
|
+
} catch (err) {
|
|
208
|
+
// Fail closed: an unreachable database invalidates the session rather
|
|
209
|
+
// than letting an unverified cookie through. The next request retries.
|
|
210
|
+
logger.error("Session validation failed", {
|
|
211
|
+
message: err?.message ?? String(err),
|
|
212
|
+
});
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
159
217
|
export function getAuthOptions() {
|
|
160
218
|
return createAuthConfig({
|
|
161
219
|
adapter: PrismaAdapter(prisma),
|
|
@@ -172,7 +230,28 @@ export function getAuthOptions() {
|
|
|
172
230
|
},
|
|
173
231
|
},
|
|
174
232
|
session: {
|
|
233
|
+
// JWT is not a preference here, it is a constraint: Auth.js throws
|
|
234
|
+
// `UnsupportedStrategy` when strategy is "database" and every
|
|
235
|
+
// configured provider is credentials, and the scaffold always ships
|
|
236
|
+
// the credentials provider. Revocation is therefore enforced by
|
|
237
|
+
// `validateSessionToken` above, not by server-side session rows.
|
|
175
238
|
strategy: "jwt",
|
|
239
|
+
maxAge: 30 * 24 * 60 * 60, // 30 days
|
|
240
|
+
},
|
|
241
|
+
callbacks: {
|
|
242
|
+
async jwt({ token, user: signedInUser }) {
|
|
243
|
+
// Sign-in: stamp the identity once; the DB check below is for
|
|
244
|
+
// every subsequent request.
|
|
245
|
+
if (signedInUser) {
|
|
246
|
+
token.id = signedInUser.id;
|
|
247
|
+
token.email = signedInUser.email;
|
|
248
|
+
token.name = signedInUser.name;
|
|
249
|
+
token.role = signedInUser.role || "viewer";
|
|
250
|
+
return token;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return validateSessionToken(token);
|
|
254
|
+
},
|
|
176
255
|
},
|
|
177
256
|
});
|
|
178
257
|
}
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import { getAuthSecret } from "@usequark/quark-core/auth";
|
|
2
|
-
import { getToken } from "next-auth/jwt";
|
|
3
|
-
|
|
4
1
|
const STRICT_AUTH_RATE_LIMIT_ROUTES = new Set([
|
|
5
2
|
// `callback/credentials` is where the next-auth client POSTs a credentials
|
|
6
3
|
// sign-in (next-auth/react posts to `callback/<provider>` for credentials
|
package/templates/base-project/packages/db/prisma/migrations/20260202061128_initial/migration.sql
CHANGED
|
@@ -16,6 +16,7 @@ CREATE TABLE "User" (
|
|
|
16
16
|
"password" TEXT,
|
|
17
17
|
"image" TEXT,
|
|
18
18
|
"role" "UserRole" NOT NULL DEFAULT 'viewer',
|
|
19
|
+
"passwordChangedAt" TIMESTAMP(3),
|
|
19
20
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
20
21
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
21
22
|
|
|
@@ -117,9 +118,6 @@ CREATE TABLE "AuditLog" (
|
|
|
117
118
|
-- CreateIndex
|
|
118
119
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
|
119
120
|
|
|
120
|
-
-- CreateIndex
|
|
121
|
-
CREATE INDEX "User_email_idx" ON "User"("email");
|
|
122
|
-
|
|
123
121
|
-- CreateIndex
|
|
124
122
|
CREATE INDEX "User_createdAt_idx" ON "User"("createdAt");
|
|
125
123
|
|
|
@@ -141,9 +139,6 @@ CREATE INDEX "Session_expires_idx" ON "Session"("expires");
|
|
|
141
139
|
-- CreateIndex
|
|
142
140
|
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
|
|
143
141
|
|
|
144
|
-
-- CreateIndex
|
|
145
|
-
CREATE INDEX "VerificationToken_token_idx" ON "VerificationToken"("token");
|
|
146
|
-
|
|
147
142
|
-- CreateIndex
|
|
148
143
|
CREATE INDEX "VerificationToken_expires_idx" ON "VerificationToken"("expires");
|
|
149
144
|
|
|
@@ -153,12 +148,6 @@ CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationTok
|
|
|
153
148
|
-- CreateIndex
|
|
154
149
|
CREATE INDEX "Job_queue_idx" ON "Job"("queue");
|
|
155
150
|
|
|
156
|
-
-- CreateIndex
|
|
157
|
-
CREATE INDEX "Job_status_idx" ON "Job"("status");
|
|
158
|
-
|
|
159
|
-
-- CreateIndex
|
|
160
|
-
CREATE INDEX "Job_runAt_idx" ON "Job"("runAt");
|
|
161
|
-
|
|
162
151
|
-- CreateIndex
|
|
163
152
|
CREATE INDEX "Job_status_runAt_idx" ON "Job"("status", "runAt");
|
|
164
153
|
|
|
@@ -29,6 +29,10 @@ model User {
|
|
|
29
29
|
password String?
|
|
30
30
|
image String?
|
|
31
31
|
role UserRole @default(viewer)
|
|
32
|
+
// Bumped whenever the account's password changes. Session tokens (web JWTs
|
|
33
|
+
// and mobile refresh tokens) issued before this instant are rejected, so a
|
|
34
|
+
// stolen or pre-change session dies with the password it was born under.
|
|
35
|
+
passwordChangedAt DateTime?
|
|
32
36
|
createdAt DateTime @default(now())
|
|
33
37
|
updatedAt DateTime @updatedAt
|
|
34
38
|
|
|
@@ -37,7 +41,8 @@ model User {
|
|
|
37
41
|
auditLogs AuditLog[]
|
|
38
42
|
files File[]
|
|
39
43
|
|
|
40
|
-
|
|
44
|
+
// `email` is already @unique (User_email_key); a second plain index on the
|
|
45
|
+
// same column only adds write cost.
|
|
41
46
|
@@index([createdAt])
|
|
42
47
|
}
|
|
43
48
|
|
|
@@ -85,8 +90,9 @@ model VerificationToken {
|
|
|
85
90
|
createdAt DateTime @default(now())
|
|
86
91
|
updatedAt DateTime @updatedAt
|
|
87
92
|
|
|
93
|
+
// `token` is already @unique (VerificationToken_token_key); a second plain
|
|
94
|
+
// index on the same column only adds write cost.
|
|
88
95
|
@@unique([identifier, token])
|
|
89
|
-
@@index([token])
|
|
90
96
|
@@index([expires])
|
|
91
97
|
}
|
|
92
98
|
|
|
@@ -106,9 +112,9 @@ model Job {
|
|
|
106
112
|
createdAt DateTime @default(now())
|
|
107
113
|
updatedAt DateTime @updatedAt
|
|
108
114
|
|
|
115
|
+
// (status, runAt) serves status-only queries through its leftmost prefix;
|
|
116
|
+
// single-column [status] and [runAt] indexes were removed as redundant.
|
|
109
117
|
@@index([queue])
|
|
110
|
-
@@index([status])
|
|
111
|
-
@@index([runAt])
|
|
112
118
|
@@index([status, runAt])
|
|
113
119
|
@@index([createdAt])
|
|
114
120
|
}
|