@grantry/nextjs 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +109 -2
- package/dist/cookie.d.ts +24 -0
- package/dist/cookie.d.ts.map +1 -0
- package/dist/cookie.js +24 -0
- package/dist/cookie.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +86 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware.d.ts +73 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +217 -0
- package/dist/middleware.js.map +1 -0
- package/dist/provider.d.ts +34 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +66 -0
- package/dist/provider.js.map +1 -0
- package/dist/server.d.ts +64 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +93 -0
- package/dist/server.js.map +1 -0
- package/package.json +61 -3
- package/src/cookie.ts +24 -0
- package/src/index.ts +249 -0
- package/src/middleware.ts +292 -0
- package/src/provider.tsx +73 -0
- package/src/server.ts +159 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route protection in Next.js middleware — `@grantry/nextjs/middleware`.
|
|
3
|
+
*
|
|
4
|
+
* export default grantryMiddleware({
|
|
5
|
+
* protectedRoutes: ['/dashboard', '/account'],
|
|
6
|
+
* signInUrl: '/sign-in',
|
|
7
|
+
* });
|
|
8
|
+
*
|
|
9
|
+
* export const config = { matcher: ['/((?!_next|.*\\..*).*)'] };
|
|
10
|
+
*
|
|
11
|
+
* Middleware runs before rendering, so an unauthenticated visitor is redirected
|
|
12
|
+
* without the protected page ever being built — which is the difference between
|
|
13
|
+
* protecting a route and merely hiding it. No request is let through signed out,
|
|
14
|
+
* a support visit's included; see `visitTicket`.
|
|
15
|
+
*/
|
|
16
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
17
|
+
import {
|
|
18
|
+
REDIRECT_URL_PARAMETER,
|
|
19
|
+
TICKET_PARAMETER,
|
|
20
|
+
environmentSignInUrl,
|
|
21
|
+
hostedSignInPage,
|
|
22
|
+
serverRouteOptions,
|
|
23
|
+
verifyToken,
|
|
24
|
+
} from '@grantry/react/server';
|
|
25
|
+
import { TOKEN_COOKIE_NAME, tokenFromCookie } from './cookie';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The headers a verified request goes on with, for the application's pages to read.
|
|
29
|
+
*
|
|
30
|
+
* ⚠️ REMOVED FROM EVERY REQUEST THIS MIDDLEWARE SEES, NOT ONLY THE ONES IT VERIFIES. They were
|
|
31
|
+
* replaced on a protected route and passed through untouched everywhere else, so a visitor who sent
|
|
32
|
+
* `x-grantry-user-id: <anybody>` to a public page had it arrive as though this middleware had set
|
|
33
|
+
* it — and the docs tell a page it can trust these names. A page outside the middleware's `matcher`
|
|
34
|
+
* is still beyond reach, which is why `auth()`, which verifies the cookie itself, is the safer read.
|
|
35
|
+
*/
|
|
36
|
+
const FORWARDED_HEADERS = ['x-grantry-user-id', 'x-grantry-session-id'] as const;
|
|
37
|
+
|
|
38
|
+
export interface GrantryMiddlewareOptions {
|
|
39
|
+
/**
|
|
40
|
+
* Path patterns requiring a signed-in visitor.
|
|
41
|
+
*
|
|
42
|
+
* A path prefix as a string — `'/dashboard'` protects it and everything under
|
|
43
|
+
* it — or a `RegExp` for anything else. `'/dashboard(.*)'`, the spelling other
|
|
44
|
+
* libraries use, means the same prefix; any other string that looks like a
|
|
45
|
+
* pattern is refused when the middleware is made (`prefixOf`).
|
|
46
|
+
* Anything not listed is public — an allowlist would silently protect new
|
|
47
|
+
* routes, which sounds safer but produces a site where adding a public page
|
|
48
|
+
* quietly breaks it.
|
|
49
|
+
*/
|
|
50
|
+
protectedRoutes?: Array<string | RegExp>;
|
|
51
|
+
/**
|
|
52
|
+
* Where to send unauthenticated visitors.
|
|
53
|
+
*
|
|
54
|
+
* Unset: `GRANTRY_SIGN_IN_URL`, then `NEXT_PUBLIC_GRANTRY_SIGN_IN_URL` — the
|
|
55
|
+
* variable `<GrantryProvider>` reads, so the two agree — then the hosted
|
|
56
|
+
* sign-in screen on the environment's sign-in URL, which is looked up with the
|
|
57
|
+
* publishable key (one request, remembered for five minutes). With none of
|
|
58
|
+
* those the visitor is answered 401 (docs/paths-and-domains.md §6).
|
|
59
|
+
* ⚠️ It defaulted to `/sign-in`, a page Grantry could only guess the
|
|
60
|
+
* application had.
|
|
61
|
+
*/
|
|
62
|
+
signInUrl?: string;
|
|
63
|
+
/** Override for self-hosted deployments and local development. */
|
|
64
|
+
apiUrl?: string;
|
|
65
|
+
/**
|
|
66
|
+
* Your publishable key, which names the application these tokens must belong
|
|
67
|
+
* to, and finds the hosted sign-in screen when no sign-in page is named. Read
|
|
68
|
+
* from `GRANTRY_PUBLISHABLE_KEY`, then `NEXT_PUBLIC_GRANTRY_PUBLISHABLE_KEY`,
|
|
69
|
+
* when omitted. ⚠️ With no key and no `environmentId`, a request carrying a
|
|
70
|
+
* token makes the middleware THROW (`no_audience`) rather than redirect — no
|
|
71
|
+
* token is ever admitted without knowing the application, and a sign-in page
|
|
72
|
+
* cannot fix a server that does not know which one it is. See
|
|
73
|
+
* `@grantry/react/server`.
|
|
74
|
+
*/
|
|
75
|
+
publishableKey?: string;
|
|
76
|
+
/** The environment id, for a deployment that configures it directly. */
|
|
77
|
+
environmentId?: string;
|
|
78
|
+
/** Require a verified email address, not merely a session. */
|
|
79
|
+
requireVerifiedEmail?: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A string route pattern, as the path prefix it protects.
|
|
84
|
+
*
|
|
85
|
+
* ⚠️ `'/dashboard(.*)'` PROTECTED NOTHING UNTIL 1 OCTOBER 2026, AND THE DOCS SAID TO WRITE IT. A
|
|
86
|
+
* string was compared as a literal prefix, so the pattern matched only a path that began with the
|
|
87
|
+
* characters `/dashboard(.*)` — none — and the middleware let every request through, signed out.
|
|
88
|
+
* The quickstart, the middleware page and this file's own example all used it (the docs audit). It
|
|
89
|
+
* is the spelling Clerk's route matcher takes, so somebody arriving from there writes it too: it is
|
|
90
|
+
* read as the prefix it means, and anything else that looks like a pattern is refused rather than
|
|
91
|
+
* silently matched against nothing.
|
|
92
|
+
*/
|
|
93
|
+
export function prefixOf(pattern: string): string {
|
|
94
|
+
const prefix = pattern.endsWith('(.*)') ? pattern.slice(0, -'(.*)'.length) : pattern;
|
|
95
|
+
if (/[()*?+[\]{}|^$\\]/u.test(prefix)) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Grantry: the route pattern "${pattern}" is not a path prefix. ` +
|
|
98
|
+
'Use a prefix such as "/dashboard", or pass a RegExp.'
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
const trimmed = prefix.replace(/\/+$/u, '');
|
|
102
|
+
return trimmed === '' ? '/' : trimmed;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function matchesPath(pathname: string, patterns: Array<string | RegExp>): boolean {
|
|
106
|
+
return patterns.some((pattern) => {
|
|
107
|
+
if (typeof pattern === 'string') {
|
|
108
|
+
// A prefix, anchored at a path boundary so `/dashboard` does not also
|
|
109
|
+
// protect `/dashboards-public`.
|
|
110
|
+
return pattern === '/' || pathname === pattern || pathname.startsWith(`${pattern}/`);
|
|
111
|
+
}
|
|
112
|
+
/*
|
|
113
|
+
* ⚠️ `lastIndex` RESET FIRST. A RegExp with the `g` or `y` flag remembers where its last match
|
|
114
|
+
* ended and starts the next `test` there, so `/^\/admin/g` matched one request, missed the
|
|
115
|
+
* next — letting it through signed out — and matched the one after.
|
|
116
|
+
*/
|
|
117
|
+
pattern.lastIndex = 0;
|
|
118
|
+
return pattern.test(pathname);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Whether a path is protected, as written and as decoded.
|
|
124
|
+
*
|
|
125
|
+
* ⚠️ BOTH SPELLINGS, BECAUSE THE ROUTER AND THIS CHECK MAY NOT AGREE ON ONE. `nextUrl.pathname`
|
|
126
|
+
* keeps percent-encoding (`/%64ashboard`), and a router that decodes before it matches a page
|
|
127
|
+
* would build `/dashboard` for an address the prefix never saw. Matching the decoded path too
|
|
128
|
+
* costs nothing on an ordinary request and only ever protects more.
|
|
129
|
+
*/
|
|
130
|
+
function matches(pathname: string, patterns: Array<string | RegExp>): boolean {
|
|
131
|
+
if (matchesPath(pathname, patterns)) return true;
|
|
132
|
+
let decoded: string;
|
|
133
|
+
try {
|
|
134
|
+
decoded = decodeURIComponent(pathname);
|
|
135
|
+
} catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
return decoded !== pathname && matchesPath(decoded, patterns);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The request as the application will see it, with no forwarded header the visitor wrote. */
|
|
142
|
+
function withoutForwardedHeaders(request: NextRequest): Headers {
|
|
143
|
+
const headers = new Headers(request.headers);
|
|
144
|
+
for (const name of FORWARDED_HEADERS) headers.delete(name);
|
|
145
|
+
return headers;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* A support visit's first request: a navigation carrying `?__grantry_ticket=`.
|
|
150
|
+
*
|
|
151
|
+
* ⚠️ CARRIED TO SIGN-IN, NEVER LET THROUGH. A view-as link opens the Home URL
|
|
152
|
+
* with its ticket (0242), and `@grantry/js` spends it on whatever page loads. A
|
|
153
|
+
* plain redirect to sign-in dropped the query string, so a protected Home URL
|
|
154
|
+
* turned every visit into a sign-in form for an operator with no password to
|
|
155
|
+
* type — and the first fix, letting the request through signed out, was a way
|
|
156
|
+
* past this middleware for anybody: the ticket is not checked here, so adding
|
|
157
|
+
* the parameter to any protected address rendered that page to a signed-out
|
|
158
|
+
* visitor. A page that trusted this middleware to keep its data from signed-out
|
|
159
|
+
* visitors would have served it.
|
|
160
|
+
*
|
|
161
|
+
* So the ticket rides the redirect instead. The sign-in page is public, the SDK
|
|
162
|
+
* spends the ticket there before anything else runs, and `<SignIn />` moves a
|
|
163
|
+
* visit on to `redirect_url` — the address the link pointed at, without the
|
|
164
|
+
* ticket. The protected page is only ever built for a signed-in request.
|
|
165
|
+
*
|
|
166
|
+
* Only GET and HEAD: a form post or a server action is never a navigation from a
|
|
167
|
+
* link, and carries nothing anywhere.
|
|
168
|
+
*/
|
|
169
|
+
function visitTicket(request: NextRequest): string | null {
|
|
170
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') return null;
|
|
171
|
+
return request.nextUrl.searchParams.get(TICKET_PARAMETER) || null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function grantryMiddleware(options: GrantryMiddlewareOptions = {}) {
|
|
175
|
+
const {
|
|
176
|
+
protectedRoutes: patterns = [],
|
|
177
|
+
apiUrl,
|
|
178
|
+
publishableKey,
|
|
179
|
+
environmentId,
|
|
180
|
+
requireVerifiedEmail = false,
|
|
181
|
+
} = options;
|
|
182
|
+
|
|
183
|
+
// Read once, here, so a pattern that cannot work fails the deploy and not a visitor.
|
|
184
|
+
const protectedRoutes = patterns.map((pattern) =>
|
|
185
|
+
typeof pattern === 'string' ? prefixOf(pattern) : pattern
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Where sign-in is: the option, the server's variables, the hosted sign-in screen.
|
|
190
|
+
*
|
|
191
|
+
* ⚠️ THE ONE SDK THAT DOES NOT DEFAULT TO REDIRECT SIGN-IN, because it cannot.
|
|
192
|
+
* A redirect sign-in keeps its verifier in the browser and spends its code
|
|
193
|
+
* there (docs/hosted-sign-in.md §6), and the only page this middleware knows
|
|
194
|
+
* is the protected one it is turning the visitor away from. Name a sign-in
|
|
195
|
+
* page — `<SignIn mode="redirect" />` makes one a single line.
|
|
196
|
+
*/
|
|
197
|
+
async function signInPage(): Promise<string | null> {
|
|
198
|
+
const configured = options.signInUrl ?? serverRouteOptions().signInUrl;
|
|
199
|
+
if (configured) return configured;
|
|
200
|
+
const signInUrl = await environmentSignInUrl({
|
|
201
|
+
...(publishableKey ? { publishableKey } : {}),
|
|
202
|
+
...(apiUrl ? { apiUrl } : {}),
|
|
203
|
+
});
|
|
204
|
+
return hostedSignInPage(signInUrl, 'sign-in');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return async function middleware(request: NextRequest): Promise<NextResponse> {
|
|
208
|
+
if (!matches(request.nextUrl.pathname, protectedRoutes)) {
|
|
209
|
+
// A public page: through, with nothing that claims to be this middleware's word.
|
|
210
|
+
return FORWARDED_HEADERS.some((name) => request.headers.has(name))
|
|
211
|
+
? NextResponse.next({ request: { headers: withoutForwardedHeaders(request) } })
|
|
212
|
+
: NextResponse.next();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const token = tokenFromCookie(request.cookies.get(TOKEN_COOKIE_NAME));
|
|
216
|
+
|
|
217
|
+
const turnAway = async (): Promise<NextResponse> => {
|
|
218
|
+
const signIn = await signInPage();
|
|
219
|
+
if (!signIn) {
|
|
220
|
+
return new NextResponse('Sign-in is required, and this application has no sign-in page.', {
|
|
221
|
+
status: 401,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const target = new URL(signIn, request.url);
|
|
226
|
+
const here = new URL(request.url);
|
|
227
|
+
// A support visit: the sign-in page spends the ticket, and the visit goes on to
|
|
228
|
+
// where the link pointed, without it. See `visitTicket`.
|
|
229
|
+
const ticket = visitTicket(request);
|
|
230
|
+
if (ticket) {
|
|
231
|
+
here.searchParams.delete(TICKET_PARAMETER);
|
|
232
|
+
target.searchParams.set(TICKET_PARAMETER, ticket);
|
|
233
|
+
}
|
|
234
|
+
/*
|
|
235
|
+
* So the app can return the visitor where they were headed rather than
|
|
236
|
+
* dumping them on a home page after signing in. ⚠️ A PATH WHEN SIGN-IN IS
|
|
237
|
+
* ON THIS ORIGIN, THE WHOLE ADDRESS WHEN IT IS NOT: on the hosted sign-in
|
|
238
|
+
* screen a path would name a page of the sign-in URL's. The query
|
|
239
|
+
* string goes too: `/dashboard?tab=billing` came back as `/dashboard`.
|
|
240
|
+
* The sign-in page follows it only on its own origin (`requestedRedirectUrl`).
|
|
241
|
+
*/
|
|
242
|
+
target.searchParams.set(
|
|
243
|
+
REDIRECT_URL_PARAMETER,
|
|
244
|
+
target.origin === here.origin ? `${here.pathname}${here.search}` : here.href
|
|
245
|
+
);
|
|
246
|
+
const response = NextResponse.redirect(target);
|
|
247
|
+
// The ticket is in this address; the page it leads to has no need to be told where it came from.
|
|
248
|
+
if (ticket) response.headers.set('Referrer-Policy', 'no-referrer');
|
|
249
|
+
return response;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
if (!token) return turnAway();
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
const verified = await verifyToken(token, {
|
|
256
|
+
...(apiUrl ? { apiUrl } : {}),
|
|
257
|
+
...(publishableKey ? { publishableKey } : {}),
|
|
258
|
+
...(environmentId ? { environmentId } : {}),
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
if (requireVerifiedEmail && !verified.emailVerified) {
|
|
262
|
+
return turnAway();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Passed to the application so a server component can read the user
|
|
266
|
+
// without verifying a second time. Set on the *outgoing* request headers,
|
|
267
|
+
// which the visitor cannot forge — anything they send under these names
|
|
268
|
+
// is replaced here (and removed on public pages; see `FORWARDED_HEADERS`).
|
|
269
|
+
const headers = withoutForwardedHeaders(request);
|
|
270
|
+
headers.set('x-grantry-user-id', verified.userId);
|
|
271
|
+
headers.set('x-grantry-session-id', verified.sessionId);
|
|
272
|
+
|
|
273
|
+
return NextResponse.next({ request: { headers } });
|
|
274
|
+
} catch (cause) {
|
|
275
|
+
/*
|
|
276
|
+
* ⚠️ A MISSING AUDIENCE IS THE ONE FAILURE THAT MUST NOT REDIRECT. Every
|
|
277
|
+
* other reason means this request cannot be trusted, and the sign-in page
|
|
278
|
+
* is the honest answer; `no_audience` means the application never said
|
|
279
|
+
* which Grantry environment it belongs to, and sending the visitor to
|
|
280
|
+
* sign in would loop them forever against a check that can never pass.
|
|
281
|
+
*/
|
|
282
|
+
if (
|
|
283
|
+
cause instanceof Error &&
|
|
284
|
+
cause.name === 'TokenVerificationError' &&
|
|
285
|
+
(cause as { reason?: string }).reason === 'no_audience'
|
|
286
|
+
) {
|
|
287
|
+
throw cause;
|
|
288
|
+
}
|
|
289
|
+
return turnAway();
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
}
|
package/src/provider.tsx
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The React provider, saying "nextjs" — and reading `NEXT_PUBLIC_GRANTRY_*`.
|
|
5
|
+
*
|
|
6
|
+
* ## Why this file exists at all
|
|
7
|
+
*
|
|
8
|
+
* Everything else in this package is a straight re-export of `@grantry/react`,
|
|
9
|
+
* on the principle that a second implementation is a second thing to drift. So
|
|
10
|
+
* a wrapper needs a reason, and it has two:
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ A NEXT.JS APP AND A PLAIN REACT APP ARE INDISTINGUISHABLE ON THE WIRE
|
|
13
|
+
* UNLESS ONE OF THEM SAYS SO. They render the same provider, construct the same
|
|
14
|
+
* client and send the same requests. That is normally a virtue and here it is a
|
|
15
|
+
* problem: the development Overview's setup guide watches for the merchant's
|
|
16
|
+
* app to connect and then shows them how to sign a user up *in the framework
|
|
17
|
+
* that connected*. Without this, everyone who installed `@grantry/nextjs` would
|
|
18
|
+
* be shown a plain-React snippet — no `middleware.ts`, no server helpers — on
|
|
19
|
+
* the one screen whose whole job is knowing what they are building.
|
|
20
|
+
*
|
|
21
|
+
* ⚠️ AND IT IS NOT DETECTABLE AT RUNTIME. `__NEXT_DATA__` exists only in the
|
|
22
|
+
* Pages Router, `process.env.NEXT_RUNTIME` is server-only, and sniffing for
|
|
23
|
+
* either would make the answer depend on which router the merchant chose. The
|
|
24
|
+
* honest signal is which package they installed, and this is the file that
|
|
25
|
+
* knows.
|
|
26
|
+
*
|
|
27
|
+
* ⚠️ THE SECOND REASON IS THE ENVIRONMENT VARIABLES, WHICH ONLY THIS PACKAGE
|
|
28
|
+
* CAN READ. Where the application's pages are is its own code since 0242 —
|
|
29
|
+
* `signInUrl`, `signUpUrl`, the fallback and force redirect URLs and
|
|
30
|
+
* `afterSignOutUrl`, Clerk's names — and a Next.js application sets them as
|
|
31
|
+
* `NEXT_PUBLIC_GRANTRY_*`. They become props of the React provider here, under
|
|
32
|
+
* whatever props were passed, so the rules stay `@grantry/react`'s.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
GrantryProvider as ReactProvider,
|
|
37
|
+
mergeRouteOptions,
|
|
38
|
+
type GrantryProviderProps,
|
|
39
|
+
type RouteOptions,
|
|
40
|
+
} from '@grantry/react';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `NEXT_PUBLIC_GRANTRY_*`, as route options.
|
|
44
|
+
*
|
|
45
|
+
* ⚠️ EVERY NAME WRITTEN OUT IN FULL. Next.js puts a `NEXT_PUBLIC_` variable into
|
|
46
|
+
* the browser bundle only where `process.env.NAME` appears literally; a name
|
|
47
|
+
* built from a prefix is correct on the server and `undefined` in the browser,
|
|
48
|
+
* which is a hydration mismatch as well as a wrong link.
|
|
49
|
+
*/
|
|
50
|
+
function publicRouteOptions(): RouteOptions {
|
|
51
|
+
return {
|
|
52
|
+
signInUrl: process.env.NEXT_PUBLIC_GRANTRY_SIGN_IN_URL,
|
|
53
|
+
signUpUrl: process.env.NEXT_PUBLIC_GRANTRY_SIGN_UP_URL,
|
|
54
|
+
signInFallbackRedirectUrl: process.env.NEXT_PUBLIC_GRANTRY_SIGN_IN_FALLBACK_REDIRECT_URL,
|
|
55
|
+
signUpFallbackRedirectUrl: process.env.NEXT_PUBLIC_GRANTRY_SIGN_UP_FALLBACK_REDIRECT_URL,
|
|
56
|
+
signInForceRedirectUrl: process.env.NEXT_PUBLIC_GRANTRY_SIGN_IN_FORCE_REDIRECT_URL,
|
|
57
|
+
signUpForceRedirectUrl: process.env.NEXT_PUBLIC_GRANTRY_SIGN_UP_FORCE_REDIRECT_URL,
|
|
58
|
+
afterSignOutUrl: process.env.NEXT_PUBLIC_GRANTRY_AFTER_SIGN_OUT_URL,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function GrantryProvider(props: GrantryProviderProps) {
|
|
63
|
+
/*
|
|
64
|
+
* Props first, then the variables — Clerk's order — merged option by option,
|
|
65
|
+
* so a prop left undefined does not hide the variable beneath it.
|
|
66
|
+
*
|
|
67
|
+
* `props.sdk` after `sdk="nextjs"`, so it is genuinely a default rather than
|
|
68
|
+
* a lock. An application embedding this inside something else can still say
|
|
69
|
+
* what it is, and passing nothing — the normal case — reports `nextjs`.
|
|
70
|
+
*/
|
|
71
|
+
const routes = mergeRouteOptions(props, publicRouteOptions());
|
|
72
|
+
return <ReactProvider sdk="nextjs" {...props} {...routes} />;
|
|
73
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side auth for the Next.js App Router — `@grantry/nextjs/server`.
|
|
3
|
+
*
|
|
4
|
+
* const { userId } = await auth();
|
|
5
|
+
* if (!userId) redirect('/sign-in');
|
|
6
|
+
*
|
|
7
|
+
* Reads the access-token cookie the client SDK mirrors onto this app's domain
|
|
8
|
+
* and verifies it against Grantry's published keys. No network call in the hot
|
|
9
|
+
* path once the keys are cached, so this is cheap enough to use on every render.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ `auth()` IS THE APP ROUTER'S ALONE, SO THE VERIFIER IS EXPORTED BESIDE IT. It reads
|
|
12
|
+
* `next/headers`, which a Pages Router `getServerSideProps` or API route cannot call; those verify
|
|
13
|
+
* `req.cookies.grantry_token` with `verifyToken` themselves. The docs said "verify in
|
|
14
|
+
* getServerSideProps" while this entry point offered nothing to verify with, and a Next.js
|
|
15
|
+
* application is told not to install `@grantry/react` beside this package (the docs audit,
|
|
16
|
+
* 1 October 2026).
|
|
17
|
+
*/
|
|
18
|
+
import { cookies } from 'next/headers';
|
|
19
|
+
import {
|
|
20
|
+
TokenVerificationError,
|
|
21
|
+
verifyToken,
|
|
22
|
+
type VerifiedToken,
|
|
23
|
+
type VerifyFailureReason,
|
|
24
|
+
type VerifyTokenOptions,
|
|
25
|
+
} from '@grantry/react/server';
|
|
26
|
+
import { TOKEN_COOKIE_NAME, tokenFromCookie } from './cookie';
|
|
27
|
+
|
|
28
|
+
export interface AuthObject {
|
|
29
|
+
/** Null when the visitor is not signed in. */
|
|
30
|
+
userId: string | null;
|
|
31
|
+
sessionId: string | null;
|
|
32
|
+
environmentId: string | null;
|
|
33
|
+
emailVerified: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* The operator, when this visitor is somebody from your team viewing as the user — the verified
|
|
36
|
+
* `act` claim. Refuse your own sensitive actions while it is set.
|
|
37
|
+
*/
|
|
38
|
+
actor: { sub: string } | null;
|
|
39
|
+
/** The verified token, for callers that want the expiry or full claims. */
|
|
40
|
+
token: VerifiedToken | null;
|
|
41
|
+
/**
|
|
42
|
+
* Why authentication failed, when it did.
|
|
43
|
+
*
|
|
44
|
+
* `expired` in particular is worth distinguishing: it usually means the tab
|
|
45
|
+
* was asleep and the client will recover on its own, not that anything is
|
|
46
|
+
* misconfigured.
|
|
47
|
+
*/
|
|
48
|
+
reason: TokenVerificationError['reason'] | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const SIGNED_OUT: AuthObject = {
|
|
52
|
+
userId: null,
|
|
53
|
+
sessionId: null,
|
|
54
|
+
environmentId: null,
|
|
55
|
+
emailVerified: false,
|
|
56
|
+
actor: null,
|
|
57
|
+
token: null,
|
|
58
|
+
reason: null,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export interface AuthOptions {
|
|
62
|
+
/** Override for self-hosted deployments and local development. */
|
|
63
|
+
apiUrl?: string;
|
|
64
|
+
/**
|
|
65
|
+
* Your publishable key — the same one the provider takes.
|
|
66
|
+
*
|
|
67
|
+
* ⚠️ IT IS WHAT SAYS WHICH APPLICATION A TOKEN MUST BELONG TO. Read from
|
|
68
|
+
* `GRANTRY_PUBLISHABLE_KEY`, then `NEXT_PUBLIC_GRANTRY_PUBLISHABLE_KEY` — the
|
|
69
|
+
* variable `<GrantryProvider>` is already given — when omitted, so the
|
|
70
|
+
* ordinary Next.js application passes nothing here. With no key and no
|
|
71
|
+
* `environmentId`, `auth()` throws `no_audience` rather than accepting a token
|
|
72
|
+
* minted for another application. See `@grantry/react/server`.
|
|
73
|
+
*/
|
|
74
|
+
publishableKey?: string;
|
|
75
|
+
/** The environment id, for a deployment that configures it directly. */
|
|
76
|
+
environmentId?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The current visitor, in a server component, route handler or server action.
|
|
81
|
+
*
|
|
82
|
+
* Never throws for an unauthenticated visitor — being signed out is an ordinary
|
|
83
|
+
* state, not an exception. The `reason` field carries the detail when a token
|
|
84
|
+
* was present but unusable.
|
|
85
|
+
*/
|
|
86
|
+
export async function auth(options: AuthOptions = {}): Promise<AuthObject> {
|
|
87
|
+
const store = await cookies();
|
|
88
|
+
const raw = tokenFromCookie(store.get(TOKEN_COOKIE_NAME));
|
|
89
|
+
if (!raw) return SIGNED_OUT;
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const token = await verifyToken(raw, options);
|
|
93
|
+
return {
|
|
94
|
+
userId: token.userId,
|
|
95
|
+
sessionId: token.sessionId,
|
|
96
|
+
environmentId: token.environmentId,
|
|
97
|
+
emailVerified: token.emailVerified,
|
|
98
|
+
actor: token.actor,
|
|
99
|
+
token,
|
|
100
|
+
reason: null,
|
|
101
|
+
};
|
|
102
|
+
} catch (cause) {
|
|
103
|
+
/*
|
|
104
|
+
* ⚠️ `no_audience` IS RETHROWN RATHER THAN REPORTED AS SIGNED OUT. It says
|
|
105
|
+
* this deployment never configured which application to accept tokens for,
|
|
106
|
+
* which is a fault in the app rather than a fact about the visitor —
|
|
107
|
+
* swallowing it would sign every user out at once and look like a broken
|
|
108
|
+
* cookie.
|
|
109
|
+
*/
|
|
110
|
+
if (cause instanceof TokenVerificationError && cause.reason !== 'no_audience') {
|
|
111
|
+
return { ...SIGNED_OUT, reason: cause.reason };
|
|
112
|
+
}
|
|
113
|
+
throw cause;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* `auth()`, but throws when nobody is signed in.
|
|
119
|
+
*
|
|
120
|
+
* For routes where being signed out is a bug rather than a case to handle —
|
|
121
|
+
* it narrows `userId` to a string, so the call site needs no null check and
|
|
122
|
+
* cannot forget one.
|
|
123
|
+
*/
|
|
124
|
+
export async function requireAuth(
|
|
125
|
+
options: AuthOptions = {}
|
|
126
|
+
): Promise<AuthObject & { userId: string; sessionId: string }> {
|
|
127
|
+
const result = await auth(options);
|
|
128
|
+
|
|
129
|
+
if (!result.userId || !result.sessionId) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
'requireAuth() was called but no user is signed in. ' +
|
|
132
|
+
'Use auth() and handle the signed-out case, or protect this route in middleware.'
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return result as AuthObject & { userId: string; sessionId: string };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/*
|
|
140
|
+
* `verifyToken` for the Pages Router and anything else `auth()` cannot reach — see the note at the
|
|
141
|
+
* top. The same function `@grantry/react/server` exports, not a copy.
|
|
142
|
+
*/
|
|
143
|
+
export {
|
|
144
|
+
TokenVerificationError,
|
|
145
|
+
verifyToken,
|
|
146
|
+
type VerifiedToken,
|
|
147
|
+
type VerifyFailureReason,
|
|
148
|
+
type VerifyTokenOptions,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/*
|
|
152
|
+
* Spending a "Verify it's you" grant before a sensitive action (0330), with `auth()`'s `sessionId`
|
|
153
|
+
* and `userId` — the same function `@grantry/react/server` exports, for the reason above.
|
|
154
|
+
*/
|
|
155
|
+
export {
|
|
156
|
+
ReverificationCheckError,
|
|
157
|
+
verifyReverification,
|
|
158
|
+
type VerifiedReverification,
|
|
159
|
+
} from '@grantry/react/server';
|