diff --git a/apps/astro/src/auth.ts b/apps/astro/src/auth.ts index c11a2283..ee77748e 100644 --- a/apps/astro/src/auth.ts +++ b/apps/astro/src/auth.ts @@ -3,7 +3,7 @@ import { builtInOAuthProviders, type BuiltInOAuthProvider } from "@aura-stack/au export const oauth = Object.keys(builtInOAuthProviders) as BuiltInOAuthProvider[] -export const { handlers, jose }: AuthInstance = createAuth({ +export const { handlers, jose, api }: AuthInstance = createAuth({ oauth, basePath: "/api/auth", trustedOrigins: ["https://*.vercel.app"], diff --git a/apps/astro/src/lib/client.ts b/apps/astro/src/lib/client.ts index 79880224..bdcd9c63 100644 --- a/apps/astro/src/lib/client.ts +++ b/apps/astro/src/lib/client.ts @@ -24,7 +24,7 @@ const getSession = async (): Promise => { const response = await client.get("/session") if (!response.ok) return null const session = await response.json() - return session && session?.user ? session : null + return session?.authenticated ? session : null } catch (error) { console.log("[error:client] getSession", error) return null diff --git a/apps/astro/src/lib/server.ts b/apps/astro/src/lib/server.ts index ec360a47..af3feaf6 100644 --- a/apps/astro/src/lib/server.ts +++ b/apps/astro/src/lib/server.ts @@ -1,4 +1,5 @@ import type { AuthServerContext } from "@/@types/types" +import { api } from "@/auth" import { createClient, type Session, type LiteralUnion, type BuiltInOAuthProvider } from "@aura-stack/auth" export const createAuthServer = async (context: AuthServerContext) => { @@ -29,10 +30,11 @@ export const createAuthServer = async (context: AuthServerContext) => { const getSession = async (): Promise => { try { - const response = await client.get("/session") - if (!response.ok) return null - const session = await response.json() - return session && session?.user ? session : null + const session = await api.getSession({ + headers: request.headers, + }) + if (!session.authenticated) return null + return session.session } catch (error) { console.log("[error:server] getSession", error) return null @@ -45,23 +47,12 @@ export const createAuthServer = async (context: AuthServerContext) => { const signOut = async (redirectTo: string = "/") => { try { - const csrfToken = await getCSRFToken() - if (!csrfToken) { - console.log("[error:server] signOut - No CSRF token found") - return null - } - - const response = await client.post("/signOut", { - searchParams: { - redirectTo, - token_type_hint: "session_token", - }, - headers: { - "X-CSRF-Token": csrfToken, - }, + const response = await api.signOut({ + redirectTo, + headers: request.headers, }) if (response.status === 202) { - return redirect(redirectTo) + return response } const json = await response.json() return json diff --git a/apps/bun/src/auth.ts b/apps/bun/src/auth.ts index 3ff255e3..791e04fd 100644 --- a/apps/bun/src/auth.ts +++ b/apps/bun/src/auth.ts @@ -1,6 +1,6 @@ import { type AuthInstance, createAuth } from "@aura-stack/auth" -export const { handlers, jose }: AuthInstance = createAuth({ +export const { handlers, jose, api }: AuthInstance = createAuth({ oauth: ["github"], basePath: "/api/auth", trustedOrigins: ["http://localhost:3000", "https://*.vercel.app"], diff --git a/apps/bun/src/index.ts b/apps/bun/src/index.ts index df7a395d..a5e20a4a 100644 --- a/apps/bun/src/index.ts +++ b/apps/bun/src/index.ts @@ -1,5 +1,4 @@ -import { handlers } from "./auth" -import { getSession } from "./lib/get-session" +import { handlers, api } from "./auth" Bun.serve({ port: 3000, @@ -16,8 +15,10 @@ Bun.serve({ return response }, "/api/protected": async (request) => { - const session = await getSession(request) - if (!session) { + const session = await api.getSession({ + headers: request.headers, + }) + if (!session.authenticated) { return Response.json( { error: "Unauthorized", diff --git a/apps/bun/src/lib/get-session.ts b/apps/bun/src/lib/get-session.ts deleted file mode 100644 index c4f2b0af..00000000 --- a/apps/bun/src/lib/get-session.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { handlers } from "../auth" -import type { Session } from "@aura-stack/auth" - -export const getSession = async (request: Request): Promise => { - try { - const url = new URL(request.url) - url.pathname = "/api/auth/session" - const response = await handlers.GET( - new Request(url.toString(), { - headers: request.headers, - }) - ) - const session = (await response.json()) as Session - return session && session?.user ? session : null - } catch { - return null - } -} diff --git a/apps/cloudflare/src/auth.ts b/apps/cloudflare/src/auth.ts index d3fb0fb2..7720b131 100644 --- a/apps/cloudflare/src/auth.ts +++ b/apps/cloudflare/src/auth.ts @@ -1,6 +1,6 @@ import { type AuthInstance, createAuth } from "@aura-stack/auth" -export const { handlers, jose }: AuthInstance = createAuth({ +export const { handlers, jose, api }: AuthInstance = createAuth({ oauth: ["github"], basePath: "/api/auth", trustedOrigins: ["http://127.0.0.1:8787", "http://localhost:8787"], diff --git a/apps/deno/src/auth.ts b/apps/deno/src/auth.ts index 6ef915df..3bd75418 100644 --- a/apps/deno/src/auth.ts +++ b/apps/deno/src/auth.ts @@ -1,6 +1,6 @@ import { type AuthInstance, createAuth } from "@aura-stack/auth" -export const { handlers, jose }: AuthInstance = createAuth({ +export const { handlers, jose, api }: AuthInstance = createAuth({ oauth: ["github"], basePath: "/api/auth", trustedOrigins: ["http://localhost:3000", "https://*.vercel.app"], diff --git a/apps/deno/src/index.ts b/apps/deno/src/index.ts index e9e11e3c..2e3578ef 100644 --- a/apps/deno/src/index.ts +++ b/apps/deno/src/index.ts @@ -1,5 +1,4 @@ -import { handlers } from "./auth.ts" -import { getSession } from "./lib/get-session.ts" +import { handlers, api } from "./auth.ts" Deno.serve({ port: 3000 }, async (request) => { const pathname = new URL(request.url).pathname @@ -7,8 +6,10 @@ Deno.serve({ port: 3000 }, async (request) => { case "/": return new Response("Welcome to the Aura Stack Deno App!") case "/api/protected": { - const session = await getSession(request) - if (!session) { + const session = await api.getSession({ + headers: request.headers + }) + if (!session.authenticated) { return Response.json( { error: "Unauthorized", diff --git a/apps/deno/src/lib/get-session.ts b/apps/deno/src/lib/get-session.ts deleted file mode 100644 index 0ed3a04d..00000000 --- a/apps/deno/src/lib/get-session.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { handlers } from "../auth.ts" -import type { Session } from "@aura-stack/auth" - -export const getSession = async (request: Request): Promise => { - try { - const url = new URL(request.url) - url.pathname = "/api/auth/session" - const response = await handlers.GET( - new Request(url.toString(), { - headers: request.headers, - }), - ) - const session = (await response.json()) as Session - return session && session?.user ? session : null - } catch { - return null - } -} diff --git a/apps/elysia/src/auth.ts b/apps/elysia/src/auth.ts index 3ff255e3..791e04fd 100644 --- a/apps/elysia/src/auth.ts +++ b/apps/elysia/src/auth.ts @@ -1,6 +1,6 @@ import { type AuthInstance, createAuth } from "@aura-stack/auth" -export const { handlers, jose }: AuthInstance = createAuth({ +export const { handlers, jose, api }: AuthInstance = createAuth({ oauth: ["github"], basePath: "/api/auth", trustedOrigins: ["http://localhost:3000", "https://*.vercel.app"], diff --git a/apps/elysia/src/lib/get-session.ts b/apps/elysia/src/lib/get-session.ts deleted file mode 100644 index a9e4c94a..00000000 --- a/apps/elysia/src/lib/get-session.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Context } from "elysia" -import { handlers } from "../auth" -import type { Session } from "@aura-stack/auth" - -/** - * Retrieves the current session by forwarding the incoming request headers - * to the Aura Auth /api/auth/session handler and parsing the response. - */ -export const getSession = async (ctx: Context): Promise => { - try { - const url = new URL(ctx.request.url) - url.pathname = "/api/auth/session" - const response = await handlers.GET( - new Request(url, { - headers: ctx.request.headers, - }) - ) - if (!response.ok) return null - const session = await response.json() - return session as Session - } catch { - return null - } -} diff --git a/apps/elysia/src/plugins/with-auth.ts b/apps/elysia/src/plugins/with-auth.ts index 4cedfbd4..92e44a4d 100644 --- a/apps/elysia/src/plugins/with-auth.ts +++ b/apps/elysia/src/plugins/with-auth.ts @@ -1,11 +1,13 @@ -import { Elysia, type Context } from "elysia" -import { getSession } from "../lib/get-session" +import { Elysia } from "elysia" +import { api } from "../auth" export const withAuthPlugin = new Elysia({ name: "with-auth" }) .resolve({ as: "scoped" }, async (ctx) => { try { - const session = await getSession(ctx as Context) - if (!session) { + const session = await api.getSession({ + headers: ctx.request.headers, + }) + if (!session!.authenticated) { return { session: null } } return { session } diff --git a/apps/express/src/auth.ts b/apps/express/src/auth.ts index 495266d5..f65e8c76 100644 --- a/apps/express/src/auth.ts +++ b/apps/express/src/auth.ts @@ -3,7 +3,8 @@ import { builtInOAuthProviders, type BuiltInOAuthProvider } from "@aura-stack/au export const oauth = Object.keys(builtInOAuthProviders) as BuiltInOAuthProvider[] -export const { handlers, jose }: AuthInstance = createAuth({ +export const { handlers, jose, api }: AuthInstance = createAuth({ + // Built-in OAuth providers configured. For testing, only GitHub is enabled. oauth: ["github"], basePath: "/api/auth", trustedOrigins: ["http://localhost:3000", "http://localhost:3001", "https://*.vercel.app"], diff --git a/apps/express/src/lib/get-session.ts b/apps/express/src/lib/get-session.ts deleted file mode 100644 index 7a07f6f8..00000000 --- a/apps/express/src/lib/get-session.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { Request } from "express" -import type { Session } from "@aura-stack/auth" -import { jose } from "@/auth.js" - -/** - * Decodes and validates the session JWT from the incoming Express request's cookies. - * Uses the Aura Auth jose instance to verify the token. - */ -export const getSession = async (request: Request): Promise => { - const cookieHeader = request.headers.cookie - if (!cookieHeader) return null - try { - const cookies = Object.fromEntries( - cookieHeader - .split(";") - .map((cookiePair) => cookiePair.trim().split("=")) - .map(([cookieName, ...cookieValueParts]) => [ - decodeURIComponent(cookieName), - decodeURIComponent(cookieValueParts.join("=")), - ]) - ) - - const sessionCookieKey = Object.keys(cookies).find((cookieName) => cookieName.includes("session_token")) - if (!sessionCookieKey) return null - - const token = cookies[sessionCookieKey] - if (!token) return null - - const decoded = await jose.decodeJWT(token) - const { exp, iat, jti, nbf, sub, aud, iss, ...user } = decoded as Record - if (!exp) return null - - return { - user, - expires: new Date(exp! * 1000).toISOString(), - } as Session - } catch { - return null - } -} diff --git a/apps/express/src/lib/verify-session.ts b/apps/express/src/lib/verify-session.ts index 38dbf73a..effee903 100644 --- a/apps/express/src/lib/verify-session.ts +++ b/apps/express/src/lib/verify-session.ts @@ -1,4 +1,5 @@ -import { getSession } from "@/lib/get-session.js" +import { api } from "@/auth.js" +import { toWebRequest } from "@/middlewares/auth.js" import type { Request, Response, NextFunction } from "express" /** @@ -10,13 +11,16 @@ import type { Request, Response, NextFunction } from "express" * }) */ export const verifySession = async (req: Request, res: Response, next: NextFunction) => { - const session = await getSession(req) - if (!session) { + const webRequest = toWebRequest(req) + const session = await api.getSession({ + headers: webRequest.headers, + }) + if (!session.authenticated) { return res.status(401).json({ error: "Unauthorized", message: "You must be signed in to access this resource.", }) } - res.locals.session = session + res.locals.session = session.session return next() } diff --git a/apps/express/test/index.test.ts b/apps/express/test/index.test.ts index 2609e8af..b744aee3 100644 --- a/apps/express/test/index.test.ts +++ b/apps/express/test/index.test.ts @@ -18,7 +18,7 @@ describe("GET /api/auth/session", () => { expect(response.status).toBe(401) expect(response.body).toMatchObject({ authenticated: false, - message: "Unauthorized", + session: null, }) }) @@ -33,10 +33,12 @@ describe("GET /api/auth/session", () => { .set("Cookie", [`aura-auth.session_token=${sessionToken}`]) expect(request.status).toBe(200) expect(request.body).toMatchObject({ - user: { - sub: "johndoe", - name: "John Doe", - email: "johndoe@example.com", + session: { + user: { + sub: "johndoe", + name: "John Doe", + email: "johndoe@example.com", + }, }, }) }) diff --git a/apps/hono/src/auth.ts b/apps/hono/src/auth.ts index 54b1b9fd..7bcc5193 100644 --- a/apps/hono/src/auth.ts +++ b/apps/hono/src/auth.ts @@ -1,6 +1,6 @@ import { AuthInstance, createAuth } from "@aura-stack/auth" -export const { handlers, jose }: AuthInstance = createAuth({ +export const { handlers, jose, api }: AuthInstance = createAuth({ oauth: ["github"], basePath: "/api/auth", trustedOrigins: ["http://localhost:3000", "https://*.vercel.app"], diff --git a/apps/hono/src/lib/get-session.ts b/apps/hono/src/lib/get-session.ts deleted file mode 100644 index 751f425b..00000000 --- a/apps/hono/src/lib/get-session.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { handlers } from "../auth" -import type { Context } from "hono" -import type { Session } from "@aura-stack/auth" - -/** - * Retrieves the current session by forwarding the incoming request headers - * to the Aura Auth /api/auth/session handler and parsing the response. - */ -export const getSession = async (ctx: Context): Promise => { - try { - const url = new URL(ctx.req.url) - url.pathname = "/api/auth/session" - const response = await handlers.GET( - new Request(url, { - headers: ctx.req.raw.headers, - }) - ) - const sessionToken = await response.json() - return sessionToken - } catch { - return null - } -} diff --git a/apps/hono/src/middleware/with-auth.ts b/apps/hono/src/middleware/with-auth.ts index 468ce7c4..94480de2 100644 --- a/apps/hono/src/middleware/with-auth.ts +++ b/apps/hono/src/middleware/with-auth.ts @@ -1,5 +1,5 @@ +import { api } from "../auth" import { createMiddleware } from "hono/factory" -import { getSession } from "../lib/get-session" import type { Session } from "@aura-stack/auth" /** @@ -11,11 +11,13 @@ export type AuthVariables = { export const withAuth = createMiddleware<{ Variables: AuthVariables }>(async (ctx, next) => { try { - const session = await getSession(ctx) - if (!session) { + const session = await api.getSession({ + headers: ctx.req.raw.headers, + }) + if (!session.authenticated) { return ctx.json({ error: "Unauthorized", message: "Active session required." }, 401) } - ctx.set("session", session) + ctx.set("session", session.session) return await next() } catch { return ctx.json({ error: "Unauthorized", message: "Active session required." }, 401) diff --git a/apps/nextjs/app-router/src/app/profile/page.tsx b/apps/nextjs/app-router/src/app/profile/page.tsx new file mode 100644 index 00000000..a8b17978 --- /dev/null +++ b/apps/nextjs/app-router/src/app/profile/page.tsx @@ -0,0 +1,3 @@ +export default function Home() { + return
Profile Redirect Value
+} diff --git a/apps/nextjs/app-router/src/auth.ts b/apps/nextjs/app-router/src/auth.ts index 76de5d31..9e29a369 100644 --- a/apps/nextjs/app-router/src/auth.ts +++ b/apps/nextjs/app-router/src/auth.ts @@ -3,7 +3,7 @@ import { builtInOAuthProviders, type BuiltInOAuthProvider } from "@aura-stack/au export const oauth = Object.keys(builtInOAuthProviders) as BuiltInOAuthProvider[] -export const { handlers, jose }: AuthInstance = createAuth({ +export const { handlers, jose, api }: AuthInstance = createAuth({ oauth, trustedOrigins: ["http://localhost:3000", "https://*.vercel.app"], }) diff --git a/apps/nextjs/app-router/src/lib/client.ts b/apps/nextjs/app-router/src/lib/client.ts index ef56d434..5bf8a18e 100644 --- a/apps/nextjs/app-router/src/lib/client.ts +++ b/apps/nextjs/app-router/src/lib/client.ts @@ -1,3 +1,4 @@ +import { redirect } from "next/navigation" import { createClient, type Session, type LiteralUnion, type BuiltInOAuthProvider } from "@aura-stack/auth" const client = createClient({ @@ -24,7 +25,7 @@ export const getSession = async (): Promise => { const response = await client.get("/session") if (!response.ok) return null const session = await response.json() - return session && session?.user ? session : null + return session?.authenticated ? session : null } catch (error) { console.log("[error:client] getSession", error) return null @@ -32,10 +33,7 @@ export const getSession = async (): Promise => { } export const signIn = async (provider: LiteralUnion, redirectTo: string = "/") => { - await client.get("/signIn/:oauth", { - params: { oauth: provider }, - searchParams: { redirectTo }, - }) + return redirect(`/auth/signIn/${provider}?redirectTo=${encodeURIComponent(redirectTo)}`) } export const signOut = async (redirectTo: string = "/") => { diff --git a/apps/nextjs/app-router/src/lib/server.ts b/apps/nextjs/app-router/src/lib/server.ts index 26408766..a5fe9f40 100644 --- a/apps/nextjs/app-router/src/lib/server.ts +++ b/apps/nextjs/app-router/src/lib/server.ts @@ -3,6 +3,7 @@ import { redirect } from "next/navigation" import { cookies, headers } from "next/headers" import { createClient, type Session, type LiteralUnion, type BuiltInOAuthProvider } from "@aura-stack/auth" +import { api } from "@/auth" const toHeaders = (incoming: Awaited>) => { return Object.fromEntries(incoming.entries()) @@ -34,16 +35,16 @@ export const getCSRFToken = async (): Promise => { } export const getSession = async (): Promise => { - "use server" try { - const response = await client.get("/session", { - headers: toHeaders(await headers()), + const session = await api.getSession({ + headers: await headers(), }) - if (!response.ok) return null - const session = await response.json() - return session && session?.user ? session : null - } catch (error) { - console.log("[error:server] getSession", error) + if (!session.authenticated) { + return null + } + return session.session + } catch { + console.log("[error:server] getSession - Failed to retrieve session") return null } } @@ -55,27 +56,15 @@ export const signIn = async (provider: LiteralUnion, redir export const signOut = async (redirectTo: string = "/") => { try { - const cookiesStore = await cookies() - const csrfToken = await getCSRFToken() - if (!csrfToken) { - console.log("[error:server] signOut - No CSRF token") - return null - } - const response = await client.post("/signOut", { - searchParams: { - redirectTo, - token_type_hint: "session_token", - }, - headers: { - ...toHeaders(await headers()), - "X-CSRF-Token": csrfToken, - }, + const cookieStore = await cookies() + const response = await api.signOut({ + headers: await headers(), }) if (response.status === 202) { const setCookies = response.headers.getSetCookie() for (const cookie of setCookies) { const nameMatch = cookie.match(/^([^=]+)=/) - nameMatch && cookiesStore.delete(nameMatch[1]) + nameMatch && cookieStore.delete(nameMatch[1]) } redirect(redirectTo) } diff --git a/apps/nextjs/pages-router/src/auth.ts b/apps/nextjs/pages-router/src/auth.ts index 4099e22b..d605f985 100644 --- a/apps/nextjs/pages-router/src/auth.ts +++ b/apps/nextjs/pages-router/src/auth.ts @@ -3,7 +3,7 @@ import { builtInOAuthProviders, type BuiltInOAuthProvider } from "@aura-stack/au export const oauth = Object.keys(builtInOAuthProviders) as BuiltInOAuthProvider[] -export const { handlers, jose }: AuthInstance = createAuth({ +export const { handlers, jose, api }: AuthInstance = createAuth({ oauth, basePath: "/api/auth", }) diff --git a/apps/nextjs/pages-router/src/lib/server.ts b/apps/nextjs/pages-router/src/lib/server.ts deleted file mode 100644 index e6daeafb..00000000 --- a/apps/nextjs/pages-router/src/lib/server.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { NextApiRequest } from "next" -import type { Session } from "@aura-stack/auth" -import type { IncomingMessage } from "http" -import { client } from "./client.api" - -/** - * Standard server-side auth function to retrieve the current session. - * Compatible with getServerSideProps and API routes. - */ -export async function getSession(req: IncomingMessage | NextApiRequest): Promise { - try { - const response = await client.get("/session", { - headers: req.headers as Record, - }) - if (!response.ok) { - return null - } - const session = await response.json() - return session && session?.user ? session : null - } catch (error) { - return null - } -} - -export const createAuthServer = { - getSession, -} diff --git a/apps/nextjs/pages-router/src/pages/index.tsx b/apps/nextjs/pages-router/src/pages/index.tsx index b40069ec..5be595ec 100644 --- a/apps/nextjs/pages-router/src/pages/index.tsx +++ b/apps/nextjs/pages-router/src/pages/index.tsx @@ -2,17 +2,19 @@ import type { GetServerSideProps, InferGetServerSidePropsType } from "next" import { Session } from "@aura-stack/auth" import { Fingerprint, LayoutDashboard } from "lucide-react" import { builtInOAuthProviders } from "@aura-stack/auth/oauth/index" -import { createAuthServer } from "@/lib/server" import { Button } from "@/components/ui/button" import { SessionClient } from "@/components/get-session-client" import { useAuthClient } from "@/contexts/auth" +import { api } from "@/auth" const providers = [builtInOAuthProviders.github(), builtInOAuthProviders.gitlab(), builtInOAuthProviders.bitbucket()] export const getServerSideProps: GetServerSideProps<{ session: Session | null }> = async ({ req }) => { - const session = await createAuthServer.getSession(req) + const session = await api.getSession({ + headers: req.headers as Record, + }) return { - props: { session }, + props: { session: session.authenticated ? session.session : null }, } } diff --git a/apps/oak/src/auth.ts b/apps/oak/src/auth.ts index 6ef915df..3bd75418 100644 --- a/apps/oak/src/auth.ts +++ b/apps/oak/src/auth.ts @@ -1,6 +1,6 @@ import { type AuthInstance, createAuth } from "@aura-stack/auth" -export const { handlers, jose }: AuthInstance = createAuth({ +export const { handlers, jose, api }: AuthInstance = createAuth({ oauth: ["github"], basePath: "/api/auth", trustedOrigins: ["http://localhost:3000", "https://*.vercel.app"], diff --git a/apps/oak/src/lib/get-session.ts b/apps/oak/src/lib/get-session.ts deleted file mode 100644 index 8d0bb50a..00000000 --- a/apps/oak/src/lib/get-session.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { RouterContext } from "@oak/oak" -import { handlers } from "../auth.ts" -import type { Session } from "@aura-stack/auth" - -/** - * Retrieves the current session by forwarding the incoming request headers - * to the Aura Auth /api/auth/session handler and parsing the response. - */ -export const getSession = async (ctx: RouterContext): Promise => { - try { - const url = new URL(ctx.request.url) - url.pathname = "/api/auth/session" - const response = await handlers.GET( - new Request(url, { - headers: ctx.request.headers, - }), - ) - const sessionToken = await response.json() - return sessionToken - } catch { - return null - } -} diff --git a/apps/oak/src/middleware/with-auth.ts b/apps/oak/src/middleware/with-auth.ts index 1b628dd1..968cf8a3 100644 --- a/apps/oak/src/middleware/with-auth.ts +++ b/apps/oak/src/middleware/with-auth.ts @@ -1,4 +1,4 @@ -import { getSession } from "../lib/get-session.ts" +import { api } from "@/auth.ts" import type { Session } from "@aura-stack/auth" import type { Next, RouteParams, RouterContext } from "@oak/oak" @@ -19,13 +19,15 @@ export type RouterContextWithState(ctx: RouterContextWithState, next: Next) => { try { - const session = await getSession(ctx) - if (!session) { + const session = await api.getSession({ + headers: ctx.request.headers + }) + if (!session.authenticated) { ctx.response.status = 401 ctx.response.body = unauthorizedBody return } - ctx.state.session = session + ctx.state.session = session.session return await next() } catch { ctx.response.status = 401 diff --git a/apps/react-router/app/actions/auth.client.ts b/apps/react-router/app/actions/auth.client.ts index 9e2ad769..54b74223 100644 --- a/apps/react-router/app/actions/auth.client.ts +++ b/apps/react-router/app/actions/auth.client.ts @@ -22,7 +22,7 @@ export const getSession = async (): Promise => { try { const response = await client.get("/session") const session = await response.json() - return session && session?.user ? session : null + return session?.authenticated ? session : null } catch (error) { console.log("[error:client] getSession", error) return null diff --git a/apps/react-router/app/actions/auth.server.ts b/apps/react-router/app/actions/auth.server.ts index d53cf3c7..bef9edc9 100644 --- a/apps/react-router/app/actions/auth.server.ts +++ b/apps/react-router/app/actions/auth.server.ts @@ -1,5 +1,6 @@ import { redirect } from "react-router" import { createClient, type Session } from "@aura-stack/auth" +import { api } from "~/auth" const client = (request: Request) => { const baseURL = new URL(request.url).origin @@ -32,10 +33,11 @@ export const getCSRFToken = async (request: Request): Promise => export const getSession = async (request: Request): Promise => { try { - const response = await client(request).get("/session") - if (!response.ok) return null - const session = await response.json() - return session && session?.user ? session : null + const session = await api.getSession({ + headers: request.headers, + }) + if (!session.authenticated) return null + return session.session } catch (error) { console.log("[error:server] getSession", error) return null @@ -48,19 +50,9 @@ export const signIn = async (providerId: string) => { export const signOut = async (request: Request, redirectTo: string = "/") => { try { - const csrfToken = await getCSRFToken(request) - if (!csrfToken) { - console.error("[error:server] signOut - No CSRF token") - return null - } - const response = await client(request).post("/signOut", { - searchParams: { - redirectTo, - token_type_hint: "session_token", - }, - headers: { - "X-CSRF-Token": csrfToken, - }, + const response = await api.signOut({ + redirectTo, + headers: request.headers, }) if (response.status === 202) { return redirect(redirectTo, { diff --git a/apps/react-router/app/auth.ts b/apps/react-router/app/auth.ts index 1d424707..22ea851a 100644 --- a/apps/react-router/app/auth.ts +++ b/apps/react-router/app/auth.ts @@ -3,7 +3,7 @@ import { builtInOAuthProviders, type BuiltInOAuthProvider } from "@aura-stack/au export const oauth = Object.keys(builtInOAuthProviders) as BuiltInOAuthProvider[] -export const { handlers } = createAuth({ +export const { handlers, jose, api }: AuthInstance = createAuth({ oauth, trustedProxyHeaders: true, }) diff --git a/apps/supabase/functions/_shared/auth.ts b/apps/supabase/functions/_shared/auth.ts index d4b67ed4..d644323a 100644 --- a/apps/supabase/functions/_shared/auth.ts +++ b/apps/supabase/functions/_shared/auth.ts @@ -1,6 +1,6 @@ import { type AuthInstance, createAuth } from "@aura-stack/auth" -export const { handlers, jose }: AuthInstance = createAuth({ +export const { handlers, jose, api }: AuthInstance = createAuth({ oauth: ["github"], basePath: "/api/auth", trustedOrigins: ["http://localhost:3000", "http://127.0.0.1:3000"], diff --git a/apps/supabase/functions/_shared/get-session.ts b/apps/supabase/functions/_shared/get-session.ts deleted file mode 100644 index 3b802497..00000000 --- a/apps/supabase/functions/_shared/get-session.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { handlers } from "./auth.ts" -import type { Session } from "@aura-stack/auth" - -export const getSession = async (request: Request): Promise => { - try { - const url = new URL(request.url) - url.pathname = "/api/auth/session" - const response = await handlers.GET( - new Request(url.toString(), { - headers: request.headers, - }) - ) - if(!response.ok) return null - const session = (await response.json()) as Session - return session && session?.user ? session : null - } catch { - return null - } -} diff --git a/apps/supabase/functions/auth/index.ts b/apps/supabase/functions/auth/index.ts index 6cddca28..47d2e982 100644 --- a/apps/supabase/functions/auth/index.ts +++ b/apps/supabase/functions/auth/index.ts @@ -1,7 +1,6 @@ -import { handlers } from "../_shared/auth.ts" -import { getSession } from "../_shared/get-session.ts" +import { handlers, api } from "../_shared/auth.ts" -// Follow this setup guide to integrate the Deno language server with your editor: +// Follow this setup guide to integrate the Deno language api with your editor: // https://deno.land/manual/getting_started/setup_your_environment // This enables autocomplete, go to definition, etc. import "@supabase/functions-js/edge-runtime.d.ts" @@ -12,8 +11,10 @@ Deno.serve(async (request) => { case "/": return new Response("Welcome to the Aura Auth Supabase App!") case "/api/protected": { - const session = await getSession(request) - if (!session) { + const session = await api.getSession({ + headers: request.headers, + }) + if (!session.authenticated) { return Response.json( { error: "Unauthorized", @@ -24,7 +25,7 @@ Deno.serve(async (request) => { } return Response.json({ message: "You have access to this protected resource.", - session, + session: session.session, }) } default: { diff --git a/apps/tanstack-start/src/auth.ts b/apps/tanstack-start/src/auth.ts index 0a256a82..22ea851a 100644 --- a/apps/tanstack-start/src/auth.ts +++ b/apps/tanstack-start/src/auth.ts @@ -3,7 +3,7 @@ import { builtInOAuthProviders, type BuiltInOAuthProvider } from "@aura-stack/au export const oauth = Object.keys(builtInOAuthProviders) as BuiltInOAuthProvider[] -export const { handlers, jose }: AuthInstance = createAuth({ +export const { handlers, jose, api }: AuthInstance = createAuth({ oauth, trustedProxyHeaders: true, }) diff --git a/apps/tanstack-start/src/lib/auth-client.ts b/apps/tanstack-start/src/lib/auth-client.ts index a5efc63f..f743b1a5 100644 --- a/apps/tanstack-start/src/lib/auth-client.ts +++ b/apps/tanstack-start/src/lib/auth-client.ts @@ -22,7 +22,7 @@ export const getSession = async (): Promise => { try { const response = await client.get("/session") const session = await response.json() - return session && session?.user ? session : null + return session?.authenticated ? session : null } catch (error) { console.log("[error:client] getSession", error) return null diff --git a/apps/tanstack-start/src/lib/auth-server.ts b/apps/tanstack-start/src/lib/auth-server.ts index 197e6875..bac98960 100644 --- a/apps/tanstack-start/src/lib/auth-server.ts +++ b/apps/tanstack-start/src/lib/auth-server.ts @@ -38,7 +38,7 @@ export const getSession = createServerFn({ method: "GET" }).handler(async () => const response = await client().get("/session") if (!response.ok) return null const session = await response.json() - return session && session?.user ? session : null + return session?.authenticated ? session : null } catch (error) { console.log("[error:server] getSession", error) return null diff --git a/packages/core/.vscode/settings.json b/packages/core/.vscode/settings.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/core/.vscode/settings.json @@ -0,0 +1 @@ +{} diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 813f18f3..05c285e2 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- Introduced an `api` object via `createAuth` for retrieving and signing out sessions on the server without calling mounted endpoints, improving resource access in trusted environments. [#112](https://github.com/aura-stack-ts/auth/pull/112) + - Added the `Dropbox` OAuth provider to the supported integrations in Aura Auth. [#59](https://github.com/aura-stack-ts/auth/pull/59) - Added the `Notion` OAuth provider to the supported integrations in Aura Auth. [#49](https://github.com/aura-stack-ts/auth/pull/49) diff --git a/packages/core/src/@types/index.ts b/packages/core/src/@types/index.ts index c2000b03..8cacc48f 100644 --- a/packages/core/src/@types/index.ts +++ b/packages/core/src/@types/index.ts @@ -5,6 +5,7 @@ import { createJoseInstance, type JWTPayload } from "@/jose.ts" import type { SerializeOptions } from "@aura-stack/router/cookie" import type { BuiltInOAuthProvider } from "@/oauth/index.ts" import type { LiteralUnion, Prettify } from "@/@types/utility.ts" +import { createAPI } from "@/api/createApi.ts" export * from "./utility.ts" export type { BuiltInOAuthProvider } from "@/oauth/index.ts" @@ -266,6 +267,12 @@ export type InternalLogger = { log: typeof createLogEntry } +export type SessionResponse = { session: Session; authenticated: true } | { session: null; authenticated: false } + +export type GetSessionAPI = (options: { headers: HeadersInit }) => Promise + +export type AuthAPI = ReturnType + export interface RouterGlobalContext { oauth: OAuthProviderRecord cookies: CookieStoreConfig @@ -289,6 +296,7 @@ export interface AuthInstance { POST: (request: Request) => Response | Promise } jose: JoseInstance + api: AuthAPI } /** diff --git a/packages/core/src/actions/session/session.ts b/packages/core/src/actions/session/session.ts index ab3c5757..b60903b3 100644 --- a/packages/core/src/actions/session/session.ts +++ b/packages/core/src/actions/session/session.ts @@ -1,26 +1,24 @@ import { createEndpoint, HeadersBuilder } from "@aura-stack/router" import { secureApiHeaders } from "@/headers.ts" -import { getErrorName, toISOString } from "@/utils.ts" -import { expiredCookieAttributes, getCookie } from "@/cookie.ts" -import type { JWTStandardClaims, Session, User } from "@/@types/index.ts" +import { expiredCookieAttributes } from "@/cookie.ts" +import { AuthInternalError } from "@/errors.ts" +import { getSession } from "@/api/getSession.ts" export const sessionAction = createEndpoint("GET", "/session", async (ctx) => { const { request, - context: { jose, cookies, logger }, + context: { cookies }, } = ctx try { - const session = getCookie(request, cookies.sessionToken.name) - const decoded = await jose.decodeJWT(session) - logger?.log("AUTH_SESSION_VALID") - const { exp, iat, jti, nbf, ...user } = decoded as User & JWTStandardClaims - const headers = new Headers(secureApiHeaders) - return Response.json({ user, expires: toISOString(exp! * 1000) } as Session, { headers }) + const session = await getSession({ ctx: ctx.context, headers: request.headers }) + if (!session.authenticated) { + throw new AuthInternalError("INVALID_JWT_TOKEN", "Session not authenticated") + } + return Response.json(session, { headers: secureApiHeaders }) } catch (error) { - logger?.log("AUTH_SESSION_INVALID", { structuredData: { error_type: getErrorName(error) } }) const headers = new HeadersBuilder(secureApiHeaders) .setCookie(cookies.sessionToken.name, "", expiredCookieAttributes) .toHeaders() - return Response.json({ authenticated: false, message: "Unauthorized" }, { status: 401, headers }) + return Response.json({ session: null, authenticated: false }, { status: 401, headers }) } }) diff --git a/packages/core/src/actions/signOut/signOut.ts b/packages/core/src/actions/signOut/signOut.ts index f340c57f..91c81f32 100644 --- a/packages/core/src/actions/signOut/signOut.ts +++ b/packages/core/src/actions/signOut/signOut.ts @@ -1,10 +1,7 @@ import { z } from "zod/v4" -import { createEndpoint, createEndpointConfig, HeadersBuilder, statusCode } from "@aura-stack/router" -import { verifyCSRF } from "@/secure.ts" -import { secureApiHeaders } from "@/headers.ts" -import { AuthSecurityError } from "@/errors.ts" -import { getBaseURL, getErrorName } from "@/utils.ts" -import { expiredCookieAttributes } from "@/cookie.ts" +import { createEndpoint, createEndpointConfig } from "@aura-stack/router" +import { getBaseURL } from "@/utils.ts" +import { signOut } from "@/api/signOut.ts" import { createRedirectTo } from "@/actions/signIn/authorization.ts" const config = createEndpointConfig({ @@ -25,64 +22,24 @@ export const signOutAction = createEndpoint( async (ctx) => { const { request, - headers, searchParams: { redirectTo }, context, } = ctx - const { jose, cookies, logger } = context - const session = headers.getCookie(cookies.sessionToken.name) - const csrfToken = headers.getCookie(cookies.csrfToken.name) - const header = headers.getHeader("X-CSRF-Token") - - logger?.log("SIGN_OUT_ATTEMPT", { - structuredData: { - has_session: Boolean(session), - has_csrf_token: Boolean(csrfToken), - has_csrf_header: Boolean(header), - }, - }) - - if (!session) { - logger?.log("SESSION_TOKEN_MISSING") - throw new AuthSecurityError("SESSION_TOKEN_MISSING", "The sessionToken is missing.") - } - if (!csrfToken) { - logger?.log("CSRF_TOKEN_MISSING") - throw new AuthSecurityError("CSRF_TOKEN_MISSING", "The CSRF token is missing.") - } - if (!header) { - logger?.log("CSRF_HEADER_MISSING") - throw new AuthSecurityError("CSRF_HEADER_MISSING", "The CSRF header is missing.") - } - try { - await verifyCSRF(jose, csrfToken, header) - } catch (error) { - logger?.log("CSRF_TOKEN_INVALID", { structuredData: { error_type: getErrorName(error) } }) - throw new AuthSecurityError("CSRF_TOKEN_INVALID", "CSRF token verification failed") - } - logger?.log("SIGN_OUT_CSRF_VERIFIED") - try { - await jose.decodeJWT(session) - logger?.log("SIGN_OUT_SUCCESS") - } catch (error) { - logger?.log("INVALID_JWT_TOKEN", { structuredData: { error_type: getErrorName(error) } }) - } const baseURL = getBaseURL(request) const location = await createRedirectTo( new Request(baseURL, { - headers: headers.toHeaders(), + headers: request.headers, }), redirectTo, context ) - logger?.log("SIGN_OUT_REDIRECT", { structuredData: { location } }) - const headersList = new HeadersBuilder(secureApiHeaders) - .setHeader("Location", location) - .setCookie(cookies.csrfToken.name, "", expiredCookieAttributes) - .setCookie(cookies.sessionToken.name, "", expiredCookieAttributes) - .toHeaders() - return Response.json({ message: "Signed out successfully" }, { status: statusCode.ACCEPTED, headers: headersList }) + + return await signOut({ + ctx: context, + headers: request.headers, + redirectTo: location, + }) }, config ) diff --git a/packages/core/src/api/createApi.ts b/packages/core/src/api/createApi.ts new file mode 100644 index 00000000..4070db39 --- /dev/null +++ b/packages/core/src/api/createApi.ts @@ -0,0 +1,23 @@ +import { signOut } from "@/api/signOut.ts" +import { getSession } from "@/api/getSession.ts" +import type { GlobalContext } from "@aura-stack/router" +import type { SessionResponse } from "@/@types/index.ts" +import { validateRedirectTo } from "@/utils.ts" + +export interface APIOptions { + headers: HeadersInit + redirectTo?: string +} + +export const createAPI = (ctx: GlobalContext) => { + return { + getSession: async ({ headers }: { headers: HeadersInit }): Promise => { + const session = await getSession({ ctx, headers }) + return session + }, + signOut: async (options: APIOptions) => { + const redirectTo = validateRedirectTo(options.redirectTo ?? "/") + return signOut({ ctx, headers: options.headers, redirectTo, skipCSRFCheck: true }) + }, + } +} diff --git a/packages/core/src/api/getSession.ts b/packages/core/src/api/getSession.ts new file mode 100644 index 00000000..9033da62 --- /dev/null +++ b/packages/core/src/api/getSession.ts @@ -0,0 +1,23 @@ +import { getCookie } from "@/cookie.ts" +import { getErrorName, toISOString } from "@/utils.ts" +import type { GlobalContext } from "@aura-stack/router/types" +import type { JWTStandardClaims, SessionResponse, User } from "@/@types/index.ts" + +export const getSession = async ({ ctx, headers }: { ctx: GlobalContext; headers: HeadersInit }): Promise => { + try { + const session = getCookie(new Headers(headers), ctx.cookies.sessionToken.name) + const decoded = await ctx.jose.decodeJWT(session) + ctx?.logger?.log("AUTH_SESSION_VALID") + const { exp, iat, jti, nbf, ...user } = decoded as User & JWTStandardClaims + return { + session: { + user, + expires: toISOString(exp! * 1000), + }, + authenticated: true, + } + } catch (error) { + ctx?.logger?.log("AUTH_SESSION_INVALID", { structuredData: { error_type: getErrorName(error) } }) + return { session: null, authenticated: false } + } +} diff --git a/packages/core/src/api/signOut.ts b/packages/core/src/api/signOut.ts new file mode 100644 index 00000000..e3ec837c --- /dev/null +++ b/packages/core/src/api/signOut.ts @@ -0,0 +1,87 @@ +import { expiredCookieAttributes, getCookie } from "@/cookie.ts" +import { verifyCSRF } from "@/secure.ts" +import { getErrorName } from "@/utils.ts" +import { AuthSecurityError } from "@/errors.ts" +import { secureApiHeaders } from "@/headers.ts" +import { HeadersBuilder, type GlobalContext } from "@aura-stack/router" + +export type SignOutOptions = { + ctx: GlobalContext + headers: HeadersInit + redirectTo?: string +} + +export type SignOutWithCSRFOptions = SignOutOptions & { + skipCSRFCheck?: boolean +} + +export const signOut = async ({ ctx, headers: headersInit, redirectTo = "/", skipCSRFCheck = false }: SignOutWithCSRFOptions) => { + const headers = new Headers(headersInit) + const header = headers.get("X-CSRF-Token") + let session = null + let csrfToken = null + try { + session = getCookie(headers, ctx.cookies.sessionToken.name) + } catch { + throw new AuthSecurityError("SESSION_TOKEN_MISSING", "The sessionToken is missing.") + } + try { + csrfToken = getCookie(headers, ctx.cookies.csrfToken.name) + } catch { + throw new AuthSecurityError("CSRF_TOKEN_MISSING", "The CSRF token is missing.") + } + ctx?.logger?.log("SIGN_OUT_ATTEMPT", { + structuredData: { + has_session: Boolean(session), + has_csrf_token: Boolean(csrfToken), + has_csrf_header: Boolean(header), + skip_csrf_check: skipCSRFCheck, + }, + }) + if (!session) { + ctx?.logger?.log("SESSION_TOKEN_MISSING") + throw new AuthSecurityError("SESSION_TOKEN_MISSING", "The sessionToken is missing.") + } + if (!skipCSRFCheck) { + if (!csrfToken) { + ctx?.logger?.log("CSRF_TOKEN_MISSING") + throw new AuthSecurityError("CSRF_TOKEN_MISSING", "The CSRF token is missing.") + } + if (!header) { + ctx?.logger?.log("CSRF_HEADER_MISSING") + throw new AuthSecurityError("CSRF_HEADER_MISSING", "The CSRF header is missing.") + } + try { + await verifyCSRF(ctx.jose, csrfToken, header) + } catch (error) { + ctx?.logger?.log("CSRF_TOKEN_INVALID", { structuredData: { error_type: getErrorName(error) } }) + throw new AuthSecurityError("CSRF_TOKEN_INVALID", "CSRF token verification failed") + } + ctx?.logger?.log("SIGN_OUT_CSRF_VERIFIED") + } else { + try { + await ctx.jose.verifyJWS(csrfToken) + } catch (error) { + ctx?.logger?.log("CSRF_TOKEN_INVALID", { structuredData: { error_type: getErrorName(error) } }) + throw new AuthSecurityError("CSRF_TOKEN_INVALID", "CSRF token verification failed") + } + } + try { + await ctx.jose.decodeJWT(session) + ctx?.logger?.log("SIGN_OUT_SUCCESS") + } catch (error) { + ctx?.logger?.log("INVALID_JWT_TOKEN", { structuredData: { error_type: getErrorName(error) } }) + } + const headersList = new HeadersBuilder(secureApiHeaders) + .setHeader("Location", redirectTo) + .setCookie(ctx.cookies.csrfToken.name, "", expiredCookieAttributes) + .setCookie(ctx.cookies.sessionToken.name, "", expiredCookieAttributes) + .toHeaders() + return Response.json( + { message: "Signed out successfully" }, + { + status: 202, + headers: headersList, + } + ) +} diff --git a/packages/core/src/context.ts b/packages/core/src/context.ts new file mode 100644 index 00000000..18bee04b --- /dev/null +++ b/packages/core/src/context.ts @@ -0,0 +1,37 @@ +import { createJoseInstance } from "@/jose.ts" +import { createProxyLogger } from "@/logger.ts" +import { createCookieStore } from "@/cookie.ts" +import { getEnv, getEnvArray, getEnvBoolean } from "@/env.ts" +import { createBuiltInOAuthProviders } from "@/oauth/index.ts" +import type { AuthConfig, CookieStoreConfig } from "@/@types/index.ts" +import type { GlobalContext } from "@aura-stack/router" + +export type InternalContext = GlobalContext & { + cookieConfig: { + secure: CookieStoreConfig + standard: CookieStoreConfig + } +} + +export const createContext = (config?: AuthConfig): InternalContext => { + const trustedProxyHeadersEnv = getEnv("TRUSTED_PROXY_HEADERS") + const useProxyHeaders = + trustedProxyHeadersEnv === undefined ? (config?.trustedProxyHeaders ?? false) : getEnvBoolean("TRUSTED_PROXY_HEADERS") + const logger = createProxyLogger(config) + const cookiePrefix = config?.cookies?.prefix + const cookieOverrides = config?.cookies?.overrides ?? {} + const secureCookieStore = createCookieStore(true, cookiePrefix, cookieOverrides, logger) + const standardCookieStore = createCookieStore(false, cookiePrefix, cookieOverrides, logger) + + return { + oauth: createBuiltInOAuthProviders(config?.oauth), + cookies: standardCookieStore, + jose: createJoseInstance(config?.secret), + secret: config?.secret, + basePath: config?.basePath ?? "/auth", + trustedProxyHeaders: useProxyHeaders, + trustedOrigins: getEnvArray("TRUSTED_ORIGINS").length > 0 ? getEnvArray("TRUSTED_ORIGINS") : config?.trustedOrigins, + logger, + cookieConfig: { secure: secureCookieStore, standard: standardCookieStore }, + } +} diff --git a/packages/core/src/cookie.ts b/packages/core/src/cookie.ts index 468a73e2..acae273c 100644 --- a/packages/core/src/cookie.ts +++ b/packages/core/src/cookie.ts @@ -79,8 +79,8 @@ export const expiredCookieAttributes: SerializeOptions = { * @param cookie Cookie name to retrieve * @returns The value of the cookie or throw an error if not found */ -export const getCookie = (request: Request, cookieName: string) => { - const cookies = request.headers.get("Cookie") +export const getCookie = (request: Request | Headers, cookieName: string) => { + const cookies = request instanceof Request ? request.headers.get("Cookie") : request.get("Cookie") if (!cookies) { throw new AuthInternalError("COOKIE_NOT_FOUND", "No cookies found. There is no active session") } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c8de6f16..90eb401c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,9 +1,6 @@ import { createRouter, type RouterConfig } from "@aura-stack/router" -import { createJoseInstance } from "@/jose.ts" -import { createCookieStore } from "@/cookie.ts" -import { createProxyLogger } from "@/logger.ts" -import { getEnv, getEnvArray, getEnvBoolean } from "@/env.ts" -import { createBuiltInOAuthProviders } from "@/oauth/index.ts" +import { createContext } from "@/context.ts" +import { createAPI } from "@/api/createApi.ts" import { createErrorHandler, useSecureCookies } from "@/utils.ts" import { signInAction, callbackAction, sessionAction, signOutAction, csrfTokenAction } from "@/actions/index.ts" import type { AuthConfig } from "@/@types/index.ts" @@ -29,33 +26,15 @@ export type { export { createClient, type AuthClient, type Client, type ClientOptions } from "@/client.ts" const createInternalConfig = (authConfig?: AuthConfig): RouterConfig => { - const trustedProxyHeadersEnv = getEnv("TRUSTED_PROXY_HEADERS") - const useProxyHeaders = - trustedProxyHeadersEnv === undefined ? (authConfig?.trustedProxyHeaders ?? false) : getEnvBoolean("TRUSTED_PROXY_HEADERS") - const logger = createProxyLogger(authConfig) - const cookiePrefix = authConfig?.cookies?.prefix - const cookieOverrides = authConfig?.cookies?.overrides ?? {} - const secureCookieStore = createCookieStore(true, cookiePrefix, cookieOverrides, logger) - const standardCookieStore = createCookieStore(false, cookiePrefix, cookieOverrides, logger) - + const context = createContext(authConfig) return { basePath: authConfig?.basePath ?? "/auth", - onError: createErrorHandler(logger), - context: { - oauth: createBuiltInOAuthProviders(authConfig?.oauth), - cookies: standardCookieStore, - jose: createJoseInstance(authConfig?.secret), - secret: authConfig?.secret, - basePath: authConfig?.basePath ?? "/auth", - trustedProxyHeaders: useProxyHeaders, - trustedOrigins: - getEnvArray("TRUSTED_ORIGINS").length > 0 ? getEnvArray("TRUSTED_ORIGINS") : authConfig?.trustedOrigins, - logger, - }, + onError: createErrorHandler(context?.logger), + context, use: [ (ctx) => { const useSecure = useSecureCookies(ctx.request, ctx.context.trustedProxyHeaders) - ctx.context.cookies = useSecure ? secureCookieStore : standardCookieStore + ctx.context.cookies = useSecure ? context.cookieConfig.secure : context.cookieConfig.standard return ctx }, ], @@ -94,5 +73,6 @@ export const createAuth = (authConfig: AuthConfig) => { return { handlers: router, jose: config.context.jose, + api: createAPI(config.context), } } diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 134de83c..efd84fa3 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -3,6 +3,7 @@ import { AuthInternalError, isAuthInternalError, isAuthSecurityError, isOAuthPro import type { ZodError } from "zod" import type { APIErrorMap, InternalLogger } from "@/@types/index.ts" import { getEnv } from "./env.ts" +import { isRelativeURL, isValidURL } from "./assert.ts" export const AURA_AUTH_VERSION = "0.4.0" @@ -113,12 +114,14 @@ export const toISOString = (date: Date | string | number): string => { return new Date(date).toISOString() } -export const useSecureCookies = (request: Request, trustedProxyHeaders: boolean): boolean => { +export const useSecureCookies = (request: Request | Headers, trustedProxyHeaders: boolean): boolean => { + const headers = request instanceof Headers ? request : request.headers + const url = request instanceof Headers ? null : request.url return trustedProxyHeaders - ? request.url.startsWith("https://") || - request.headers.get("X-Forwarded-Proto") === "https" || - (request.headers.get("Forwarded")?.includes("proto=https") ?? false) - : request.url.startsWith("https://") + ? url?.startsWith("https://") || + headers.get("X-Forwarded-Proto") === "https" || + (headers.get("Forwarded")?.includes("proto=https") ?? false) + : (url?.startsWith("https://") ?? false) } export const formatZodError = = Record>(error: ZodError): APIErrorMap => { @@ -166,3 +169,14 @@ export const createBasicAuthHeader = (username: string, password: string): strin const credentials = `${getUsername}:${getPassword}` return `Basic ${btoa(credentials)}` } + +/** + * Validates and sanitizes redirect URLs to prevent open redirect attacks. + * Only relative URLs (starting with /) are allowed; absolute URLs are + * rejected and replaced with "/" to enforce same-origin redirects. + */ +export const validateRedirectTo = (url: string): string => { + if (!isRelativeURL(url) && !isValidURL(url)) return "/" + if (isRelativeURL(url)) return url + return "/" +} diff --git a/packages/core/test/actions/session/session.test.ts b/packages/core/test/actions/session/session.test.ts index af756ef0..63362230 100644 --- a/packages/core/test/actions/session/session.test.ts +++ b/packages/core/test/actions/session/session.test.ts @@ -15,7 +15,7 @@ describe("sessionAction", () => { test("sessionToken cookie not found", async () => { const request = await GET(new Request("https://example.com/auth/session")) expect(request.status).toBe(401) - expect(await request.json()).toEqual({ authenticated: false, message: "Unauthorized" }) + expect(await request.json()).toEqual({ authenticated: false, session: null }) }) test("invalid sessionToken cookie", async () => { @@ -27,7 +27,7 @@ describe("sessionAction", () => { }) ) expect(request.status).toBe(401) - expect(await request.json()).toEqual({ authenticated: false, message: "Unauthorized" }) + expect(await request.json()).toEqual({ authenticated: false, session: null }) }) test("valid sessionToken cookie with correct version", async () => { @@ -41,7 +41,27 @@ describe("sessionAction", () => { }) ) expect(request.status).toBe(200) - expect(await request.json()).toEqual({ user: sessionPayload, expires: expect.any(String) }) + expect(await request.json()).toEqual({ + authenticated: true, + session: { user: sessionPayload, expires: expect.any(String) }, + }) + }) + + test("valid sessionToken cookie in insecure connection", async () => { + const sessionToken = await encodeJWT(sessionPayload) + + const request = await GET( + new Request("http://example.com/auth/session", { + headers: { + Cookie: `aura-auth.session_token=${sessionToken}`, + }, + }) + ) + expect(request.status).toBe(200) + expect(await request.json()).toEqual({ + authenticated: true, + session: { user: sessionPayload, expires: expect.any(String) }, + }) }) test("expired sessionToken cookie", async () => { @@ -58,7 +78,7 @@ describe("sessionAction", () => { }) ) expect(request.status).toBe(401) - expect(await request.json()).toEqual({ authenticated: false, message: "Unauthorized" }) + expect(await request.json()).toEqual({ authenticated: false, session: null }) decodeJWTMock.mockRestore() }) @@ -195,8 +215,11 @@ describe("sessionAction", () => { const session = await requestSession.json() const { id, ...rest } = userInfoMock expect(session).toEqual({ - user: { sub: id, ...rest }, - expires: expect.any(String), + authenticated: true, + session: { + user: { sub: id, ...rest }, + expires: expect.any(String), + }, }) }) }) diff --git a/packages/core/test/cookie.test.ts b/packages/core/test/cookie.test.ts index e0394e48..708a8826 100644 --- a/packages/core/test/cookie.test.ts +++ b/packages/core/test/cookie.test.ts @@ -116,6 +116,13 @@ describe("getCookie", () => { }) expect(getSetCookie(response, cookieStore.sessionToken.name)).toBe("sessionValue") }) + + test("getCookie from headers object", () => { + const sessionCookie = setCookie(cookieStore.sessionToken.name, "sessionValue") + const headers = new Headers() + headers.set("Cookie", sessionCookie) + expect(getCookie(headers, cookieStore.sessionToken.name)).toBe("sessionValue") + }) }) describe("defineSecureCookieOptions", () => {