Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions apps/nextjs/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/@types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<LiteralUnion<OAuthIntegrations>, OAuthSecureConfig>
cookies: CookieOptions
secret: string
jose: Awaited<ReturnType<typeof createJoseInstance>>
}

/**
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/@types/router.d.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createOAuthIntegrations>
cookies: CookieOptions
jose: ReturnType<typeof createJoseInstance>
}
}
11 changes: 6 additions & 5 deletions packages/core/src/actions/callback/callback.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/actions/csrfToken/csrfToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
5 changes: 2 additions & 3 deletions packages/core/src/actions/session/session.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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,
Expand Down
13 changes: 6 additions & 7 deletions packages/core/src/actions/signOut/signOut.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -30,7 +29,7 @@ export const signOutAction = createEndpoint(
request,
headers,
searchParams: { redirectTo },
context: { cookies },
context: { cookies, jose },
} = ctx
try {
const cookiesOptions = secureCookieOptions(request, cookies)
Expand All @@ -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(
Expand Down
23 changes: 17 additions & 6 deletions packages/core/src/cookie.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -13,6 +14,7 @@ const createInternalConfig = (authConfig?: AuthConfig): RouterConfig => {
context: {
oauth: createOAuthIntegrations(authConfig?.oauth),
cookies: authConfig?.cookies ?? defaultCookieConfig,
jose: createJoseInstance(authConfig?.secret),
},
}
}
Expand Down Expand Up @@ -47,5 +49,6 @@ export const createAuth = (authConfig: AuthConfig) => {
)
return {
handlers: router,
jose: config.context.jose,
}
}
18 changes: 13 additions & 5 deletions packages/core/src/jose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
22 changes: 11 additions & 11 deletions packages/core/src/secure.ts
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -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<boolean> => {

export const verifyCSRF = async (jose: AuthConfigInternal["jose"], cookie: string, header: string): Promise<boolean> => {
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)) {
Expand Down
8 changes: 5 additions & 3 deletions packages/core/test/actions/session/session.test.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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")
})

Expand Down
8 changes: 4 additions & 4 deletions packages/core/test/actions/signOut/signOut.test.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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")
})

Expand Down
14 changes: 9 additions & 5 deletions packages/core/test/presets.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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,
})
Loading