@usequark/quark-create-app 1.25.9 → 1.25.11
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/app/api/auth/google/route.js +1 -20
- package/templates/base-project/apps/web/src/app/api/health/route.js +11 -7
- package/templates/base-project/apps/web/src/lib/auth.js +27 -0
- package/templates/base-project/apps/web/src/lib/email-verified.js +28 -0
- package/templates/worker/src/index.js +1 -1
package/package.json
CHANGED
|
@@ -6,6 +6,7 @@ import { validateBody } from "@usequark/quark-core/core";
|
|
|
6
6
|
import { user } from "@usequark/quark-db";
|
|
7
7
|
import { NextResponse } from "next/server";
|
|
8
8
|
import { z } from "zod";
|
|
9
|
+
import { isEmailVerified } from "../../../../lib/email-verified";
|
|
9
10
|
import { issueTokenPair } from "../../../../lib/jwt";
|
|
10
11
|
import { handleError } from "../../error-handler";
|
|
11
12
|
|
|
@@ -15,26 +16,6 @@ const googleAuthSchema = z.object({
|
|
|
15
16
|
|
|
16
17
|
const GOOGLE_TOKENINFO_URL = "https://oauth2.googleapis.com/tokeninfo";
|
|
17
18
|
|
|
18
|
-
/**
|
|
19
|
-
* Whether Google's `email_verified` claim says the address is confirmed.
|
|
20
|
-
*
|
|
21
|
-
* `tokeninfo` returns the claim as the *string* `"true"` or `"false"`, not a
|
|
22
|
-
* boolean, so a plain truthiness check would do the exact opposite of the
|
|
23
|
-
* obvious thing: reject every real sign-in (`"false"` is truthy) while waving
|
|
24
|
-
* through the one case that matters. Only an explicit confirmation counts.
|
|
25
|
-
* Anything else — a missing claim, a number, a null — is unverified, because
|
|
26
|
-
* the failure modes here are asymmetric: a wrong refusal costs one unlucky
|
|
27
|
-
* sign-in, and a wrong acceptance hands an unconfirmed address a session.
|
|
28
|
-
*
|
|
29
|
-
* @param {unknown} value
|
|
30
|
-
* @returns {boolean}
|
|
31
|
-
*/
|
|
32
|
-
function isEmailVerified(value) {
|
|
33
|
-
if (typeof value === "boolean") return value;
|
|
34
|
-
if (typeof value !== "string") return false;
|
|
35
|
-
return value.trim().toLowerCase() === "true";
|
|
36
|
-
}
|
|
37
|
-
|
|
38
19
|
/**
|
|
39
20
|
* POST /api/auth/google
|
|
40
21
|
* Verify Google ID token and issue a custom JWT.
|
|
@@ -18,12 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { createLogger } from "@usequark/quark-core/core";
|
|
21
|
-
import {
|
|
22
|
-
checkQueues,
|
|
23
|
-
checkStorage,
|
|
24
|
-
runHealthChecks,
|
|
25
|
-
} from "@usequark/quark-core/health";
|
|
26
|
-
import { getRegisteredQueues } from "@usequark/quark-core/queue";
|
|
21
|
+
import { checkStorage, runHealthChecks } from "@usequark/quark-core/health";
|
|
27
22
|
import { pingRedis } from "@usequark/quark-core/redis";
|
|
28
23
|
import { pingDatabase } from "@usequark/quark-db";
|
|
29
24
|
import { NextResponse } from "next/server";
|
|
@@ -35,12 +30,21 @@ export async function GET() {
|
|
|
35
30
|
// `pingDatabase` is passed in rather than imported by the core module:
|
|
36
31
|
// `@usequark/quark-db` depends on `@usequark/quark-core`, so core cannot
|
|
37
32
|
// import it back without a cycle.
|
|
33
|
+
//
|
|
34
|
+
// There is deliberately no `queues` probe. `getRegisteredQueues()` reads a
|
|
35
|
+
// Map that only queues created *in this process* are registered in, and
|
|
36
|
+
// the web service creates none: the worker owns every long-lived queue.
|
|
37
|
+
// So the probe reported `null` on every request here — while its
|
|
38
|
+
// `getRegisteredQueues` import pulled all of BullMQ into this route.
|
|
39
|
+
// This endpoint is polled by the platform healthcheck and by
|
|
40
|
+
// `<HealthIndicator />` on every page view, making it the worst possible
|
|
41
|
+
// place to load a queue library for a result that is always empty.
|
|
42
|
+
// Queue depth is the worker's to publish, via `job_queue_depth`.
|
|
38
43
|
const result = await runHealthChecks({
|
|
39
44
|
probes: {
|
|
40
45
|
database: pingDatabase,
|
|
41
46
|
redis: pingRedis,
|
|
42
47
|
storage: checkStorage,
|
|
43
|
-
queues: () => checkQueues(getRegisteredQueues),
|
|
44
48
|
},
|
|
45
49
|
});
|
|
46
50
|
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
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
|
+
import { AppError } from "@usequark/quark-core/errors";
|
|
4
5
|
import { Prisma, prisma, user } from "@usequark/quark-db";
|
|
5
6
|
import NextAuth from "next-auth";
|
|
6
7
|
import CredentialsProvider from "next-auth/providers/credentials";
|
|
7
8
|
import GithubProvider from "next-auth/providers/github";
|
|
8
9
|
import GoogleProvider from "next-auth/providers/google";
|
|
10
|
+
import { isEmailVerified } from "./email-verified";
|
|
9
11
|
|
|
10
12
|
const logger = createLogger({ name: "auth" });
|
|
11
13
|
const STALE_SESSION_PATTERN = /jwtsessionerror|no matching decryption secret/i;
|
|
@@ -125,6 +127,31 @@ if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
|
|
|
125
127
|
GoogleProvider({
|
|
126
128
|
clientId: process.env.GOOGLE_CLIENT_ID,
|
|
127
129
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
|
130
|
+
// The web sign-in creates its account on first sight through the
|
|
131
|
+
// Prisma adapter, so an unconfirmed address here means the same thing it
|
|
132
|
+
// does on the mobile route: the confirmation that is supposed to
|
|
133
|
+
// establish ownership of the address never happened. Google does issue
|
|
134
|
+
// tokens for unconfirmed addresses.
|
|
135
|
+
//
|
|
136
|
+
// The refusal is thrown rather than returned, and that is deliberate.
|
|
137
|
+
// Auth.js has no "null means refuse" contract: `getUserAndAccount` reads
|
|
138
|
+
// `profile.email` straight off whatever the mapper hands back, so a null
|
|
139
|
+
// denial works only because it trips a TypeError that is caught and
|
|
140
|
+
// logged as `OAuthProfileParseError` — indistinguishable, in the log, from
|
|
141
|
+
// a malformed provider response or a user who cancelled. Throwing says
|
|
142
|
+
// why in the one place an operator can read it, and lands in the same
|
|
143
|
+
// catch: no session, no user row, redirect back to the sign-in page.
|
|
144
|
+
profile: (profile) => {
|
|
145
|
+
if (!isEmailVerified(profile.email_verified)) {
|
|
146
|
+
throw new AppError(
|
|
147
|
+
"Google sign-in refused: the account's email address is not verified",
|
|
148
|
+
401,
|
|
149
|
+
"OAUTH_EMAIL_NOT_VERIFIED",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return profile;
|
|
154
|
+
},
|
|
128
155
|
}),
|
|
129
156
|
);
|
|
130
157
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a provider's `email_verified` claim says the address is confirmed.
|
|
3
|
+
*
|
|
4
|
+
* Providers disagree on the wire format. Google's `tokeninfo` endpoint reports
|
|
5
|
+
* the claim as the *string* `"true"` or `"false"`; the OIDC userinfo that
|
|
6
|
+
* reaches a NextAuth profile mapper carries the same string. The id token's own
|
|
7
|
+
* claim is a boolean. A plain truthiness check would therefore do the exact
|
|
8
|
+
* opposite of the obvious thing: accept `"false"` while refusing every real
|
|
9
|
+
* sign-in. Both spellings mean the same thing and both are accepted; nothing
|
|
10
|
+
* else is.
|
|
11
|
+
*
|
|
12
|
+
* Everything else — a missing claim, a null, a number, `"yes"` — reads as
|
|
13
|
+
* unverified, because the failure modes are asymmetric. A wrong refusal costs
|
|
14
|
+
* one unlucky sign-in. A wrong acceptance hands an unconfirmed address a
|
|
15
|
+
* session on a route or a provider that creates the account on first sight.
|
|
16
|
+
*
|
|
17
|
+
* Shared by both Google sign-in paths — the hand-written `POST /api/auth/google`
|
|
18
|
+
* the mobile app posts to, and the NextAuth provider the web button uses — so
|
|
19
|
+
* there is one definition of "confirmed" rather than two that can drift.
|
|
20
|
+
*
|
|
21
|
+
* @param {unknown} value
|
|
22
|
+
* @returns {boolean}
|
|
23
|
+
*/
|
|
24
|
+
export function isEmailVerified(value) {
|
|
25
|
+
if (typeof value === "boolean") return value;
|
|
26
|
+
if (typeof value !== "string") return false;
|
|
27
|
+
return value.trim().toLowerCase() === "true";
|
|
28
|
+
}
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
createWorker,
|
|
13
13
|
updateQueueDepths,
|
|
14
14
|
} from "@usequark/quark-core/queue";
|
|
15
|
-
import { getRedisEndpoint
|
|
15
|
+
import { getRedisEndpoint } from "@usequark/quark-core/redis";
|
|
16
16
|
import { job, prisma } from "@usequark/quark-db";
|
|
17
17
|
import { JOB_NAMES, JOB_QUEUES } from "@usequark/quark-jobs";
|
|
18
18
|
import { jobHandlers } from "./handlers/index.js";
|