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; +}