@aravindc26/velu 0.13.6 → 0.13.8
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
CHANGED
|
@@ -1,16 +1,72 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared API authentication via PREVIEW_API_SECRET.
|
|
3
3
|
*/
|
|
4
|
+
import { createHmac, timingSafeEqual } from 'crypto';
|
|
4
5
|
import { NextRequest } from 'next/server';
|
|
5
6
|
|
|
6
7
|
const PREVIEW_API_SECRET = process.env.PREVIEW_API_SECRET || '';
|
|
7
8
|
|
|
9
|
+
export function getPreviewSecret(): string {
|
|
10
|
+
return PREVIEW_API_SECRET;
|
|
11
|
+
}
|
|
12
|
+
|
|
8
13
|
export function verifyApiSecret(request: NextRequest): boolean {
|
|
9
14
|
if (!PREVIEW_API_SECRET) return true; // No secret configured — allow all
|
|
10
15
|
const header = request.headers.get('x-preview-secret') || '';
|
|
11
16
|
return header === PREVIEW_API_SECRET;
|
|
12
17
|
}
|
|
13
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Verify an HMAC-signed preview token.
|
|
21
|
+
*
|
|
22
|
+
* Token format: `{base64url_hmac}.{sessionId}:{unix_expiry}`
|
|
23
|
+
*
|
|
24
|
+
* Returns `{ valid: true, expiry }` when the token is authentic, matches the
|
|
25
|
+
* expected session ID, and has not expired. Returns `{ valid: false, expiry: 0 }`
|
|
26
|
+
* otherwise.
|
|
27
|
+
*/
|
|
28
|
+
export function verifyPreviewToken(
|
|
29
|
+
token: string,
|
|
30
|
+
expectedSessionId: string,
|
|
31
|
+
): { valid: boolean; expiry: number } {
|
|
32
|
+
const fail = { valid: false, expiry: 0 };
|
|
33
|
+
if (!PREVIEW_API_SECRET) return { valid: true, expiry: 0 }; // No secret — allow all
|
|
34
|
+
|
|
35
|
+
const dotIdx = token.indexOf('.');
|
|
36
|
+
if (dotIdx === -1) return fail;
|
|
37
|
+
|
|
38
|
+
const sigB64 = token.slice(0, dotIdx);
|
|
39
|
+
const payload = token.slice(dotIdx + 1);
|
|
40
|
+
|
|
41
|
+
// Payload must be "{sessionId}:{expiry}"
|
|
42
|
+
const colonIdx = payload.indexOf(':');
|
|
43
|
+
if (colonIdx === -1) return fail;
|
|
44
|
+
|
|
45
|
+
const sessionId = payload.slice(0, colonIdx);
|
|
46
|
+
const expiryStr = payload.slice(colonIdx + 1);
|
|
47
|
+
|
|
48
|
+
if (sessionId !== expectedSessionId) return fail;
|
|
49
|
+
|
|
50
|
+
const expiry = parseInt(expiryStr, 10);
|
|
51
|
+
if (isNaN(expiry) || expiry < Math.floor(Date.now() / 1000)) return fail;
|
|
52
|
+
|
|
53
|
+
// Recompute HMAC and compare in constant time
|
|
54
|
+
const expectedSig = createHmac('sha256', PREVIEW_API_SECRET)
|
|
55
|
+
.update(payload)
|
|
56
|
+
.digest('base64url')
|
|
57
|
+
// Strip padding to match Python's rstrip(b"=")
|
|
58
|
+
.replace(/=+$/, '');
|
|
59
|
+
|
|
60
|
+
// Ensure both are the same length before timingSafeEqual
|
|
61
|
+
const sigBuf = Buffer.from(sigB64, 'utf8');
|
|
62
|
+
const expectedBuf = Buffer.from(expectedSig, 'utf8');
|
|
63
|
+
if (sigBuf.length !== expectedBuf.length) return fail;
|
|
64
|
+
|
|
65
|
+
if (!timingSafeEqual(sigBuf, expectedBuf)) return fail;
|
|
66
|
+
|
|
67
|
+
return { valid: true, expiry };
|
|
68
|
+
}
|
|
69
|
+
|
|
14
70
|
export function unauthorizedResponse() {
|
|
15
71
|
return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
|
16
72
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { NextRequest } from 'next/server';
|
|
2
|
+
import { NextResponse } from 'next/server';
|
|
3
|
+
import { getPreviewSecret, verifyPreviewToken } from './lib/preview-auth';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Next.js proxy (middleware) that enforces HMAC-signed token authentication
|
|
7
|
+
* for preview page routes.
|
|
8
|
+
*
|
|
9
|
+
* Flow:
|
|
10
|
+
* 1. Requests with `x-preview-secret` header pass through (server-to-server API calls).
|
|
11
|
+
* 2. Extract sessionId from the URL path (`/{sessionId}/...`).
|
|
12
|
+
* 3. If `?token=` query param is present: validate HMAC, set an HttpOnly cookie,
|
|
13
|
+
* redirect to the same URL without the token (keeps browser history clean).
|
|
14
|
+
* 4. If a valid `__velu_preview_{sessionId}` cookie exists: allow.
|
|
15
|
+
* 5. Otherwise: 403.
|
|
16
|
+
*/
|
|
17
|
+
export function proxy(request: NextRequest) {
|
|
18
|
+
const secret = getPreviewSecret();
|
|
19
|
+
|
|
20
|
+
// No secret configured — allow everything (dev / local)
|
|
21
|
+
if (!secret) return NextResponse.next();
|
|
22
|
+
|
|
23
|
+
// Server-to-server API calls use the x-preview-secret header — skip page auth
|
|
24
|
+
if (request.headers.get('x-preview-secret')) {
|
|
25
|
+
return NextResponse.next();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { pathname } = request.nextUrl;
|
|
29
|
+
|
|
30
|
+
// Extract sessionId: first path segment after leading slash
|
|
31
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
32
|
+
if (segments.length === 0) return NextResponse.next();
|
|
33
|
+
|
|
34
|
+
// Skip Next.js internal and static paths
|
|
35
|
+
const first = segments[0];
|
|
36
|
+
if (first === '_next' || first === 'api') return NextResponse.next();
|
|
37
|
+
|
|
38
|
+
// sessionId should be numeric
|
|
39
|
+
const sessionId = first;
|
|
40
|
+
if (!/^\d+$/.test(sessionId)) return NextResponse.next();
|
|
41
|
+
|
|
42
|
+
// --- Token in query string: validate, set cookie, redirect to clean URL ---
|
|
43
|
+
const tokenParam = request.nextUrl.searchParams.get('token');
|
|
44
|
+
if (tokenParam) {
|
|
45
|
+
const { valid, expiry } = verifyPreviewToken(tokenParam, sessionId);
|
|
46
|
+
if (!valid) {
|
|
47
|
+
return new NextResponse('Forbidden', { status: 403 });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Redirect to the same URL without the token
|
|
51
|
+
const cleanUrl = request.nextUrl.clone();
|
|
52
|
+
cleanUrl.searchParams.delete('token');
|
|
53
|
+
const response = NextResponse.redirect(cleanUrl);
|
|
54
|
+
|
|
55
|
+
// Compute remaining TTL for cookie maxAge
|
|
56
|
+
const now = Math.floor(Date.now() / 1000);
|
|
57
|
+
const maxAge = expiry > 0 ? expiry - now : 86400;
|
|
58
|
+
|
|
59
|
+
response.cookies.set(`__velu_preview_${sessionId}`, tokenParam, {
|
|
60
|
+
httpOnly: true,
|
|
61
|
+
secure: true,
|
|
62
|
+
sameSite: 'none', // Required for cross-origin iframe
|
|
63
|
+
path: `/${sessionId}`,
|
|
64
|
+
maxAge,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return response;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// --- Cookie: validate existing token stored in cookie ---
|
|
71
|
+
const cookie = request.cookies.get(`__velu_preview_${sessionId}`);
|
|
72
|
+
if (cookie) {
|
|
73
|
+
const { valid } = verifyPreviewToken(cookie.value, sessionId);
|
|
74
|
+
if (valid) return NextResponse.next();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return new NextResponse('Forbidden', { status: 403 });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const config = {
|
|
81
|
+
matcher: [
|
|
82
|
+
'/((?!_next|favicon\\.ico|robots\\.txt|sitemap\\.xml).*)',
|
|
83
|
+
],
|
|
84
|
+
};
|