@usequark/quark-create-app 1.25.5 → 1.25.7
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/.env.railway.example +10 -0
- package/templates/base-project/apps/web/.env.example +22 -5
- package/templates/base-project/apps/web/src/app/api/csrf/route.js +21 -10
- package/templates/base-project/apps/web/src/app/auth/register/RegisterPageClient.js +42 -2
- package/templates/config/package.json +1 -0
- package/templates/config/src/index.js +7 -0
- package/templates/config/src/oauth.js +94 -0
- package/templates/config/src/validate-env.js +34 -0
- package/templates/mobile/.env.example +7 -0
package/package.json
CHANGED
|
@@ -23,6 +23,16 @@ AUTH_ALLOW_SIGNUP=false
|
|
|
23
23
|
# Must be at least 32 characters or startup validation fails.
|
|
24
24
|
NEXTAUTH_SECRET=CHANGE_ME_TO_A_32_CHARACTERS_OR_LONGER_SECRET
|
|
25
25
|
|
|
26
|
+
# --- Optional OAuth providers ---
|
|
27
|
+
# Unset by default; social sign-in stays off. These are the credentials the
|
|
28
|
+
# /api/auth/google and /api/auth/apple routes bind tokens to. Server-only -
|
|
29
|
+
# do not prefix them with NEXT_PUBLIC_.
|
|
30
|
+
# GOOGLE_CLIENT_ID=
|
|
31
|
+
# GOOGLE_CLIENT_SECRET=
|
|
32
|
+
# APPLE_CLIENT_ID=
|
|
33
|
+
# GITHUB_ID=
|
|
34
|
+
# GITHUB_SECRET=
|
|
35
|
+
|
|
26
36
|
# APP_URL is the canonical public URL. NEXTAUTH_URL is derived automatically.
|
|
27
37
|
# Uncomment only if you need to override it explicitly.
|
|
28
38
|
# NEXTAUTH_URL=https://__QUARK_PROJECT_NAME__.railway.app
|
|
@@ -26,11 +26,28 @@ NEXTAUTH_URL=http://localhost:3005
|
|
|
26
26
|
NEXTAUTH_SECRET=replace_with_a_long_random_secret
|
|
27
27
|
|
|
28
28
|
# --- OAuth Providers (optional) ---
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
29
|
+
# Leave every variable in this section unset and social sign-in is off.
|
|
30
|
+
# Nothing in Quark requires these.
|
|
31
|
+
#
|
|
32
|
+
# Each provider is enabled by its client id alone. These are the credentials
|
|
33
|
+
# the /api/auth/google and /api/auth/apple routes will bind tokens to.
|
|
34
|
+
#
|
|
35
|
+
# GitHub - Get credentials at: https://github.com/settings/developers
|
|
36
|
+
# GITHUB_ID=your_github_client_id
|
|
37
|
+
# GITHUB_SECRET=your_github_client_secret
|
|
38
|
+
|
|
39
|
+
# Google - Get credentials at: https://console.cloud.google.com/apis/credentials
|
|
40
|
+
# Both halves together add the Google button to the web sign-in page.
|
|
41
|
+
# The client id is also the token audience the mobile app's POST /api/auth/google
|
|
42
|
+
# will be checked against.
|
|
43
|
+
# GOOGLE_CLIENT_ID=your_google_client_id
|
|
44
|
+
# GOOGLE_CLIENT_SECRET=your_google_client_secret
|
|
45
|
+
|
|
46
|
+
# Apple - Create a Services ID at: https://developer.apple.com/account/resources/identifiers/list/serviceId
|
|
47
|
+
# The Services ID is the token audience the mobile app's POST /api/auth/apple
|
|
48
|
+
# will be checked against. No secret is involved - that route verifies the
|
|
49
|
+
# signed identity token itself.
|
|
50
|
+
# APPLE_CLIENT_ID=com.example.yourapp
|
|
34
51
|
|
|
35
52
|
# --- SEO / Indexing ---
|
|
36
53
|
# Set to "true" ONLY in your production deployment.
|
|
@@ -6,21 +6,32 @@
|
|
|
6
6
|
|
|
7
7
|
import { generateCsrfToken } from "@usequark/quark-core";
|
|
8
8
|
import { NextResponse } from "next/server";
|
|
9
|
-
import { auth } from "@/lib/auth";
|
|
10
9
|
import { getCsrfCookieOptions } from "@/lib/csrf-cookie";
|
|
11
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Headers applied to every response.
|
|
13
|
+
*
|
|
14
|
+
* `no-store` is load-bearing, not hygiene. The body of this response *is* a
|
|
15
|
+
* write credential: paired with the cookie it sets, it authorises the holder's
|
|
16
|
+
* next mutating request. A shared cache that stored it could hand the token to
|
|
17
|
+
* a second caller, who would then be able to write as the first — so this
|
|
18
|
+
* response must never be cached, by the browser or anything between it and the
|
|
19
|
+
* client. `Vary: Cookie` keeps that true for a cache that keys on request
|
|
20
|
+
* headers rather than obeying `no-store` outright.
|
|
21
|
+
*/
|
|
22
|
+
const NO_STORE_HEADERS = {
|
|
23
|
+
"Cache-Control": "no-store, no-cache, must-revalidate",
|
|
24
|
+
Vary: "Cookie",
|
|
25
|
+
};
|
|
26
|
+
|
|
12
27
|
export async function GET(request) {
|
|
13
28
|
try {
|
|
14
|
-
const session = await auth();
|
|
15
|
-
|
|
16
|
-
if (!session) {
|
|
17
|
-
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
// Generate CSRF token
|
|
21
29
|
const csrfToken = generateCsrfToken();
|
|
22
30
|
|
|
23
|
-
const response = NextResponse.json(
|
|
31
|
+
const response = NextResponse.json(
|
|
32
|
+
{ csrfToken },
|
|
33
|
+
{ headers: NO_STORE_HEADERS },
|
|
34
|
+
);
|
|
24
35
|
|
|
25
36
|
// Store the token in a secure HTTP-only cookie so the server can
|
|
26
37
|
// validate it on subsequent state-changing requests.
|
|
@@ -34,7 +45,7 @@ export async function GET(request) {
|
|
|
34
45
|
} catch (_error) {
|
|
35
46
|
return NextResponse.json(
|
|
36
47
|
{ error: "Failed to generate CSRF token" },
|
|
37
|
-
{ status: 500 },
|
|
48
|
+
{ status: 500, headers: NO_STORE_HEADERS },
|
|
38
49
|
);
|
|
39
50
|
}
|
|
40
51
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { clearCsrfToken, getCsrfToken } from "@usequark/quark-core/csrf-client";
|
|
3
4
|
import {
|
|
4
5
|
Button,
|
|
5
6
|
ErrorBanner,
|
|
@@ -98,16 +99,55 @@ function RegisterForm() {
|
|
|
98
99
|
setPasswordError("");
|
|
99
100
|
setLoading(true);
|
|
100
101
|
|
|
101
|
-
|
|
102
|
+
// The CSRF token has to be in hand before the write. `GET /api/csrf` sets
|
|
103
|
+
// the matching httpOnly cookie and returns the same value; the server
|
|
104
|
+
// compares the two, so a request that arrives without the cookie is
|
|
105
|
+
// rejected before the handler runs. This page is pre-authentication, which
|
|
106
|
+
// is why the endpoint mints tokens for callers with no session.
|
|
107
|
+
//
|
|
108
|
+
// Both the token fetch and the write can fail on the network, and this
|
|
109
|
+
// handler has already set `loading`. Without a catch, either one would
|
|
110
|
+
// leave the button spinning forever.
|
|
111
|
+
try {
|
|
112
|
+
const res = await postRegistration(await getCsrfToken());
|
|
113
|
+
|
|
114
|
+
// The cache refetches on a timer, which covers expiry. It cannot cover
|
|
115
|
+
// the server rotating the cookie underneath us — a sign-out, or anything
|
|
116
|
+
// else that clears cookies — and that surfaces as a rejected write with
|
|
117
|
+
// nothing visibly wrong. Drop the stale token and try once more; a
|
|
118
|
+
// second failure is a real failure and falls through to the banner.
|
|
119
|
+
if (res.status === 401) {
|
|
120
|
+
clearCsrfToken();
|
|
121
|
+
return await completeRegistration(
|
|
122
|
+
await postRegistration(await getCsrfToken()),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return await completeRegistration(res);
|
|
127
|
+
} catch {
|
|
128
|
+
setError("Could not reach the server. Try again.");
|
|
129
|
+
setLoading(false);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** POST the form to /api/auth/register with the given token. */
|
|
134
|
+
async function postRegistration(csrfToken) {
|
|
135
|
+
return await fetch("/api/auth/register", {
|
|
102
136
|
method: "POST",
|
|
103
|
-
headers: {
|
|
137
|
+
headers: {
|
|
138
|
+
"Content-Type": "application/json",
|
|
139
|
+
"X-CSRF-Token": csrfToken,
|
|
140
|
+
},
|
|
104
141
|
body: JSON.stringify({
|
|
105
142
|
email: email.trim(),
|
|
106
143
|
password,
|
|
107
144
|
name: name.trim() || undefined,
|
|
108
145
|
}),
|
|
109
146
|
});
|
|
147
|
+
}
|
|
110
148
|
|
|
149
|
+
/** Surface a failure, or sign the new account in. */
|
|
150
|
+
async function completeRegistration(res) {
|
|
111
151
|
if (!res.ok) {
|
|
112
152
|
const body = await res.json().catch(() => ({}));
|
|
113
153
|
setError(body.message || "Registration failed. Try again.");
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"./rate-limit": "./src/rate-limit.js",
|
|
9
9
|
"./app-url": "./src/app-url.js",
|
|
10
10
|
"./environment": "./src/environment.js",
|
|
11
|
+
"./oauth": "./src/oauth.js",
|
|
11
12
|
"./load-config": "./src/load-config.js",
|
|
12
13
|
"./validate-env": "./src/validate-env.js",
|
|
13
14
|
"./redis": "./src/redis.js"
|
|
@@ -11,6 +11,13 @@ export {
|
|
|
11
11
|
resolveEnvironment,
|
|
12
12
|
} from "./environment.js";
|
|
13
13
|
export { getConfig, loadConfig, resetConfig } from "./load-config.js";
|
|
14
|
+
export {
|
|
15
|
+
getAppleClientId,
|
|
16
|
+
getGoogleClientId,
|
|
17
|
+
isAppleAuthEnabled,
|
|
18
|
+
isAudienceValid,
|
|
19
|
+
isGoogleAuthEnabled,
|
|
20
|
+
} from "./oauth.js";
|
|
14
21
|
export { applyRateLimit, rateLimit } from "./rate-limit.js";
|
|
15
22
|
export { closeSharedRedisClient, getSharedRedisClient } from "./redis.js";
|
|
16
23
|
export { loadEnv } from "./validate-env.js";
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth provider configuration.
|
|
3
|
+
*
|
|
4
|
+
* Every provider here is optional. Nothing in Quark requires an OAuth client
|
|
5
|
+
* id, so an app that never enables social sign-in reads `undefined` from these
|
|
6
|
+
* helpers and is unaffected.
|
|
7
|
+
*
|
|
8
|
+
* The contract the auth routes depend on: a provider is either fully
|
|
9
|
+
* configured or it is *off*. There is no third state in which a route serves
|
|
10
|
+
* requests with a weakened trust check. A caller that reaches
|
|
11
|
+
* `/api/auth/google` without `GOOGLE_CLIENT_ID` set gets a refusal, not an
|
|
12
|
+
* unverified login — the route is a public endpoint, so "nobody configured it"
|
|
13
|
+
* is not a reason to leave it serving.
|
|
14
|
+
*
|
|
15
|
+
* The client id is the `aud` claim on the id token the provider issues, which
|
|
16
|
+
* is what binds a token to *this* deployment. Without it, a token minted by any
|
|
17
|
+
* other app registered with the same provider verifies fine and would be
|
|
18
|
+
* accepted here.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Reads a client id, treating a blank value as unset.
|
|
23
|
+
*
|
|
24
|
+
* A variable set to `""` or whitespace survives `process.env` but is not a
|
|
25
|
+
* usable client id. Treating it as configured would turn the audience check
|
|
26
|
+
* into a comparison against the empty string and reject every real token,
|
|
27
|
+
* which reads as "sign-in is broken" rather than "sign-in is off".
|
|
28
|
+
*
|
|
29
|
+
* @param {string | undefined} value
|
|
30
|
+
* @returns {string | undefined} The trimmed id, or undefined when blank.
|
|
31
|
+
*/
|
|
32
|
+
function readClientId(value) {
|
|
33
|
+
const trimmed = value?.trim();
|
|
34
|
+
return trimmed ? trimmed : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The Google OAuth client id, or undefined when Google sign-in is not configured.
|
|
39
|
+
*
|
|
40
|
+
* @returns {string | undefined}
|
|
41
|
+
*/
|
|
42
|
+
export function getGoogleClientId() {
|
|
43
|
+
return readClientId(process.env.GOOGLE_CLIENT_ID);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The Apple Services ID, or undefined when Apple sign-in is not configured.
|
|
48
|
+
*
|
|
49
|
+
* @returns {string | undefined}
|
|
50
|
+
*/
|
|
51
|
+
export function getAppleClientId() {
|
|
52
|
+
return readClientId(process.env.APPLE_CLIENT_ID);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Whether Google sign-in is enabled on this deployment.
|
|
57
|
+
*
|
|
58
|
+
* @returns {boolean}
|
|
59
|
+
*/
|
|
60
|
+
export function isGoogleAuthEnabled() {
|
|
61
|
+
return Boolean(getGoogleClientId());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Whether Apple sign-in is enabled on this deployment.
|
|
66
|
+
*
|
|
67
|
+
* @returns {boolean}
|
|
68
|
+
*/
|
|
69
|
+
export function isAppleAuthEnabled() {
|
|
70
|
+
return Boolean(getAppleClientId());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Compares a token's `aud` claim against the expected client id.
|
|
75
|
+
*
|
|
76
|
+
* Used for Google, where the token is verified out of process by the provider's
|
|
77
|
+
* own introspection endpoint and the claims arrive as parsed JSON. Apple
|
|
78
|
+
* verifies the signature locally, so its audience is passed to `jwtVerify`
|
|
79
|
+
* instead and never comes through here.
|
|
80
|
+
*
|
|
81
|
+
* @param {unknown} actual - The `aud` claim from the verified token.
|
|
82
|
+
* @param {string | undefined} expected - The configured client id.
|
|
83
|
+
* @returns {boolean} False when either side is missing, so an unconfigured
|
|
84
|
+
* deployment cannot pass by accident.
|
|
85
|
+
*/
|
|
86
|
+
export function isAudienceValid(actual, expected) {
|
|
87
|
+
if (typeof actual !== "string" || actual === "") {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
if (typeof expected !== "string" || expected === "") {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
return actual === expected;
|
|
94
|
+
}
|
|
@@ -85,6 +85,15 @@ const ENV_DESCRIPTIONS = {
|
|
|
85
85
|
NEXTAUTH_SECRET: "NextAuth secret for JWT signing",
|
|
86
86
|
NEXTAUTH_URL: "NextAuth callback URL (derived from APP_URL if not set)",
|
|
87
87
|
|
|
88
|
+
// OAuth (optional — each provider is inert unless its client id is set)
|
|
89
|
+
GITHUB_ID: "GitHub OAuth client id for NextAuth sign-in",
|
|
90
|
+
GITHUB_SECRET: "GitHub OAuth client secret for NextAuth sign-in",
|
|
91
|
+
GOOGLE_CLIENT_ID:
|
|
92
|
+
"Google OAuth client id — enables POST /api/auth/google and the id token audience check",
|
|
93
|
+
GOOGLE_CLIENT_SECRET: "Google OAuth client secret for NextAuth sign-in",
|
|
94
|
+
APPLE_CLIENT_ID:
|
|
95
|
+
"Apple Services ID — enables POST /api/auth/apple and the identity token audience check",
|
|
96
|
+
|
|
88
97
|
// Application
|
|
89
98
|
APP_NAME: "Application name - used in metadata, emails, and page titles",
|
|
90
99
|
APP_DESCRIPTION:
|
|
@@ -221,6 +230,13 @@ function createEnvSchema(service) {
|
|
|
221
230
|
"NEXTAUTH_URL must be an absolute http(s) URL",
|
|
222
231
|
),
|
|
223
232
|
|
|
233
|
+
// OAuth (optional — each provider is inert unless its client id is set)
|
|
234
|
+
GITHUB_ID: optionalString,
|
|
235
|
+
GITHUB_SECRET: optionalString,
|
|
236
|
+
GOOGLE_CLIENT_ID: optionalString,
|
|
237
|
+
GOOGLE_CLIENT_SECRET: optionalString,
|
|
238
|
+
APPLE_CLIENT_ID: optionalString,
|
|
239
|
+
|
|
224
240
|
// Application
|
|
225
241
|
APP_NAME: optionalString,
|
|
226
242
|
APP_DESCRIPTION: optionalString,
|
|
@@ -416,6 +432,24 @@ function collectWarnings() {
|
|
|
416
432
|
);
|
|
417
433
|
}
|
|
418
434
|
|
|
435
|
+
// OAuth: half a provider is a provider that silently does not work.
|
|
436
|
+
// `auth.js` only registers a NextAuth provider when BOTH the client id and
|
|
437
|
+
// the secret are set, so an id with no secret leaves the sign-in button
|
|
438
|
+
// absent and the failure looks like a missing feature rather than a
|
|
439
|
+
// half-finished configuration. Worth saying out loud.
|
|
440
|
+
for (const [idKey, secretKey, label] of [
|
|
441
|
+
["GITHUB_ID", "GITHUB_SECRET", "GitHub"],
|
|
442
|
+
["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", "Google"],
|
|
443
|
+
]) {
|
|
444
|
+
const hasId = Boolean(process.env[idKey]?.trim());
|
|
445
|
+
const hasSecret = Boolean(process.env[secretKey]?.trim());
|
|
446
|
+
if (hasId !== hasSecret) {
|
|
447
|
+
warnings.push(
|
|
448
|
+
`${label} OAuth is incomplete: set both ${idKey} and ${secretKey} to enable it. The sign-in button stays hidden until both are present.`,
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
419
453
|
// Umami pair completeness
|
|
420
454
|
const umamiUrl = process.env.NEXT_PUBLIC_UMAMI_URL?.trim();
|
|
421
455
|
const umamiWebsiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID?.trim();
|
|
@@ -10,7 +10,14 @@ EXPO_PUBLIC_API_URL=http://localhost:3000
|
|
|
10
10
|
# --- Google OAuth (Optional) ---
|
|
11
11
|
# Get credentials at: https://console.cloud.google.com/apis/credentials
|
|
12
12
|
# EXPO_PUBLIC_GOOGLE_CLIENT_ID=your_google_client_id
|
|
13
|
+
#
|
|
14
|
+
# This must be the SAME client id the web app has as GOOGLE_CLIENT_ID. The
|
|
15
|
+
# server compares it against the token's audience on every sign-in, so a
|
|
16
|
+
# mismatch here fails the login with an "invalid token" error.
|
|
13
17
|
|
|
14
18
|
# --- Apple Sign In (Optional) ---
|
|
15
19
|
# Apple Sign In is configured via EAS and Apple Developer Console.
|
|
16
20
|
# No environment variables needed - it uses native module configuration.
|
|
21
|
+
#
|
|
22
|
+
# The web app must have APPLE_CLIENT_ID set to your Services ID, or
|
|
23
|
+
# POST /api/auth/apple refuses every request as unconfigured.
|