diff --git a/apps/server/app/v2/databases/[database]/auth/[...nextauth]/route.ts b/apps/server/app/v2/databases/[database]/auth/[...nextauth]/route.ts index b1da97c..521c614 100644 --- a/apps/server/app/v2/databases/[database]/auth/[...nextauth]/route.ts +++ b/apps/server/app/v2/databases/[database]/auth/[...nextauth]/route.ts @@ -2,7 +2,7 @@ import { NextRequest } from "next/server"; import NileAuth from "@nile-auth/core"; import { EventEnum, Logger, ResponseLogger } from "@nile-auth/logger"; -import { getOrigin, getSecureCookies } from "@nile-auth/core/cookies"; +import { getOrigin } from "@nile-auth/core/cookies"; const log = Logger(EventEnum.NILE_AUTH); diff --git a/apps/server/app/v2/databases/[database]/auth/mfa/route.ts b/apps/server/app/v2/databases/[database]/auth/mfa/route.ts index 952a4f1..73c8dc6 100644 --- a/apps/server/app/v2/databases/[database]/auth/mfa/route.ts +++ b/apps/server/app/v2/databases/[database]/auth/mfa/route.ts @@ -386,7 +386,6 @@ export async function PUT(req: NextRequest) { `; if (sessionError) { - console.log(sessionError); return sessionError; } diff --git a/apps/server/app/v2/databases/[database]/auth/verify-email/verify-email.test.ts b/apps/server/app/v2/databases/[database]/auth/verify-email/verify-email.test.ts index 448d78a..d5bb8d4 100644 --- a/apps/server/app/v2/databases/[database]/auth/verify-email/verify-email.test.ts +++ b/apps/server/app/v2/databases/[database]/auth/verify-email/verify-email.test.ts @@ -89,7 +89,7 @@ jest.mock("@nile-auth/logger", () => ({ (body, { status = 200, headers = {} }) => new Response(body, { status, headers }), ), - { error: (e: Error) => console.log(e) }, + { error: (e: Error) => jest.fn }, ], })); diff --git a/apps/server/app/v2/databases/[database]/tenants/[tenantId]/invite/acceptInvite.test.ts b/apps/server/app/v2/databases/[database]/tenants/[tenantId]/invite/acceptInvite.test.ts index df462b5..61bd915 100644 --- a/apps/server/app/v2/databases/[database]/tenants/[tenantId]/invite/acceptInvite.test.ts +++ b/apps/server/app/v2/databases/[database]/tenants/[tenantId]/invite/acceptInvite.test.ts @@ -59,7 +59,9 @@ describe("accept invite", () => { values.forEach((val, idx) => { const normalized = - val && typeof val === "object" && "value" in (val as Record) + val && + typeof val === "object" && + "value" in (val as Record) ? (val as { value: string }).value : val; text = text.replace(`$${idx + 1}`, normalized as string); @@ -129,7 +131,9 @@ describe("accept invite", () => { } values.forEach((val, i) => { const normalized = - val && typeof val === "object" && "value" in (val as Record) + val && + typeof val === "object" && + "value" in (val as Record) ? (val as { value: string }).value : val; text = text.replace(`$${i + 1}`, normalized as string); @@ -141,7 +145,6 @@ describe("accept invite", () => { commands.push(text); if (text.includes("DELETE")) { - console.log(text, "not here?"); return [null, { rowCount: 1 }]; } diff --git a/apps/server/test/integration.test.ts b/apps/server/test/integration.test.ts index 0f1bfe8..87ca1ee 100644 --- a/apps/server/test/integration.test.ts +++ b/apps/server/test/integration.test.ts @@ -19,6 +19,10 @@ const primaryUser = { email: "delete@me.com", password: "deleteme", }; +const staleJwtUser = { + email: "delete4@me.com", + password: "deleteme", +}; const newUser = { email: "delete2@me.com", password: "deleteme", @@ -165,23 +169,63 @@ describe("api integration", () => { await nile.db.query("delete from tenants where id = $1", [newTenant.id]); await nile.clearConnections(); }, 10000); + + test("revoked JWT cannot be reused after sign out", async () => { + const nile = new Server(config); + await initialDebugCleanup(nile); + + const user = (await nile.api.users.createUser( + staleJwtUser, + )) as unknown as { id: string }; + expect(user.id).toBeTruthy(); + + await nile.api.login(staleJwtUser, { returnResponse: true }); + + const activeMe = await nile.api.users.me<{ email: string }>(); + expect(activeMe.email).toEqual(staleJwtUser.email); + + // capture the auth cookies before revocation + const staleHeaders = new Headers(nile.api.headers); + + const signOutRes = (await nile.api.auth.signOut({ + callbackUrl: "http://localhost:3000", + })) as Response | { url: string }; + if (signOutRes instanceof Response) { + expect(signOutRes.status).toEqual(200); + } else { + expect(signOutRes.url).toEqual("http://localhost:3000"); + } + + const staleMe = (await nile.api.users.me( + staleHeaders, + )) as Response; + expect(staleMe.status).toEqual(401); + + await nile.db.query("delete from auth.credentials where user_id = $1", [ + user.id, + ]); + await nile.db.query("delete from users.users where id = $1", [user.id]); + await nile.clearConnections(); + }, 10000); }); async function initialDebugCleanup(nile: Server) { // remove the users 1st, fk constraints - const existing = [primaryUser, newUser, tenantUser].map(async (u) => { - const exists = await nile.db.query( - "select * from users.users where email = $1", - [u.email], - ); - if (exists.rows.length > 0) { - const id = exists.rows[0].id; - await nile.db.query("delete from auth.credentials where user_id = $1", [ - id, - ]); - await nile.db.query("delete from users.users where id= $1", [id]); - } - }); + const existing = [primaryUser, newUser, tenantUser, staleJwtUser].map( + async (u) => { + const exists = await nile.db.query( + "select * from users.users where email = $1", + [u.email], + ); + if (exists.rows.length > 0) { + const id = exists.rows[0].id; + await nile.db.query("delete from auth.credentials where user_id = $1", [ + id, + ]); + await nile.db.query("delete from users.users where id= $1", [id]); + } + }, + ); await Promise.all(existing); const tenants = await nile.db.query("select * from tenants;"); const commands = tenants.rows.reduce((accum, t) => { diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index d938eb4..44219eb 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -1,5 +1,8 @@ import { getToken, JWT } from "next-auth/jwt"; import { Logger } from "@nile-auth/logger"; +import { Pool } from "pg"; +import getDbInfo from "@nile-auth/query/getDbInfo"; +import { query } from "@nile-auth/query"; import { getSecureCookies } from "./next-auth/cookies"; type SessionUser = { user?: { id?: string } }; @@ -31,7 +34,38 @@ export async function buildFetch( !isNaN(token.exp) && token.exp > now ) { - return [{ user: { id: String(token.id) } }]; + try { + if (typeof token.jti !== "string") { + throw new Error("JWT missing jti"); + } + const dbInfo = getDbInfo(undefined, req); + const pool = new Pool(dbInfo); + const sql = await query(pool); + const sessions = await sql` + SELECT + expires_at + FROM + auth.sessions + WHERE + session_token = ${token.jti} + `; + if ( + sessions && + "rowCount" in sessions && + sessions.rowCount > 0 && + sessions.rows[0]?.expires_at && + new Date(sessions.rows[0].expires_at).getTime() > Date.now() + ) { + return [{ user: { id: String(token.id) } }]; + } + } catch (e) { + if (e instanceof Error) { + warn("revocation check failed", { + message: e.message, + stack: e.stack, + }); + } + } } } const url = new URL(req.url); diff --git a/packages/core/src/index.test.ts b/packages/core/src/index.test.ts new file mode 100644 index 0000000..364ff53 --- /dev/null +++ b/packages/core/src/index.test.ts @@ -0,0 +1,141 @@ +jest.mock("next-auth", () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock("./nextOptions", () => ({ + nextOptions: jest.fn(), +})); + +jest.mock("./utils", () => ({ + buildOptions: jest.fn(), +})); + +jest.mock("@nile-auth/query/getDbInfo", () => ({ + __esModule: true, + default: jest.fn(), +})); + +jest.mock("./next-auth/cookies", () => { + const actual = jest.requireActual("./next-auth/cookies"); + return { + ...actual, + getOrigin: jest.fn(), + getTenantCookie: jest.fn(), + setTenantCookie: jest.fn(), + }; +}); + +jest.mock("@nile-auth/query", () => ({ + queryByReq: jest.fn(), +})); + +jest.mock("./mfa/providerResponse", () => ({ + buildProviderMfaResponse: jest.fn(), +})); + +jest.mock("./next-auth/providers/email", () => ({ + sendVerifyEmail: jest.fn(), +})); + +import NileAuth from "./index"; +import NextAuth from "next-auth"; +import { nextOptions } from "./nextOptions"; +import { buildOptions } from "./utils"; +import getDbInfo from "@nile-auth/query/getDbInfo"; +import { + getTenantCookie, + getOrigin, + setTenantCookie, +} from "./next-auth/cookies"; +import { queryByReq } from "@nile-auth/query"; +import { buildProviderMfaResponse } from "./mfa/providerResponse"; + +const nextAuthMock = NextAuth as jest.MockedFunction; +const nextOptionsMock = nextOptions as jest.MockedFunction; +const buildOptionsMock = buildOptions as jest.MockedFunction< + typeof buildOptions +>; +const getDbInfoMock = getDbInfo as jest.MockedFunction; +const getTenantCookieMock = getTenantCookie as jest.MockedFunction< + typeof getTenantCookie +>; +const getOriginMock = getOrigin as jest.MockedFunction; +const setTenantCookieMock = setTenantCookie as jest.MockedFunction< + typeof setTenantCookie +>; +const queryByReqMock = queryByReq as jest.MockedFunction; +const buildProviderMfaResponseMock = + buildProviderMfaResponse as jest.MockedFunction< + typeof buildProviderMfaResponse + >; + +describe("NileAuth", () => { + const dbInfo = { + host: "localhost", + database: "nile", + user: "nile", + password: "secret", + port: 5432, + }; + + beforeEach(() => { + jest.clearAllMocks(); + getDbInfoMock.mockReturnValue(dbInfo as any); + nextOptionsMock.mockResolvedValue([{ providers: [{}] } as any]); + buildOptionsMock.mockReturnValue({} as any); + getOriginMock.mockReturnValue("https://example.com"); + getTenantCookieMock.mockReturnValue(null); + buildProviderMfaResponseMock.mockResolvedValue(null); + nextAuthMock.mockResolvedValue(new Response(null, { status: 200 })); + setTenantCookieMock.mockReturnValue( + new Headers([["set-cookie", "tenant=new"]]), + ); + }); + + it("appends tenant cookie header after a successful credentials callback", async () => { + const executedQueries: string[] = []; + queryByReqMock.mockResolvedValueOnce(async function sql( + strings: TemplateStringsArray, + ...values: unknown[] + ): Promise { + let text = strings[0] ?? ""; + for (let i = 1; i < strings.length; i++) { + text += `$${i}${strings[i] ?? ""}`; + } + values.forEach((val, idx) => { + text = text.replace(`$${idx + 1}`, String(val)); + }); + text = text.replace(/\n\s+/g, " ").trim(); + executedQueries.push(text); + return [ + { + rowCount: 1, + rows: [{ id: "tenant-123", name: "Tenant 123" }], + }, + ]; + }); + + const form = new FormData(); + form.set("email", "unit@example.com"); + const req = new Request( + "https://example.com/api/auth/[...nextauth]/callback/credentials", + { + method: "POST", + body: form, + }, + ); + + const res = await NileAuth(req, { + params: { nextauth: ["callback", "credentials"] }, + }); + + expect(setTenantCookieMock).toHaveBeenCalledWith(req, [ + { id: "tenant-123", name: "Tenant 123" }, + ]); + expect(res.headers.get("set-cookie")).toEqual("tenant=new"); + expect(executedQueries).toEqual([ + "SELECT DISTINCT t.id, t.name FROM public.tenants t JOIN users.tenant_users tu ON t.id = tu.tenant_id JOIN users.users u ON u.id = tu.user_id WHERE LOWER(u.email) = LOWER(unit@example.com) AND tu.deleted IS NULL AND t.deleted IS NULL AND u.deleted IS NULL", + ]); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b60b2bc..76aa34c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,12 +4,17 @@ import NextAuth, { AuthOptions as NextAuthOptions } from "next-auth"; import { buildOptions } from "./utils"; import { nextOptions } from "./nextOptions"; import getDbInfo from "@nile-auth/query/getDbInfo"; -import { getOrigin, getTenantCookie } from "./next-auth/cookies"; +import { + getOrigin, + getTenantCookie, + setTenantCookie, +} from "./next-auth/cookies"; import { isFQDN } from "validator"; import { ActionableErrors, AuthOptions } from "./types"; import { sendVerifyEmail } from "./next-auth/providers/email"; import { buildProviderMfaResponse } from "./mfa/providerResponse"; import { decodeMfaPayload } from "./mfa/utils"; +import { queryByReq } from "@nile-auth/query"; export { maxAge } from "./nextOptions"; const { warn } = Logger("[nile-auth]"); @@ -35,6 +40,41 @@ function isWellFormedUrl(input: string) { } } +function normalizeCredentialIdentifier(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length ? trimmed : null; +} + +async function getCredentialIdentifier(req: Request): Promise { + try { + const formData = await req.clone().formData(); + const fromForm = + normalizeCredentialIdentifier(formData.get("email")) ?? + normalizeCredentialIdentifier(formData.get("identifier")); + if (fromForm) { + return fromForm; + } + } catch { + // ignore parsing form errors + } + + try { + const jsonData = (await req.clone().json()) as Record; + const fromJson = + normalizeCredentialIdentifier(jsonData?.email) ?? + normalizeCredentialIdentifier(jsonData?.identifier); + if (fromJson) { + return fromJson; + } + } catch { + // ignore parsing json errors + } + return null; +} + // Do not give NileAuth the `responder`, just return basic responses, let the wrappers handle the logging export default async function NileAuth( req: Request, @@ -81,6 +121,49 @@ export default async function NileAuth( const segments = params?.nextauth ?? []; const isCallbackRoute = segments[0] === "callback"; const isCredentials = segments[1] === "credentials"; + + const identifier = await getCredentialIdentifier(preserve); + if (identifier) { + try { + const sql = await queryByReq(preserve); + const [tenantRows] = await sql` + SELECT DISTINCT + t.id, + t.name + FROM + public.tenants t + JOIN users.tenant_users tu ON t.id = tu.tenant_id + JOIN users.users u ON u.id = tu.user_id + WHERE + LOWER(u.email) = LOWER(${identifier}) + AND tu.deleted IS NULL + AND t.deleted IS NULL + AND u.deleted IS NULL + `; + + if (tenantRows && "name" in tenantRows) { + warn("Failed to fetch tenants when setting tenant cookie", { + tenantRows, + }); + } + + if (tenantRows && "rowCount" in tenantRows) { + const tenantHeaders = setTenantCookie(req, tenantRows.rows); + const tenantCookie = tenantHeaders?.get("set-cookie"); + if (tenantCookie) { + handler.headers.append("set-cookie", tenantCookie); + } + } + } catch (tenantError) { + if (tenantError instanceof Error) { + warn("Unable to set tenant cookie after credentials callback", { + message: tenantError.message, + stack: tenantError.stack, + }); + } + } + } + if (handler.status < 402 && isCallbackRoute && isCredentials) { const providerMfaResponse = await buildProviderMfaResponse( await preserve.clone(), diff --git a/packages/core/src/next-auth/adapter/getSessionAndUser.ts b/packages/core/src/next-auth/adapter/getSessionAndUser.ts index 32d3238..cdd899e 100644 --- a/packages/core/src/next-auth/adapter/getSessionAndUser.ts +++ b/packages/core/src/next-auth/adapter/getSessionAndUser.ts @@ -15,6 +15,7 @@ export function getSessionAndUser(pool: Pool) { if (sessionToken === undefined) { return null; } + const sql = await query(pool); // try doing jwt first, its maybe faster? try { @@ -25,12 +26,30 @@ export function getSessionAndUser(pool: Pool) { if ( typeof parsed?.email === "string" && typeof parsed?.id === "string" && - typeof parsed.exp === "number" + typeof parsed.exp === "number" && + typeof parsed.jti === "string" ) { + const sessions = await sql` + SELECT + expires_at + FROM + auth.sessions + WHERE + session_token = ${parsed.jti} + `; + if (!(sessions && "rowCount" in sessions && sessions.rowCount > 0)) { + return null; + } + const expires = sessions.rows[0]?.expires_at + ? new Date(sessions.rows[0].expires_at) + : null; + if (expires && expires.getTime() < Date.now()) { + return null; + } return { user: { id: parsed.id, email: parsed.email, emailVerified: null }, session: { - sessionToken, + sessionToken: parsed.jti, userId: parsed.id, expires: new Date(parsed.exp * 1000), }, @@ -40,7 +59,6 @@ export function getSessionAndUser(pool: Pool) { // do nothing, they may have a valid session (non JWT) } - const sql = query(pool); const sessions = await sql` SELECT * diff --git a/packages/core/src/next-auth/cookies/index.ts b/packages/core/src/next-auth/cookies/index.ts index 60ce082..6a33a5c 100644 --- a/packages/core/src/next-auth/cookies/index.ts +++ b/packages/core/src/next-auth/cookies/index.ts @@ -2,6 +2,7 @@ import { CookieOption, CookiesOptions, User } from "next-auth"; import { encode } from "next-auth/jwt"; import { maxAge } from "../../nextOptions"; import { jwt } from "@nile-auth/core/utils"; +import getDbInfo from "@nile-auth/query/getDbInfo"; import { HEADER_ORIGIN, HEADER_SECURE_COOKIES, @@ -235,17 +236,20 @@ export async function makeNewSessionJwt(req: Request, user: User) { const useSecureCookies = getSecureCookies(req); const sessionCookie = getSessionTokenCookie(useSecureCookies); let newToken = ""; + const dbInfo = getDbInfo(undefined, req); const defaultToken = { name: user.name, email: user.email, picture: user.image, sub: user.id?.toString(), + exp: Math.floor(Date.now() / 1000) + maxAge, }; const token = await jwt({ token: defaultToken, user: user as User, account: null, // not oauth + dbInfo, }); if (process.env.NEXTAUTH_SECRET) { // no salt for session cookie diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 0e70749..449cf89 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -2,6 +2,7 @@ import { Account, DefaultSession, Profile, Session, User } from "next-auth"; import { AdapterUser } from "next-auth/adapters"; import { Pool } from "pg"; import { JWT } from "next-auth/jwt"; +import { randomUUID } from "crypto"; import { Logger } from "@nile-auth/logger"; import getDbInfo, { DbCreds } from "@nile-auth/query/getDbInfo"; @@ -135,6 +136,7 @@ export async function jwt(params: { trigger?: "signIn" | "signUp" | "update"; isNewUser?: boolean; session?: any; + dbInfo?: DbCreds; }): Promise { const { user, token } = params; if (user) { @@ -143,9 +145,67 @@ export async function jwt(params: { token.picture = user.image; } debug("JWT CALLBACK", { token, user }); + if (!token.jti) { + token.jti = randomUUID(); + } + const exp = + typeof token.exp === "number" && !isNaN(token.exp) ? token.exp : null; + if (params.dbInfo && token.id && exp) { + await persistJwtSession({ + token, + dbInfo: params.dbInfo, + userId: String(token.id), + expiresAt: new Date(exp * 1000), + }); + } return token; } +async function persistJwtSession({ + token, + dbInfo, + userId, + expiresAt, +}: { + token: JWT; + dbInfo: DbCreds; + userId: string; + expiresAt: Date; +}) { + try { + const pool = new Pool(dbInfo); + pool.on("error", (e: Error) => { + info("Unexpected error on client", { + stack: e.stack, + message: e.message, + }); + }); + const sql = await query(pool); + await sql` + INSERT INTO + auth.sessions (user_id, expires_at, session_token) + VALUES + ( + ${userId}, + ${expiresAt}, + ${token.jti} + ) + ON CONFLICT (session_token) DO + UPDATE + SET + user_id = EXCLUDED.user_id, + expires_at = EXCLUDED.expires_at + `; + } catch (e) { + if (e instanceof Error) { + warn("Failed to persist JWT session", { + message: e.message, + stack: e.stack, + }); + } + } +} + export function buildOptions(req: Request, cfg?: AuthOptions) { const dbInfo = getDbInfo(cfg); const config = cfg ? cfg : ({} as AuthOptions); @@ -166,7 +226,11 @@ export function buildOptions(req: Request, cfg?: AuthOptions) { } return true; }, - jwt, + jwt: async (params) => + jwt({ + ...params, + dbInfo, + }), session: async function session(params) { const { session, token, user } = params; debug("session CALLBACK"); @@ -190,6 +254,29 @@ export function buildOptions(req: Request, cfg?: AuthOptions) { return { expires: new Date().toISOString() }; }, }; + config.events = { + async signOut(message) { + if (!message?.token?.jti) { + return; + } + try { + const pool = new Pool(dbInfo); + const sql = await query(pool); + await sql` + DELETE FROM auth.sessions + WHERE + session_token = ${message.token.jti} + `; + } catch (e) { + if (e instanceof Error) { + warn("Failed to revoke JWT session", { + message: e.message, + stack: e.stack, + }); + } + } + }, + }; return { ...(config ? { ...config, ...dbInfo } : {}), debug: true,