From a98e23be26ef761b38e3ac8973a60d5201203ce3 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Tue, 3 Mar 2026 15:38:28 -0500 Subject: [PATCH 1/8] feat(core): introduce server API via createAuth --- apps/bun/src/lib/get-session.ts | 2 +- packages/core/.vscode/settings.json | 1 + packages/core/src/@types/index.ts | 7 ++++ packages/core/src/actions/session/session.ts | 21 +++++----- packages/core/src/context.ts | 38 +++++++++++++++++++ packages/core/src/index.ts | 35 ++++------------- packages/core/src/server/create-server.ts | 28 ++++++++++++++ .../core/test/actions/session/session.test.ts | 35 ++++++++++++++--- 8 files changed, 121 insertions(+), 46 deletions(-) create mode 100644 packages/core/.vscode/settings.json create mode 100644 packages/core/src/context.ts create mode 100644 packages/core/src/server/create-server.ts diff --git a/apps/bun/src/lib/get-session.ts b/apps/bun/src/lib/get-session.ts index c4f2b0af..41577354 100644 --- a/apps/bun/src/lib/get-session.ts +++ b/apps/bun/src/lib/get-session.ts @@ -1,5 +1,5 @@ import { handlers } from "../auth" -import type { Session } from "@aura-stack/auth" +import { type Session } from "@aura-stack/auth" export const getSession = async (request: Request): Promise => { try { 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/src/@types/index.ts b/packages/core/src/@types/index.ts index c2000b03..168702a9 100644 --- a/packages/core/src/@types/index.ts +++ b/packages/core/src/@types/index.ts @@ -266,6 +266,12 @@ export type InternalLogger = { log: typeof createLogEntry } +export type SessionResponse = { session: Session; authenticated: true } | { session: null; authenticated: false } + +export interface AuthServerAPI { + getSession: (request: Request) => Promise +} + export interface RouterGlobalContext { oauth: OAuthProviderRecord cookies: CookieStoreConfig @@ -275,6 +281,7 @@ export interface RouterGlobalContext { trustedProxyHeaders: boolean trustedOrigins?: TrustedOrigin[] | ((request: Request) => Promise | TrustedOrigin[]) logger?: InternalLogger + server: AuthServerAPI } /** diff --git a/packages/core/src/actions/session/session.ts b/packages/core/src/actions/session/session.ts index ab3c5757..5fdd292a 100644 --- a/packages/core/src/actions/session/session.ts +++ b/packages/core/src/actions/session/session.ts @@ -1,26 +1,23 @@ 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" export const sessionAction = createEndpoint("GET", "/session", async (ctx) => { const { request, - context: { jose, cookies, logger }, + context: { server, 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 server.getSession(request) + 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/context.ts b/packages/core/src/context.ts new file mode 100644 index 00000000..f3d9d4aa --- /dev/null +++ b/packages/core/src/context.ts @@ -0,0 +1,38 @@ +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 & { + cookieCofig: { + 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: secureCookieStore, + 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, + cookieCofig: { secure: secureCookieStore, standard: standardCookieStore }, + server: undefined as any, + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c8de6f16..d9c8e25d 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 { createServerAPI } from "@/server/create-server.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,16 @@ 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) + context.server = createServerAPI(context) 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.cookieCofig.secure : context.cookieCofig.standard return ctx }, ], @@ -94,5 +74,6 @@ export const createAuth = (authConfig: AuthConfig) => { return { handlers: router, jose: config.context.jose, + server: config.context.server } } diff --git a/packages/core/src/server/create-server.ts b/packages/core/src/server/create-server.ts new file mode 100644 index 00000000..f91e483e --- /dev/null +++ b/packages/core/src/server/create-server.ts @@ -0,0 +1,28 @@ +import { getCookie } from "../cookie.ts" +import { getErrorName, toISOString } from "../utils.ts" +import type { GlobalContext } from "@aura-stack/router" +import type { JWTStandardClaims, SessionResponse, User } from "@/@types/index.ts" + +export const createServerAPI = (ctx: Omit) => { + return { + getSession: async (request: Request): Promise => { + const { cookies, jose, logger } = 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 + return { + session: { + user, + expires: toISOString(exp! * 1000), + }, + authenticated: true, + } + } catch (error) { + logger?.log("AUTH_SESSION_INVALID", { structuredData: { error_type: getErrorName(error) } }) + return { session: null, authenticated: false } + } + }, + } +} 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), + }, }) }) }) From 8297c895943827dfd5dd7c4f59a6eaa6e7779ede Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Tue, 3 Mar 2026 15:49:53 -0500 Subject: [PATCH 2/8] chore(examples): implement `server.getSession` function --- apps/bun/src/auth.ts | 2 +- apps/bun/src/index.ts | 7 ++-- apps/bun/src/lib/get-session.ts | 18 --------- apps/deno/src/auth.ts | 2 +- apps/deno/src/index.ts | 3 +- apps/deno/src/lib/get-session.ts | 18 --------- apps/elysia/src/auth.ts | 2 +- apps/elysia/src/lib/get-session.ts | 24 ----------- apps/elysia/src/plugins/with-auth.ts | 8 ++-- apps/express/src/auth.ts | 4 +- apps/express/src/lib/get-session.ts | 40 ------------------- apps/express/src/lib/verify-session.ts | 10 +++-- apps/hono/src/auth.ts | 2 +- apps/hono/src/lib/get-session.ts | 23 ----------- apps/hono/src/middleware/with-auth.ts | 8 ++-- apps/oak/src/auth.ts | 2 +- apps/oak/src/lib/get-session.ts | 23 ----------- apps/oak/src/middleware/with-auth.ts | 7 ++-- apps/supabase/functions/_shared/auth.ts | 2 +- .../supabase/functions/_shared/get-session.ts | 19 --------- apps/supabase/functions/auth/index.ts | 9 ++--- packages/core/src/@types/index.ts | 1 + packages/core/src/index.ts | 2 +- 23 files changed, 35 insertions(+), 201 deletions(-) delete mode 100644 apps/bun/src/lib/get-session.ts delete mode 100644 apps/deno/src/lib/get-session.ts delete mode 100644 apps/elysia/src/lib/get-session.ts delete mode 100644 apps/express/src/lib/get-session.ts delete mode 100644 apps/hono/src/lib/get-session.ts delete mode 100644 apps/oak/src/lib/get-session.ts delete mode 100644 apps/supabase/functions/_shared/get-session.ts diff --git a/apps/bun/src/auth.ts b/apps/bun/src/auth.ts index 3ff255e3..ed29ea51 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, server }: 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..c11d2c58 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, server } from "./auth" Bun.serve({ port: 3000, @@ -16,8 +15,8 @@ Bun.serve({ return response }, "/api/protected": async (request) => { - const session = await getSession(request) - if (!session) { + const session = await server.getSession(request) + 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 41577354..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/deno/src/auth.ts b/apps/deno/src/auth.ts index 6ef915df..7a9103c2 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, server }: 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..e9822087 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" Deno.serve({ port: 3000 }, async (request) => { const pathname = new URL(request.url).pathname @@ -7,7 +6,7 @@ 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) + const session = await server.getSession(request) if (!session) { return Response.json( { 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..ed29ea51 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, server }: 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..c62e9a7e 100644 --- a/apps/elysia/src/plugins/with-auth.ts +++ b/apps/elysia/src/plugins/with-auth.ts @@ -1,11 +1,11 @@ -import { Elysia, type Context } from "elysia" -import { getSession } from "../lib/get-session" +import { Elysia } from "elysia" +import { server } 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 server.getSession(ctx.request) + if (!session!.authenticated) { return { session: null } } return { session } diff --git a/apps/express/src/auth.ts b/apps/express/src/auth.ts index 495266d5..0a066e9b 100644 --- a/apps/express/src/auth.ts +++ b/apps/express/src/auth.ts @@ -3,8 +3,8 @@ import { builtInOAuthProviders, type BuiltInOAuthProvider } from "@aura-stack/au export const oauth = Object.keys(builtInOAuthProviders) as BuiltInOAuthProvider[] -export const { handlers, jose }: AuthInstance = createAuth({ - oauth: ["github"], +export const { handlers, jose, server }: AuthInstance = createAuth({ + oauth, 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..e9f94b38 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 { server } from "@/auth.js" +import { toWebRequest } from "@/middlewares/auth.js" import type { Request, Response, NextFunction } from "express" /** @@ -10,13 +11,14 @@ 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 server.getSession(webRequest) + 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/hono/src/auth.ts b/apps/hono/src/auth.ts index 54b1b9fd..d26efd29 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, server }: 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..62442909 100644 --- a/apps/hono/src/middleware/with-auth.ts +++ b/apps/hono/src/middleware/with-auth.ts @@ -1,5 +1,5 @@ +import { server } from "../auth" import { createMiddleware } from "hono/factory" -import { getSession } from "../lib/get-session" import type { Session } from "@aura-stack/auth" /** @@ -11,11 +11,11 @@ export type AuthVariables = { export const withAuth = createMiddleware<{ Variables: AuthVariables }>(async (ctx, next) => { try { - const session = await getSession(ctx) - if (!session) { + const session = await server.getSession(ctx.req.raw) + 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/oak/src/auth.ts b/apps/oak/src/auth.ts index 6ef915df..7a9103c2 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, server }: 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..60a0938f 100644 --- a/apps/oak/src/middleware/with-auth.ts +++ b/apps/oak/src/middleware/with-auth.ts @@ -1,4 +1,3 @@ -import { getSession } from "../lib/get-session.ts" import type { Session } from "@aura-stack/auth" import type { Next, RouteParams, RouterContext } from "@oak/oak" @@ -19,13 +18,13 @@ export type RouterContextWithState(ctx: RouterContextWithState, next: Next) => { try { - const session = await getSession(ctx) - if (!session) { + const session = await server.getSession(ctx.request) + 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/supabase/functions/_shared/auth.ts b/apps/supabase/functions/_shared/auth.ts index d4b67ed4..dcedcee5 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, server }: 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..d113e436 100644 --- a/apps/supabase/functions/auth/index.ts +++ b/apps/supabase/functions/auth/index.ts @@ -1,5 +1,4 @@ -import { handlers } from "../_shared/auth.ts" -import { getSession } from "../_shared/get-session.ts" +import { handlers, server } from "../_shared/auth.ts" // Follow this setup guide to integrate the Deno language server with your editor: // https://deno.land/manual/getting_started/setup_your_environment @@ -12,8 +11,8 @@ 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 server.getSession(request) + if (!session.user) { return Response.json( { error: "Unauthorized", @@ -24,7 +23,7 @@ Deno.serve(async (request) => { } return Response.json({ message: "You have access to this protected resource.", - session, + session: session.session, }) } default: { diff --git a/packages/core/src/@types/index.ts b/packages/core/src/@types/index.ts index 168702a9..51496d0a 100644 --- a/packages/core/src/@types/index.ts +++ b/packages/core/src/@types/index.ts @@ -296,6 +296,7 @@ export interface AuthInstance { POST: (request: Request) => Response | Promise } jose: JoseInstance + server: AuthServerAPI } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d9c8e25d..e8710a88 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -74,6 +74,6 @@ export const createAuth = (authConfig: AuthConfig) => { return { handlers: router, jose: config.context.jose, - server: config.context.server + server: config.context.server, } } From acaa8044f6ed9d1586841565ae96daa7e9203a1c Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Wed, 4 Mar 2026 08:19:26 -0500 Subject: [PATCH 3/8] chore: apply coderabbit --- apps/deno/src/index.ts | 4 ++-- apps/express/src/auth.ts | 3 ++- apps/express/test/index.test.ts | 12 +++++++----- apps/nextjs/app-router/src/lib/client.ts | 6 ++---- apps/oak/src/middleware/with-auth.ts | 1 + apps/supabase/functions/auth/index.ts | 2 +- 6 files changed, 15 insertions(+), 13 deletions(-) diff --git a/apps/deno/src/index.ts b/apps/deno/src/index.ts index e9822087..87830cac 100644 --- a/apps/deno/src/index.ts +++ b/apps/deno/src/index.ts @@ -1,4 +1,4 @@ -import { handlers } from "./auth.ts" +import { handlers, server } from "./auth.ts" Deno.serve({ port: 3000 }, async (request) => { const pathname = new URL(request.url).pathname @@ -7,7 +7,7 @@ Deno.serve({ port: 3000 }, async (request) => { return new Response("Welcome to the Aura Stack Deno App!") case "/api/protected": { const session = await server.getSession(request) - if (!session) { + if (!session.authenticated) { return Response.json( { error: "Unauthorized", diff --git a/apps/express/src/auth.ts b/apps/express/src/auth.ts index 0a066e9b..5bd2eab5 100644 --- a/apps/express/src/auth.ts +++ b/apps/express/src/auth.ts @@ -4,7 +4,8 @@ import { builtInOAuthProviders, type BuiltInOAuthProvider } from "@aura-stack/au export const oauth = Object.keys(builtInOAuthProviders) as BuiltInOAuthProvider[] export const { handlers, jose, server }: AuthInstance = createAuth({ - oauth, + // 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/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/nextjs/app-router/src/lib/client.ts b/apps/nextjs/app-router/src/lib/client.ts index ef56d434..e68b7e19 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({ @@ -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/oak/src/middleware/with-auth.ts b/apps/oak/src/middleware/with-auth.ts index 60a0938f..c9601642 100644 --- a/apps/oak/src/middleware/with-auth.ts +++ b/apps/oak/src/middleware/with-auth.ts @@ -1,3 +1,4 @@ +import { server } from "@/auth.ts" import type { Session } from "@aura-stack/auth" import type { Next, RouteParams, RouterContext } from "@oak/oak" diff --git a/apps/supabase/functions/auth/index.ts b/apps/supabase/functions/auth/index.ts index d113e436..b6ffe4b5 100644 --- a/apps/supabase/functions/auth/index.ts +++ b/apps/supabase/functions/auth/index.ts @@ -12,7 +12,7 @@ Deno.serve(async (request) => { return new Response("Welcome to the Aura Auth Supabase App!") case "/api/protected": { const session = await server.getSession(request) - if (!session.user) { + if (!session.authenticated) { return Response.json( { error: "Unauthorized", From 0d96bd736a18cc66c7c1f6d2fcb463013192c9bc Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Thu, 5 Mar 2026 19:13:28 -0500 Subject: [PATCH 4/8] feat(core): expose getSession and signOut APIs --- packages/core/src/@types/index.ts | 10 +-- packages/core/src/actions/session/session.ts | 5 +- .../src/actions/signIn/authorization-url.ts | 2 +- packages/core/src/actions/signOut/signOut.ts | 63 +++----------- packages/core/src/api/createApi.ts | 26 ++++++ packages/core/src/api/getSession.ts | 23 ++++++ packages/core/src/api/signOut.ts | 82 +++++++++++++++++++ packages/core/src/context.ts | 7 +- packages/core/src/cookie.ts | 4 +- packages/core/src/index.ts | 7 +- packages/core/src/server/create-server.ts | 28 ------- packages/core/src/utils.ts | 19 +++-- packages/core/test/cookie.test.ts | 7 ++ 13 files changed, 179 insertions(+), 104 deletions(-) create mode 100644 packages/core/src/api/createApi.ts create mode 100644 packages/core/src/api/getSession.ts create mode 100644 packages/core/src/api/signOut.ts delete mode 100644 packages/core/src/server/create-server.ts diff --git a/packages/core/src/@types/index.ts b/packages/core/src/@types/index.ts index 51496d0a..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" @@ -268,9 +269,9 @@ export type InternalLogger = { export type SessionResponse = { session: Session; authenticated: true } | { session: null; authenticated: false } -export interface AuthServerAPI { - getSession: (request: Request) => Promise -} +export type GetSessionAPI = (options: { headers: HeadersInit }) => Promise + +export type AuthAPI = ReturnType export interface RouterGlobalContext { oauth: OAuthProviderRecord @@ -281,7 +282,6 @@ export interface RouterGlobalContext { trustedProxyHeaders: boolean trustedOrigins?: TrustedOrigin[] | ((request: Request) => Promise | TrustedOrigin[]) logger?: InternalLogger - server: AuthServerAPI } /** @@ -296,7 +296,7 @@ export interface AuthInstance { POST: (request: Request) => Response | Promise } jose: JoseInstance - server: AuthServerAPI + api: AuthAPI } /** diff --git a/packages/core/src/actions/session/session.ts b/packages/core/src/actions/session/session.ts index 5fdd292a..b60903b3 100644 --- a/packages/core/src/actions/session/session.ts +++ b/packages/core/src/actions/session/session.ts @@ -2,14 +2,15 @@ import { createEndpoint, HeadersBuilder } from "@aura-stack/router" import { secureApiHeaders } from "@/headers.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: { server, cookies }, + context: { cookies }, } = ctx try { - const session = await server.getSession(request) + const session = await getSession({ ctx: ctx.context, headers: request.headers }) if (!session.authenticated) { throw new AuthInternalError("INVALID_JWT_TOKEN", "Session not authenticated") } diff --git a/packages/core/src/actions/signIn/authorization-url.ts b/packages/core/src/actions/signIn/authorization-url.ts index 8a9929a2..4e254285 100644 --- a/packages/core/src/actions/signIn/authorization-url.ts +++ b/packages/core/src/actions/signIn/authorization-url.ts @@ -78,4 +78,4 @@ export const createAuthorizationURL = async (oauth: OAuthProvider, redirectURI: codeVerifier, method, } -} +} \ No newline at end of file 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..5559ef08 --- /dev/null +++ b/packages/core/src/api/createApi.ts @@ -0,0 +1,26 @@ +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: ctx as GlobalContext, headers }) + return session + }, + signOut: async (options: APIOptions) => { + const redirectTo = validateRedirectTo(options.redirectTo ?? "/") + return signOut({ ctx: ctx as GlobalContext, headers: options.headers, redirectTo, skipCSRFCheck: true }) + }, + signIn: async () => { + return Response.redirect("/sign-in", 302) + }, + } +} 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..8ab9bee8 --- /dev/null +++ b/packages/core/src/api/signOut.ts @@ -0,0 +1,82 @@ +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 { + await ctx.jose.verifyJWS(csrfToken) + } + 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 index f3d9d4aa..18bee04b 100644 --- a/packages/core/src/context.ts +++ b/packages/core/src/context.ts @@ -7,7 +7,7 @@ import type { AuthConfig, CookieStoreConfig } from "@/@types/index.ts" import type { GlobalContext } from "@aura-stack/router" export type InternalContext = GlobalContext & { - cookieCofig: { + cookieConfig: { secure: CookieStoreConfig standard: CookieStoreConfig } @@ -25,14 +25,13 @@ export const createContext = (config?: AuthConfig): InternalContext => { return { oauth: createBuiltInOAuthProviders(config?.oauth), - cookies: secureCookieStore, + 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, - cookieCofig: { secure: secureCookieStore, standard: standardCookieStore }, - server: undefined as any, + 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 e8710a88..90eb401c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,6 @@ import { createRouter, type RouterConfig } from "@aura-stack/router" import { createContext } from "@/context.ts" -import { createServerAPI } from "@/server/create-server.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" @@ -27,7 +27,6 @@ export { createClient, type AuthClient, type Client, type ClientOptions } from " const createInternalConfig = (authConfig?: AuthConfig): RouterConfig => { const context = createContext(authConfig) - context.server = createServerAPI(context) return { basePath: authConfig?.basePath ?? "/auth", onError: createErrorHandler(context?.logger), @@ -35,7 +34,7 @@ const createInternalConfig = (authConfig?: AuthConfig): RouterConfig => { use: [ (ctx) => { const useSecure = useSecureCookies(ctx.request, ctx.context.trustedProxyHeaders) - ctx.context.cookies = useSecure ? context.cookieCofig.secure : context.cookieCofig.standard + ctx.context.cookies = useSecure ? context.cookieConfig.secure : context.cookieConfig.standard return ctx }, ], @@ -74,6 +73,6 @@ export const createAuth = (authConfig: AuthConfig) => { return { handlers: router, jose: config.context.jose, - server: config.context.server, + api: createAPI(config.context), } } diff --git a/packages/core/src/server/create-server.ts b/packages/core/src/server/create-server.ts deleted file mode 100644 index f91e483e..00000000 --- a/packages/core/src/server/create-server.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { getCookie } from "../cookie.ts" -import { getErrorName, toISOString } from "../utils.ts" -import type { GlobalContext } from "@aura-stack/router" -import type { JWTStandardClaims, SessionResponse, User } from "@/@types/index.ts" - -export const createServerAPI = (ctx: Omit) => { - return { - getSession: async (request: Request): Promise => { - const { cookies, jose, logger } = 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 - return { - session: { - user, - expires: toISOString(exp! * 1000), - }, - authenticated: true, - } - } catch (error) { - logger?.log("AUTH_SESSION_INVALID", { structuredData: { error_type: getErrorName(error) } }) - return { session: null, authenticated: false } - } - }, - } -} diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 134de83c..e3273b95 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,9 @@ export const createBasicAuthHeader = (username: string, password: string): strin const credentials = `${getUsername}:${getPassword}` return `Basic ${btoa(credentials)}` } + +export const validateRedirectTo = (url: string): string => { + if (!isRelativeURL(url) && !isValidURL(url)) return "/" + if (isRelativeURL(url)) return url + return "/" +} 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", () => { From 6c37f55ac51258a40e675b9ffa6084e0a9885cb1 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Thu, 5 Mar 2026 19:35:36 -0500 Subject: [PATCH 5/8] chore(examples): implement api.getSession and api.signOut --- apps/astro/src/auth.ts | 2 +- apps/astro/src/lib/server.ts | 27 +++++--------- apps/bun/src/auth.ts | 2 +- apps/bun/src/index.ts | 6 ++- apps/cloudflare/src/auth.ts | 2 +- apps/deno/src/auth.ts | 2 +- apps/deno/src/index.ts | 6 ++- apps/elysia/src/auth.ts | 2 +- apps/elysia/src/plugins/with-auth.ts | 6 ++- apps/express/src/auth.ts | 2 +- apps/express/src/lib/verify-session.ts | 6 ++- apps/hono/src/auth.ts | 2 +- apps/hono/src/middleware/with-auth.ts | 6 ++- apps/nextjs/app-router/src/app/page.tsx | 2 +- .../app-router/src/app/profile/page.tsx | 3 ++ apps/nextjs/app-router/src/auth.ts | 2 +- apps/nextjs/app-router/src/lib/client.ts | 1 + apps/nextjs/app-router/src/lib/server.ts | 37 +++++++------------ apps/nextjs/pages-router/src/auth.ts | 2 +- apps/nextjs/pages-router/src/lib/server.ts | 19 ++++------ apps/oak/src/auth.ts | 2 +- apps/oak/src/middleware/with-auth.ts | 6 ++- apps/react-router/app/actions/auth.server.ts | 26 +++++-------- apps/react-router/app/auth.ts | 2 +- apps/supabase/functions/_shared/auth.ts | 2 +- apps/supabase/functions/auth/index.ts | 8 ++-- apps/tanstack-start/src/auth.ts | 2 +- .../src/actions/signIn/authorization-url.ts | 2 +- packages/core/src/api/createApi.ts | 3 -- 29 files changed, 86 insertions(+), 104 deletions(-) create mode 100644 apps/nextjs/app-router/src/app/profile/page.tsx 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/server.ts b/apps/astro/src/lib/server.ts index ec360a47..3133b89d 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,20 +47,9 @@ 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) diff --git a/apps/bun/src/auth.ts b/apps/bun/src/auth.ts index ed29ea51..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, server }: 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 c11d2c58..a5e20a4a 100644 --- a/apps/bun/src/index.ts +++ b/apps/bun/src/index.ts @@ -1,4 +1,4 @@ -import { handlers, server } from "./auth" +import { handlers, api } from "./auth" Bun.serve({ port: 3000, @@ -15,7 +15,9 @@ Bun.serve({ return response }, "/api/protected": async (request) => { - const session = await server.getSession(request) + const session = await api.getSession({ + headers: request.headers, + }) if (!session.authenticated) { return Response.json( { 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 7a9103c2..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, server }: 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 87830cac..2e3578ef 100644 --- a/apps/deno/src/index.ts +++ b/apps/deno/src/index.ts @@ -1,4 +1,4 @@ -import { handlers, server } from "./auth.ts" +import { handlers, api } from "./auth.ts" Deno.serve({ port: 3000 }, async (request) => { const pathname = new URL(request.url).pathname @@ -6,7 +6,9 @@ Deno.serve({ port: 3000 }, async (request) => { case "/": return new Response("Welcome to the Aura Stack Deno App!") case "/api/protected": { - const session = await server.getSession(request) + const session = await api.getSession({ + headers: request.headers + }) if (!session.authenticated) { return Response.json( { diff --git a/apps/elysia/src/auth.ts b/apps/elysia/src/auth.ts index ed29ea51..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, server }: 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/plugins/with-auth.ts b/apps/elysia/src/plugins/with-auth.ts index c62e9a7e..92e44a4d 100644 --- a/apps/elysia/src/plugins/with-auth.ts +++ b/apps/elysia/src/plugins/with-auth.ts @@ -1,10 +1,12 @@ import { Elysia } from "elysia" -import { server } from "../auth" +import { api } from "../auth" export const withAuthPlugin = new Elysia({ name: "with-auth" }) .resolve({ as: "scoped" }, async (ctx) => { try { - const session = await server.getSession(ctx.request) + const session = await api.getSession({ + headers: ctx.request.headers, + }) if (!session!.authenticated) { return { session: null } } diff --git a/apps/express/src/auth.ts b/apps/express/src/auth.ts index 5bd2eab5..f65e8c76 100644 --- a/apps/express/src/auth.ts +++ b/apps/express/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, server }: 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", diff --git a/apps/express/src/lib/verify-session.ts b/apps/express/src/lib/verify-session.ts index e9f94b38..effee903 100644 --- a/apps/express/src/lib/verify-session.ts +++ b/apps/express/src/lib/verify-session.ts @@ -1,4 +1,4 @@ -import { server } from "@/auth.js" +import { api } from "@/auth.js" import { toWebRequest } from "@/middlewares/auth.js" import type { Request, Response, NextFunction } from "express" @@ -12,7 +12,9 @@ import type { Request, Response, NextFunction } from "express" */ export const verifySession = async (req: Request, res: Response, next: NextFunction) => { const webRequest = toWebRequest(req) - const session = await server.getSession(webRequest) + const session = await api.getSession({ + headers: webRequest.headers, + }) if (!session.authenticated) { return res.status(401).json({ error: "Unauthorized", diff --git a/apps/hono/src/auth.ts b/apps/hono/src/auth.ts index d26efd29..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, server }: 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/middleware/with-auth.ts b/apps/hono/src/middleware/with-auth.ts index 62442909..94480de2 100644 --- a/apps/hono/src/middleware/with-auth.ts +++ b/apps/hono/src/middleware/with-auth.ts @@ -1,4 +1,4 @@ -import { server } from "../auth" +import { api } from "../auth" import { createMiddleware } from "hono/factory" import type { Session } from "@aura-stack/auth" @@ -11,7 +11,9 @@ export type AuthVariables = { export const withAuth = createMiddleware<{ Variables: AuthVariables }>(async (ctx, next) => { try { - const session = await server.getSession(ctx.req.raw) + const session = await api.getSession({ + headers: ctx.req.raw.headers, + }) if (!session.authenticated) { return ctx.json({ error: "Unauthorized", message: "Active session required." }, 401) } diff --git a/apps/nextjs/app-router/src/app/page.tsx b/apps/nextjs/app-router/src/app/page.tsx index 665071e9..e15672cf 100644 --- a/apps/nextjs/app-router/src/app/page.tsx +++ b/apps/nextjs/app-router/src/app/page.tsx @@ -7,7 +7,7 @@ import { SessionClient } from "@/components/get-session-client" const providers = [builtInOAuthProviders.github(), builtInOAuthProviders.gitlab(), builtInOAuthProviders.bitbucket()] export default async function Home() { - const { getSession, signIn } = createAuthServer + const { getSession, signIn, signOut } = createAuthServer const session = await getSession() const isAuthenticated = Boolean(session && session?.user) 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 e68b7e19..5f959cd8 100644 --- a/apps/nextjs/app-router/src/lib/client.ts +++ b/apps/nextjs/app-router/src/lib/client.ts @@ -25,6 +25,7 @@ export const getSession = async (): Promise => { const response = await client.get("/session") if (!response.ok) return null const session = await response.json() + console.log("[debug:client] getSession response", session) return session && session?.user ? session : null } catch (error) { console.log("[error:client] getSession", error) 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 index e6daeafb..feed7b23 100644 --- a/apps/nextjs/pages-router/src/lib/server.ts +++ b/apps/nextjs/pages-router/src/lib/server.ts @@ -1,27 +1,22 @@ +import { api } from "@/auth" 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 { +export async function getSession(req: NextApiRequest): Promise { try { - const response = await client.get("/session", { + const session = await api.getSession({ headers: req.headers as Record, }) - if (!response.ok) { + if (!session.authenticated) { return null } - const session = await response.json() - return session && session?.user ? session : null - } catch (error) { + return session.session + } catch { + console.log("[error:server] getSession - Failed to retrieve session") return null } } - -export const createAuthServer = { - getSession, -} diff --git a/apps/oak/src/auth.ts b/apps/oak/src/auth.ts index 7a9103c2..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, server }: 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/middleware/with-auth.ts b/apps/oak/src/middleware/with-auth.ts index c9601642..968cf8a3 100644 --- a/apps/oak/src/middleware/with-auth.ts +++ b/apps/oak/src/middleware/with-auth.ts @@ -1,4 +1,4 @@ -import { server } from "@/auth.ts" +import { api } from "@/auth.ts" import type { Session } from "@aura-stack/auth" import type { Next, RouteParams, RouterContext } from "@oak/oak" @@ -19,7 +19,9 @@ export type RouterContextWithState(ctx: RouterContextWithState, next: Next) => { try { - const session = await server.getSession(ctx.request) + const session = await api.getSession({ + headers: ctx.request.headers + }) if (!session.authenticated) { ctx.response.status = 401 ctx.response.body = unauthorizedBody 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 dcedcee5..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, server }: 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/auth/index.ts b/apps/supabase/functions/auth/index.ts index b6ffe4b5..47d2e982 100644 --- a/apps/supabase/functions/auth/index.ts +++ b/apps/supabase/functions/auth/index.ts @@ -1,6 +1,6 @@ -import { handlers, server } from "../_shared/auth.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" @@ -11,7 +11,9 @@ Deno.serve(async (request) => { case "/": return new Response("Welcome to the Aura Auth Supabase App!") case "/api/protected": { - const session = await server.getSession(request) + const session = await api.getSession({ + headers: request.headers, + }) if (!session.authenticated) { return Response.json( { 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/packages/core/src/actions/signIn/authorization-url.ts b/packages/core/src/actions/signIn/authorization-url.ts index 4e254285..8a9929a2 100644 --- a/packages/core/src/actions/signIn/authorization-url.ts +++ b/packages/core/src/actions/signIn/authorization-url.ts @@ -78,4 +78,4 @@ export const createAuthorizationURL = async (oauth: OAuthProvider, redirectURI: codeVerifier, method, } -} \ No newline at end of file +} diff --git a/packages/core/src/api/createApi.ts b/packages/core/src/api/createApi.ts index 5559ef08..55ca59d4 100644 --- a/packages/core/src/api/createApi.ts +++ b/packages/core/src/api/createApi.ts @@ -19,8 +19,5 @@ export const createAPI = (ctx: GlobalContext) => { const redirectTo = validateRedirectTo(options.redirectTo ?? "/") return signOut({ ctx: ctx as GlobalContext, headers: options.headers, redirectTo, skipCSRFCheck: true }) }, - signIn: async () => { - return Response.redirect("/sign-in", 302) - }, } } From 8e66e0e559ca098142f3b836bdc78f414fbaa722 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Thu, 5 Mar 2026 19:39:56 -0500 Subject: [PATCH 6/8] docs(core): update CHANGELOG.md file --- packages/core/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) 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) From 96ec1c0fb30b4469ec7d33f624d818280ea02411 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Thu, 5 Mar 2026 19:47:07 -0500 Subject: [PATCH 7/8] chore(build): disable turborepo cache to rebuild packages Fixes stale build artifacts that prevented AuthInstance API types from being recognized. From 7726d5dcf63bba2d5232488bc72bf9220775dee9 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Thu, 5 Mar 2026 20:07:13 -0500 Subject: [PATCH 8/8] chore: apply coderabbit --- apps/astro/src/lib/client.ts | 2 +- apps/astro/src/lib/server.ts | 2 +- apps/nextjs/app-router/src/app/page.tsx | 2 +- apps/nextjs/app-router/src/lib/client.ts | 3 +-- apps/nextjs/pages-router/src/lib/server.ts | 22 -------------------- apps/nextjs/pages-router/src/pages/index.tsx | 8 ++++--- apps/react-router/app/actions/auth.client.ts | 2 +- apps/tanstack-start/src/lib/auth-client.ts | 2 +- apps/tanstack-start/src/lib/auth-server.ts | 2 +- packages/core/src/api/createApi.ts | 4 ++-- packages/core/src/api/signOut.ts | 7 ++++++- packages/core/src/utils.ts | 5 +++++ 12 files changed, 25 insertions(+), 36 deletions(-) delete mode 100644 apps/nextjs/pages-router/src/lib/server.ts 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 3133b89d..af3feaf6 100644 --- a/apps/astro/src/lib/server.ts +++ b/apps/astro/src/lib/server.ts @@ -52,7 +52,7 @@ export const createAuthServer = async (context: AuthServerContext) => { headers: request.headers, }) if (response.status === 202) { - return redirect(redirectTo) + return response } const json = await response.json() return json diff --git a/apps/nextjs/app-router/src/app/page.tsx b/apps/nextjs/app-router/src/app/page.tsx index e15672cf..665071e9 100644 --- a/apps/nextjs/app-router/src/app/page.tsx +++ b/apps/nextjs/app-router/src/app/page.tsx @@ -7,7 +7,7 @@ import { SessionClient } from "@/components/get-session-client" const providers = [builtInOAuthProviders.github(), builtInOAuthProviders.gitlab(), builtInOAuthProviders.bitbucket()] export default async function Home() { - const { getSession, signIn, signOut } = createAuthServer + const { getSession, signIn } = createAuthServer const session = await getSession() const isAuthenticated = Boolean(session && session?.user) diff --git a/apps/nextjs/app-router/src/lib/client.ts b/apps/nextjs/app-router/src/lib/client.ts index 5f959cd8..5bf8a18e 100644 --- a/apps/nextjs/app-router/src/lib/client.ts +++ b/apps/nextjs/app-router/src/lib/client.ts @@ -25,8 +25,7 @@ export const getSession = async (): Promise => { const response = await client.get("/session") if (!response.ok) return null const session = await response.json() - console.log("[debug:client] getSession response", session) - 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/nextjs/pages-router/src/lib/server.ts b/apps/nextjs/pages-router/src/lib/server.ts deleted file mode 100644 index feed7b23..00000000 --- a/apps/nextjs/pages-router/src/lib/server.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { api } from "@/auth" -import type { NextApiRequest } from "next" -import type { Session } from "@aura-stack/auth" - -/** - * Standard server-side auth function to retrieve the current session. - * Compatible with getServerSideProps and API routes. - */ -export async function getSession(req: NextApiRequest): Promise { - try { - const session = await api.getSession({ - headers: req.headers as Record, - }) - if (!session.authenticated) { - return null - } - return session.session - } catch { - console.log("[error:server] getSession - Failed to retrieve session") - return null - } -} 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/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/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/src/api/createApi.ts b/packages/core/src/api/createApi.ts index 55ca59d4..4070db39 100644 --- a/packages/core/src/api/createApi.ts +++ b/packages/core/src/api/createApi.ts @@ -12,12 +12,12 @@ export interface APIOptions { export const createAPI = (ctx: GlobalContext) => { return { getSession: async ({ headers }: { headers: HeadersInit }): Promise => { - const session = await getSession({ ctx: ctx as GlobalContext, headers }) + const session = await getSession({ ctx, headers }) return session }, signOut: async (options: APIOptions) => { const redirectTo = validateRedirectTo(options.redirectTo ?? "/") - return signOut({ ctx: ctx as GlobalContext, headers: options.headers, redirectTo, skipCSRFCheck: true }) + return signOut({ ctx, headers: options.headers, redirectTo, skipCSRFCheck: true }) }, } } diff --git a/packages/core/src/api/signOut.ts b/packages/core/src/api/signOut.ts index 8ab9bee8..e3ec837c 100644 --- a/packages/core/src/api/signOut.ts +++ b/packages/core/src/api/signOut.ts @@ -59,7 +59,12 @@ export const signOut = async ({ ctx, headers: headersInit, redirectTo = "/", ski } ctx?.logger?.log("SIGN_OUT_CSRF_VERIFIED") } else { - await ctx.jose.verifyJWS(csrfToken) + 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) diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index e3273b95..efd84fa3 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -170,6 +170,11 @@ export const createBasicAuthHeader = (username: string, password: string): strin 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