diff --git a/apps/nextjs/src/app/page.tsx b/apps/nextjs/src/app/page.tsx index 9ece3691..99e16353 100644 --- a/apps/nextjs/src/app/page.tsx +++ b/apps/nextjs/src/app/page.tsx @@ -6,14 +6,19 @@ const getSession = async () => { const headersList = new Headers(await headers()) const session = await fetch("http://localhost:3000/auth/session", { headers: headersList, + cache: "no-store", }) const response = await session.json() return response } const getCSRFToken = async () => { + const cookieStore = await cookies() const csrfResponse = await fetch("http://localhost:3000/auth/csrfToken", { method: "GET", + headers: { + Cookie: cookieStore.toString(), + }, }) const csrfData = await csrfResponse.json() return csrfData.csrfToken @@ -36,13 +41,13 @@ const signOut = async () => { const signOutResponse = await fetch("http://localhost:3000/auth/signOut?token_type_hint=session_token", { method: "POST", headers: headersList, - credentials: "include", body: JSON.stringify({}), + cache: "no-cache", }) const response = await signOutResponse.json() if (signOutResponse.status === 202) { - cookieStore.delete("aura-stack.sessionToken") - cookieStore.delete("aura-stack.csrfToken") + cookieStore.delete("aura-auth.sessionToken") + cookieStore.delete("aura-auth.csrfToken") redirect("/") } return response diff --git a/packages/core/src/@types/index.ts b/packages/core/src/@types/index.ts index 61440de4..247c10ac 100644 --- a/packages/core/src/@types/index.ts +++ b/packages/core/src/@types/index.ts @@ -3,6 +3,7 @@ import type { OAuthIntegrations } from "@/oauth/index.js" import { OAuthAccessTokenErrorResponse, OAuthAuthorizationErrorResponse } from "@/schemas.js" import { SESSION_VERSION } from "@/actions/session/session.js" import { SerializeOptions } from "cookie" +import { createJoseInstance } from "@/jose.js" /** * Standardized user profile returned by OAuth integrations after fetching user information @@ -124,11 +125,19 @@ export interface AuthConfig { * @see https://httpwg.org/http-extensions/draft-ietf-httpbis-rfc6265bis.html#name-the-__host-prefix */ cookies?: CookieOptions + /** + * Secret used to sign and verify JWT tokens for session and csrf protection. + * If not provided, it will load from the environment variable `AURA_AUTH_SECRET`, but if it + * doesn't exist, it will throw an error during the initialization of the Auth module. + */ + secret?: string } export interface AuthConfigInternal { oauth: Record, OAuthSecureConfig> cookies: CookieOptions + secret: string + jose: Awaited> } /** diff --git a/packages/core/src/@types/router.d.ts b/packages/core/src/@types/router.d.ts index 8132943e..37273c29 100644 --- a/packages/core/src/@types/router.d.ts +++ b/packages/core/src/@types/router.d.ts @@ -1,10 +1,12 @@ import { createOAuthIntegrations } from "@/oauth/index.ts" import type { GlobalContext } from "@aura-stack/router" import type { CookieOptions } from "./index.ts" +import { createJoseInstance } from "@/jose.ts" declare module "@aura-stack/router" { interface GlobalContext { oauth: ReturnType cookies: CookieOptions + jose: ReturnType } } diff --git a/packages/core/src/actions/callback/callback.ts b/packages/core/src/actions/callback/callback.ts index 4984bb20..37b5baf4 100644 --- a/packages/core/src/actions/callback/callback.ts +++ b/packages/core/src/actions/callback/callback.ts @@ -1,13 +1,13 @@ import z from "zod" import { createEndpoint, createEndpointConfig, statusCode } from "@aura-stack/router" import { createCSRF } from "@/secure.js" -import { equals, isValidRelativePath, sanitizeURL } from "@/utils.js" import { cacheControl } from "@/headers.js" import { getUserInfo } from "./userinfo.js" import { AuraResponse } from "@/response.js" import { createAccessToken } from "./access-token.js" -import { SESSION_VERSION } from "../session/session.js" +import { SESSION_VERSION } from "@/actions/session/session.js" import { AuthError, ERROR_RESPONSE, isAuthError } from "@/error.js" +import { equals, isValidRelativePath, sanitizeURL } from "@/utils.js" import { OAuthAuthorizationErrorResponse, OAuthAuthorizationResponse } from "@/schemas.js" import { createSessionCookie, expireCookie, getCookie, secureCookieOptions, setCookie } from "@/cookie.js" import type { JWTPayload } from "@/jose.js" @@ -43,7 +43,7 @@ export const callbackAction = (oauth: AuthConfigInternal["oauth"]) => { request, params: { oauth }, searchParams: { code, state }, - context: { oauth: oauthIntegrations, cookies }, + context: { oauth: oauthIntegrations, cookies, jose }, } = ctx try { const oauthConfig = oauthIntegrations[oauth] @@ -77,10 +77,11 @@ export const callbackAction = (oauth: AuthConfigInternal["oauth"]) => { integrations: [oauth], version: SESSION_VERSION, } as never as JWTPayload, - cookieOptions + cookieOptions, + jose ) - const csrfToken = await createCSRF() + const csrfToken = await createCSRF(jose) const csrfCookie = setCookie( "csrfToken", csrfToken, diff --git a/packages/core/src/actions/csrfToken/csrfToken.ts b/packages/core/src/actions/csrfToken/csrfToken.ts index 75284596..36a0a12e 100644 --- a/packages/core/src/actions/csrfToken/csrfToken.ts +++ b/packages/core/src/actions/csrfToken/csrfToken.ts @@ -6,12 +6,12 @@ import { getCookie, secureCookieOptions, setCookie } from "@/cookie.js" export const csrfTokenAction = createEndpoint("GET", "/csrfToken", async (ctx) => { const { request, - context: { cookies }, + context: { cookies, jose }, } = ctx const cookieOptions = secureCookieOptions(request, { ...cookies, flag: "host" }) const existingCSRFToken = getCookie(request, "csrfToken", cookieOptions, true) - const csrfToken = await createCSRF(existingCSRFToken) + const csrfToken = await createCSRF(jose, existingCSRFToken) const headers = new Headers(cacheControl) headers.set("Set-Cookie", setCookie("csrfToken", csrfToken, cookieOptions)) diff --git a/packages/core/src/actions/session/session.ts b/packages/core/src/actions/session/session.ts index f607e222..f4925fbf 100644 --- a/packages/core/src/actions/session/session.ts +++ b/packages/core/src/actions/session/session.ts @@ -1,6 +1,5 @@ import { createEndpoint } from "@aura-stack/router" import { equals } from "@/utils.js" -import { decodeJWT } from "@/jose.js" import { AuthError } from "@/error.js" import { cacheControl } from "@/headers.js" import { expireCookie, getCookie, secureCookieOptions } from "@/cookie.js" @@ -11,12 +10,12 @@ export const SESSION_VERSION = "v0.1.0" export const sessionAction = createEndpoint("GET", "/session", async (ctx) => { const { request, - context: { cookies }, + context: { cookies, jose }, } = ctx const cookieOptions = secureCookieOptions(request, cookies) try { const session = getCookie(request, "sessionToken", cookieOptions) - const decoded = (await decodeJWT(session)) as OAuthUserProfile + const decoded = (await jose.decodeJWT(session)) as OAuthUserProfile const user: OAuthUserProfileInternal = { sub: decoded.sub, email: decoded.email, diff --git a/packages/core/src/actions/signOut/signOut.ts b/packages/core/src/actions/signOut/signOut.ts index fd97f2a4..9c9919d9 100644 --- a/packages/core/src/actions/signOut/signOut.ts +++ b/packages/core/src/actions/signOut/signOut.ts @@ -1,14 +1,13 @@ import z from "zod" import { createEndpoint, createEndpointConfig, statusCode } from "@aura-stack/router" -import { decodeJWT } from "@/jose.js" import { verifyCSRF } from "@/secure.js" import { cacheControl } from "@/headers.js" import { AuraResponse } from "@/response.js" -import { OAuthErrorResponse } from "@/@types/index.js" +import { getNormalizedOriginPath } from "@/utils.js" +import { createRedirectTo } from "@/actions/signIn/authorization.js" import { expireCookie, getCookie, secureCookieOptions } from "@/cookie.js" -import { createRedirectTo } from "../signIn/authorization.js" import { InvalidCsrfTokenError, InvalidRedirectToError } from "@/error.js" -import { getNormalizedOriginPath } from "@/utils.js" +import type { OAuthErrorResponse } from "@/@types/index.js" const config = createEndpointConfig({ schemas: { @@ -30,7 +29,7 @@ export const signOutAction = createEndpoint( request, headers, searchParams: { redirectTo }, - context: { cookies }, + context: { cookies, jose }, } = ctx try { const cookiesOptions = secureCookieOptions(request, cookies) @@ -43,8 +42,8 @@ export const signOutAction = createEndpoint( if (!header || !session || !csrfToken) { throw new Error("Missing CSRF token or session token") } - await verifyCSRF(csrfToken, header) - await decodeJWT(session) + await verifyCSRF(jose, csrfToken, header) + await jose.decodeJWT(session) const normalizedOriginPath = getNormalizedOriginPath(request.url) const location = createRedirectTo( diff --git a/packages/core/src/cookie.ts b/packages/core/src/cookie.ts index fa454462..1168104b 100644 --- a/packages/core/src/cookie.ts +++ b/packages/core/src/cookie.ts @@ -1,8 +1,15 @@ import { parse, serialize, type SerializeOptions } from "cookie" -import { AuthError } from "./error.js" -import { isRequest } from "./assert.js" -import { encodeJWT, type JWTPayload } from "./jose.js" -import type { CookieName, CookieOptions, CookieOptionsInternal, LiteralUnion, StandardCookie } from "@/@types/index.js" +import { AuthError } from "@/error.js" +import { isRequest } from "@/assert.js" +import type { JWTPayload } from "@/jose.js" +import type { + AuthConfigInternal, + CookieName, + CookieOptions, + CookieOptionsInternal, + LiteralUnion, + StandardCookie, +} from "@/@types/index.js" export { parse } from "cookie" @@ -131,9 +138,13 @@ export const getCookie = ( * @param session - The JWT payload to be encoded in the session cookie * @returns The serialized session cookie string */ -export const createSessionCookie = async (session: JWTPayload, cookieOptions: CookieOptionsInternal) => { +export const createSessionCookie = async ( + session: JWTPayload, + cookieOptions: CookieOptionsInternal, + jose: AuthConfigInternal["jose"] +) => { try { - const encoded = await encodeJWT(session) + const encoded = await jose.encodeJWT(session) return setCookie("sessionToken", encoded, cookieOptions) } catch (error) { // @ts-ignore diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b934be3b..8cd82710 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,7 @@ import "dotenv/config" import { createRouter, type RouterConfig } from "@aura-stack/router" import { onErrorHandler } from "./utils.js" +import { createJoseInstance } from "@/jose.js" import { defaultCookieConfig } from "@/cookie.js" import { createOAuthIntegrations } from "@/oauth/index.js" import { signInAction, callbackAction, sessionAction, signOutAction, csrfTokenAction } from "@/actions/index.js" @@ -13,6 +14,7 @@ const createInternalConfig = (authConfig?: AuthConfig): RouterConfig => { context: { oauth: createOAuthIntegrations(authConfig?.oauth), cookies: authConfig?.cookies ?? defaultCookieConfig, + jose: createJoseInstance(authConfig?.secret), }, } } @@ -47,5 +49,6 @@ export const createAuth = (authConfig: AuthConfig) => { ) return { handlers: router, + jose: config.context.jose, } } diff --git a/packages/core/src/jose.ts b/packages/core/src/jose.ts index 9bfc55a9..b759d836 100644 --- a/packages/core/src/jose.ts +++ b/packages/core/src/jose.ts @@ -2,10 +2,18 @@ import "dotenv/config" import { createJWT, createJWS, createDeriveKey } from "@aura-stack/jose" export type { JWTPayload } from "@aura-stack/jose/jose" -const secretKey = process.env.AURA_AUTH_SECRET! +export const createJoseInstance = (secret?: string) => { + secret ??= process.env.AURA_AUTH_SECRET! + const { derivedKey: derivedSessionKey } = createDeriveKey(secret, "session") + const { derivedKey: derivedCsrfTokenKey } = createDeriveKey(secret, "csrfToken") -const { derivedKey: derivedSessionKey } = createDeriveKey(secretKey, "session") -const { derivedKey: derivedCsrfTokenKey } = createDeriveKey(secretKey, "csrfToken") + const { decodeJWT, encodeJWT } = createJWT(derivedSessionKey) + const { signJWS, verifyJWS } = createJWS(derivedCsrfTokenKey) -export const { decodeJWT, encodeJWT } = createJWT(derivedSessionKey) -export const { signJWS, verifyJWS } = createJWS(derivedCsrfTokenKey) + return { + decodeJWT, + encodeJWT, + signJWS, + verifyJWS, + } +} diff --git a/packages/core/src/secure.ts b/packages/core/src/secure.ts index 64f851e8..de48404d 100644 --- a/packages/core/src/secure.ts +++ b/packages/core/src/secure.ts @@ -1,7 +1,7 @@ import crypto from "node:crypto" import { equals } from "./utils.js" -import { signJWS, verifyJWS } from "./jose.js" import { InvalidCsrfTokenError } from "./error.js" +import { AuthConfigInternal } from "./@types/index.js" export const generateSecure = (length: number = 32) => { return crypto.randomBytes(length).toString("base64url") @@ -28,28 +28,28 @@ export const createPKCE = async (verifier?: string) => { /** * Creates a CSRF token to be used in OAuth flows to prevent cross-site request forgery attacks. - * - token: A cryptographically random string that serves as the CSRF token. - * - hash: tuple of the token and its hash for verification and separated by a colon (:). * - * @returns + * @param csrfCookie - Optional existing CSRF cookie to verify and reuse + * @returns Signed CSRF token */ -export const createCSRF = async (csrfCookie?: string) => { +export const createCSRF = async (jose: AuthConfigInternal["jose"], csrfCookie?: string) => { try { const token = generateSecure(32) if (csrfCookie) { - await verifyJWS(csrfCookie) + await jose.verifyJWS(csrfCookie) return csrfCookie } - return signJWS({ token }) + return jose.signJWS({ token }) } catch { const token = generateSecure(32) - return signJWS({ token }) + return jose.signJWS({ token }) } } -export const verifyCSRF = async (cookie: string, header: string): Promise => { + +export const verifyCSRF = async (jose: AuthConfigInternal["jose"], cookie: string, header: string): Promise => { try { - const { token: cookieToken } = await verifyJWS(cookie) - const { token: headerToken } = await verifyJWS(header) + const { token: cookieToken } = await jose.verifyJWS(cookie) + const { token: headerToken } = await jose.verifyJWS(header) const cookieBuffer = Buffer.from(cookieToken as string) const headerBuffer = Buffer.from(headerToken as string) if (!equals(headerBuffer.length, cookieBuffer.length)) { diff --git a/packages/core/test/actions/session/session.test.ts b/packages/core/test/actions/session/session.test.ts index 8c49faac..3506a737 100644 --- a/packages/core/test/actions/session/session.test.ts +++ b/packages/core/test/actions/session/session.test.ts @@ -1,8 +1,10 @@ -import { GET, sessionPayload } from "@test/presets.js" +import { GET, jose, sessionPayload } from "@test/presets.js" import { describe, test, expect, vi } from "vitest" -import { encodeJWT, type JWTPayload } from "@/jose.js" +import { type JWTPayload } from "@/jose.js" describe("sessionAction", () => { + const { encodeJWT } = jose + test("sessionToken cookie not found", async () => { const request = await GET(new Request("https://example.com/auth/session")) expect(request.status).toBe(401) @@ -54,7 +56,7 @@ describe("sessionAction", () => { }) test("expired sessionToken cookie", async () => { - const decodeJWTMock = vi.spyOn(await import("@/jose.js"), "decodeJWT").mockImplementation(() => { + const decodeJWTMock = vi.spyOn(jose, "decodeJWT").mockImplementation(async () => { throw new Error("Token expired") }) diff --git a/packages/core/test/actions/signOut/signOut.test.ts b/packages/core/test/actions/signOut/signOut.test.ts index 5b1b96c3..68f806e9 100644 --- a/packages/core/test/actions/signOut/signOut.test.ts +++ b/packages/core/test/actions/signOut/signOut.test.ts @@ -1,10 +1,10 @@ import { describe, test, expect, vi } from "vitest" -import { encodeJWT } from "@/jose.js" import { createCSRF } from "@/secure.js" -import { POST, sessionPayload } from "@test/presets.js" +import { POST, jose, sessionPayload } from "@test/presets.js" describe("signOut action", async () => { - const csrf = await createCSRF() + const csrf = await createCSRF(jose) + const { encodeJWT } = jose test("sessionToken cookie not present", async () => { const response = await POST( @@ -39,7 +39,7 @@ describe("signOut action", async () => { }) test("expired sessionToken cookie", async () => { - const decodeJWTMock = vi.spyOn(await import("@/jose.js"), "decodeJWT").mockImplementation(() => { + const decodeJWTMock = vi.spyOn(await import("@/jose.js"), "createJoseInstance").mockImplementation(() => { throw new Error("Token expired") }) diff --git a/packages/core/test/presets.ts b/packages/core/test/presets.ts index f6cab43a..df3c51d2 100644 --- a/packages/core/test/presets.ts +++ b/packages/core/test/presets.ts @@ -1,7 +1,7 @@ -import { CookieOptionsInternal, OAuthSecureConfig } from "@/@types/index.js" -import { SESSION_VERSION } from "@/actions/session/session.js" import { createAuth } from "@/index.js" -import { type JWTPayload } from "@/jose.js" +import { SESSION_VERSION } from "@/actions/session/session.js" +import { CookieOptionsInternal, OAuthSecureConfig } from "@/@types/index.js" +import type { JWTPayload } from "@/jose.js" export const oauthCustomService: OAuthSecureConfig = { id: "oauth-integration", @@ -28,7 +28,11 @@ export const secureCookieOptions: CookieOptionsInternal = { secure: true, prefix export const hostCookieOptions: CookieOptionsInternal = { secure: true, prefix: "__Host-" } -export const { GET, POST } = createAuth({ +export const { + handlers: { GET, POST }, + jose, +} = createAuth({ oauth: [oauthCustomService], cookies: {}, -}).handlers + secret: process.env.AURA_AUTH_SECRET, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0975701b..80b476d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2411,6 +2411,7 @@ packages: next@16.0.7: resolution: {integrity: sha512-3mBRJyPxT4LOxAJI6IsXeFtKfiJUbjCLgvXO02fV8Wy/lIhPvP94Fe7dGhUgHXcQy4sSuYwQNcOLhIfOm0rL0A==} engines: {node: '>=20.9.0'} + deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details. hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0