diff --git a/src/app/api/auth/refresh/route.ts b/src/app/api/auth/refresh/route.ts
new file mode 100644
index 00000000..13d3bd36
--- /dev/null
+++ b/src/app/api/auth/refresh/route.ts
@@ -0,0 +1,84 @@
+import { NextRequest, NextResponse } from "next/server";
+import {
+ ACCESS_TOKEN_MAX_AGE,
+ REFRESH_TOKEN_MAX_AGE,
+ USE_SECURE_COOKIES,
+ createAccessToken,
+ createRefreshToken,
+ getTokenCookieName,
+ verifyRefreshToken,
+} from "@/lib/auth-tokens";
+
+function unauthorizedResponse() {
+ const response = NextResponse.json(
+ { error: "Invalid refresh token" },
+ { status: 401 }
+ );
+
+ response.cookies.set({
+ name: getTokenCookieName("access"),
+ value: "",
+ maxAge: 0,
+ path: "/",
+ });
+ response.cookies.set({
+ name: getTokenCookieName("refresh"),
+ value: "",
+ maxAge: 0,
+ path: "/",
+ });
+
+ return response;
+}
+
+export async function POST(req: NextRequest) {
+ const refreshToken = req.cookies.get(getTokenCookieName("refresh"))?.value;
+ if (!refreshToken) {
+ return unauthorizedResponse();
+ }
+
+ let payload;
+ try {
+ payload = verifyRefreshToken(refreshToken);
+ } catch {
+ return unauthorizedResponse();
+ }
+
+ const accessToken = createAccessToken({
+ githubId: payload.githubId,
+ githubLogin: payload.githubLogin,
+ });
+ const newRefreshToken = createRefreshToken({
+ githubId: payload.githubId,
+ githubLogin: payload.githubLogin,
+ });
+
+ const response = NextResponse.json({
+ ok: true,
+ accessTokenExpiresIn: ACCESS_TOKEN_MAX_AGE,
+ });
+
+ response.cookies.set({
+ name: getTokenCookieName("access"),
+ value: accessToken,
+ httpOnly: true,
+ sameSite: "lax",
+ secure: USE_SECURE_COOKIES,
+ path: "/",
+ maxAge: ACCESS_TOKEN_MAX_AGE,
+ });
+ response.cookies.set({
+ name: getTokenCookieName("refresh"),
+ value: newRefreshToken,
+ httpOnly: true,
+ sameSite: "lax",
+ secure: USE_SECURE_COOKIES,
+ path: "/",
+ maxAge: REFRESH_TOKEN_MAX_AGE,
+ });
+
+ return response;
+}
+
+
+
diff --git a/src/app/api/auth/token/route.ts b/src/app/api/auth/token/route.ts
new file mode 100644
index 00000000..c0caeb5b
--- /dev/null
+++ b/src/app/api/auth/token/route.ts
@@ -0,0 +1,64 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import {
+ ACCESS_TOKEN_MAX_AGE,
+ REFRESH_TOKEN_MAX_AGE,
+ USE_SECURE_COOKIES,
+ createAccessToken,
+ createRefreshToken,
+ getTokenCookieName,
+} from "@/lib/auth-tokens";
+
+/**
+ * POST /api/auth/token
+ *
+ * This endpoint allows users to exchange their existing NextAuth session
+ * for the parallel JWT authentication tokens. This is intended for
+ * setting up access for third-party tools, CLI, or mobile applications.
+ */
+export async function POST(req: NextRequest) {
+ const session = await getServerSession(authOptions);
+
+ if (!session || !session.githubId || !session.githubLogin) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const accessToken = createAccessToken({
+ githubId: session.githubId,
+ githubLogin: session.githubLogin,
+ });
+
+ const refreshToken = createRefreshToken({
+ githubId: session.githubId,
+ githubLogin: session.githubLogin,
+ });
+
+ const response = NextResponse.json({
+ ok: true,
+ message: "Tokens generated successfully",
+ accessTokenExpiresIn: ACCESS_TOKEN_MAX_AGE,
+ });
+
+ response.cookies.set({
+ name: getTokenCookieName("access"),
+ value: accessToken,
+ httpOnly: true,
+ sameSite: "lax",
+ secure: USE_SECURE_COOKIES,
+ path: "/",
+ maxAge: ACCESS_TOKEN_MAX_AGE,
+ });
+
+ response.cookies.set({
+ name: getTokenCookieName("refresh"),
+ value: refreshToken,
+ httpOnly: true,
+ sameSite: "lax",
+ secure: USE_SECURE_COOKIES,
+ path: "/",
+ maxAge: REFRESH_TOKEN_MAX_AGE,
+ });
+
+ return response;
+}
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
index 890ee3c0..6e35034e 100644
--- a/src/app/dashboard/page.tsx
+++ b/src/app/dashboard/page.tsx
@@ -24,17 +24,27 @@ import Link from "next/link";
import PersonalRecords from "@/components/PersonalRecords";
import LocalCodingTime from "@/components/LocalCodingTime";
import RecentActivity from "@/components/RecentActivity";
+
import { authOptions } from "@/lib/auth";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await getServerSession(authOptions);
- if (!session) redirect("/");
+
+ if (!session) {
+ redirect("/");
+ }
+
+ if (session.githubId && session.githubLogin) {
+ // Note: JWT token generation for third-party clients (mobile apps, CLI, etc.)
+ // is available via the /api/auth/token endpoint.
+ }
return (
+
Settings
+
+
@@ -54,13 +66,15 @@ export default async function DashboardPage() {
- {/* Row 1: Contribution graph + Streak + Local Coding Time */}
+ {/* Row 1 */}
+
+
@@ -92,19 +106,21 @@ export default async function DashboardPage() {
- {/* Row 3: Issue metrics + CI analytics */}
+ {/* Row 3 */}
- {/* Row 4: Pinned repositories */}
+ {/* Row 4 */}
+
{/* Row 5: Inactive repository reminder */}
@@ -124,3 +140,4 @@ export default async function DashboardPage() {
);
}
+
diff --git a/src/app/leaderboard/page.tsx b/src/app/leaderboard/page.tsx
index d424038e..3a12bd9e 100644
--- a/src/app/leaderboard/page.tsx
+++ b/src/app/leaderboard/page.tsx
@@ -181,3 +181,4 @@ export default async function LeaderboardPage({
);
}
+
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx
index f5c5a445..3d07eca6 100644
--- a/src/components/Footer.tsx
+++ b/src/components/Footer.tsx
@@ -47,7 +47,7 @@ export default function Footer() {
className="transition-colors hover:text-[var(--card-foreground)]"
href="https://github.com/Priyanshu-byte-coder/devtrack/discussions"
target="_blank"
- rel="noreferrer"
+ rel="noopener noreferrer"
>
Discussions
@@ -55,7 +55,7 @@ export default function Footer() {
className="transition-colors hover:text-[var(--card-foreground)]"
href="https://github.com/Priyanshu-byte-coder/devtrack/issues"
target="_blank"
- rel="noreferrer"
+ rel="noopener noreferrer"
>
Issues
@@ -63,7 +63,7 @@ export default function Footer() {
className="transition-colors hover:text-[var(--card-foreground)]"
href="https://github.com/Priyanshu-byte-coder/devtrack"
target="_blank"
- rel="noreferrer"
+ rel="noopener noreferrer"
>
GitHub Repository
@@ -79,7 +79,7 @@ export default function Footer() {
className="transition-colors hover:text-[var(--card-foreground)]"
href="https://www.linkedin.com/in/priyanshu-doshi-21a54230a/"
target="_blank"
- rel="noreferrer"
+ rel="noopener noreferrer"
>
LinkedIn
@@ -87,7 +87,7 @@ export default function Footer() {
className="transition-colors hover:text-[var(--card-foreground)]"
href="https://github.com/Priyanshu-byte-coder"
target="_blank"
- rel="noreferrer"
+ rel="noopener noreferrer"
>
GitHub
@@ -95,7 +95,7 @@ export default function Footer() {
className="transition-colors hover:text-[var(--card-foreground)]"
href="https://portfolio-eta-gilt-84.vercel.app/"
target="_blank"
- rel="noreferrer"
+ rel="noopener noreferrer"
>
Portfolio
diff --git a/src/lib/auth-tokens.ts b/src/lib/auth-tokens.ts
new file mode 100644
index 00000000..1eda3e6e
--- /dev/null
+++ b/src/lib/auth-tokens.ts
@@ -0,0 +1,153 @@
+/**
+ * Intended Use Case for this JWT System:
+ * The primary DevTrack web application uses NextAuth (session cookies) for authentication.
+ * This parallel JWT-based authentication system provides long-lived refresh tokens and
+ * short-lived access tokens specifically designed for external API clients, CLI tools,
+ * or mobile applications that cannot rely on browser-based NextAuth session mechanisms.
+ */
+
+import { createHmac, timingSafeEqual } from "crypto";
+
+export const ACCESS_TOKEN_MAX_AGE = 15 * 60; // 15 minutes
+export const REFRESH_TOKEN_MAX_AGE = 30 * 24 * 60 * 60; // 30 days
+export const USE_SECURE_COOKIES = process.env.NODE_ENV === "production";
+
+export type AccessTokenPayload = {
+ type: "access";
+ githubId: string;
+ githubLogin: string;
+ iat: number;
+ exp: number;
+};
+
+export type RefreshTokenPayload = {
+ type: "refresh";
+ githubId: string;
+ githubLogin: string;
+ iat: number;
+ exp: number;
+};
+
+function base64UrlEncode(value: Buffer | string) {
+ return Buffer.from(value)
+ .toString("base64")
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_")
+ .replace(/=+$/, "");
+}
+
+function base64UrlDecode(value: string) {
+ const padded = value.padEnd(value.length + ((4 - (value.length % 4)) % 4), "=");
+ return Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64");
+}
+
+function sign(value: string, secret: string): string {
+ return createHmac("sha256", secret).update(value).digest("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+}
+
+function createJwt
(payload: T, secret: string): string {
+ const header = { alg: "HS256", typ: "JWT" };
+ const encodedHeader = base64UrlEncode(JSON.stringify(header));
+ const encodedPayload = base64UrlEncode(JSON.stringify(payload));
+ const signature = sign(`${encodedHeader}.${encodedPayload}`, secret);
+ return `${encodedHeader}.${encodedPayload}.${signature}`;
+}
+
+function verifyJwt(token: string, secret: string) {
+ const parts = token.split(".");
+ if (parts.length !== 3) {
+ throw new Error("Invalid JWT format");
+ }
+
+ const [encodedHeader, encodedPayload, signature] = parts;
+ const expectedSignature = sign(`${encodedHeader}.${encodedPayload}`, secret);
+ if (!cryptoTimingSafeEqual(signature, expectedSignature)) {
+ throw new Error("Invalid JWT signature");
+ }
+
+ const payloadJson = base64UrlDecode(encodedPayload).toString("utf8");
+ const payload = JSON.parse(payloadJson) as { exp?: number };
+
+ if (typeof payload.exp !== "number" || payload.exp * 1000 < Date.now()) {
+ throw new Error("JWT expired");
+ }
+
+ return payload;
+}
+
+function cryptoTimingSafeEqual(a: string, b: string) {
+ const aBuffer = Buffer.from(a, "utf8");
+ const bBuffer = Buffer.from(b, "utf8");
+ if (aBuffer.length !== bBuffer.length) {
+ return false;
+ }
+ return timingSafeEqual(aBuffer, bBuffer);
+}
+
+export function getAuthTokenSecret() {
+ const secret = process.env.JWT_SECRET || process.env.NEXTAUTH_SECRET;
+ if (!secret) {
+ throw new Error("JWT_SECRET or NEXTAUTH_SECRET is required for JWT authentication");
+ }
+ return secret;
+}
+
+export function getTokenCookieName(type: "access" | "refresh") {
+ return `${USE_SECURE_COOKIES ? "__Secure-" : ""}devtrack-${type}-token`;
+}
+
+export function createAccessToken({
+ githubId,
+ githubLogin,
+}: {
+ githubId: string;
+ githubLogin: string;
+}) {
+ const now = Math.floor(Date.now() / 1000);
+ return createJwt(
+ {
+ type: "access",
+ githubId,
+ githubLogin,
+ iat: now,
+ exp: now + ACCESS_TOKEN_MAX_AGE,
+ },
+ getAuthTokenSecret()
+ );
+}
+
+export function createRefreshToken({
+ githubId,
+ githubLogin,
+}: {
+ githubId: string;
+ githubLogin: string;
+}) {
+ const now = Math.floor(Date.now() / 1000);
+ return createJwt(
+ {
+ type: "refresh",
+ githubId,
+ githubLogin,
+ iat: now,
+ exp: now + REFRESH_TOKEN_MAX_AGE,
+ },
+ getAuthTokenSecret()
+ );
+}
+
+export function verifyAccessToken(token: string) {
+ const payload = verifyJwt(token, getAuthTokenSecret()) as AccessTokenPayload;
+ if (payload.type !== "access") {
+ throw new Error("Invalid access token");
+ }
+ return payload;
+}
+
+export function verifyRefreshToken(token: string) {
+ const payload = verifyJwt(token, getAuthTokenSecret()) as RefreshTokenPayload;
+ if (payload.type !== "refresh") {
+ throw new Error("Invalid refresh token");
+ }
+ return payload;
+}