diff --git a/README.md b/README.md index 55db26e..2b21de7 100644 --- a/README.md +++ b/README.md @@ -67,12 +67,19 @@ app.use( prefix: "/auth", successRedirect: "http://localhost:3000/oauth-success", failureRedirect: "http://localhost:3000/oauth-failure", - autoProvision: true, defaultRole: "ROLE_USER", - setRefreshCookie: true, - appendTokensInRedirect: false, - includeAuthorities: true, - issueJwt: true, + onSuccess(info)=>{ + const {profile, existingUser,} =info; + if(existingUser){ + return existingUser; + } + + // Logic to create new user + reateUser(profile) + }, + onfailure(info)=>{ + // Logic to be executed onFailure + }, providers: { google: { clientID: "GOOGLE_CLIENT_ID", @@ -82,15 +89,6 @@ app.use( }, }, }, - cookies: { - enabled: true, - name: "AuthRefreshToken", - httpOnly: true, - secure: false, - sameSite: "Strict", - maxAge: 7 * 24 * 60 * 60 * 1000, - path: "/", - }, twoFA: { enabled: false, prefix: "/auth/2fa", @@ -250,7 +248,6 @@ oauth2: { - **defaultRole**: Default role assigned to new users. - **providers**: Supported providers (e.g., Google, GitHub). - ### **Two-Factor Authentication** ```javascript @@ -334,6 +331,7 @@ All endpoints use the configured prefix. Default prefixes shown below: - **GET** `/auth/{provider}` - Initiate OAuth login - **GET** `/auth/{provider}/callback` - OAuth callback - **GET** `/auth/error` - OAuth error redirect +- **POST** `/auth/token` - to get token from temporary code ### **Two-Factor Authentication** @@ -368,6 +366,15 @@ All endpoints use the configured prefix. Default prefixes shown below: 3. Server processes authentication and auto-creates user if add any logic onSuccess. 4. Server redirects to success URL with tokens as cookies. 5. Subsequent requests use JWT or session authentication. +6. After successful provider authentication, the temporary code will be set as a query parameter on the redirect URL. +7. Frontend can then trigger `{prefix}/token` with a `POST` request and payload: + +```json +{ + "code": "code-from-redirect-url" +} +``` + ## Logout Behavior diff --git a/package.json b/package.json index 0fd7fb9..4b21ae3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@flycatch/auth-core", - "version": "1.3.0", + "version": "1.4.0", "description": "A unified authentication module for Express.js, NestJS frameworks, supporting JWT, session-based, and Google OAuth login.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/index.ts b/src/index.ts index 831bd9d..6559a1e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ import { NextFunction, Request, Response, Router } from "express"; import { Config } from "./interfaces/config.interface"; import express from "express"; -import createLogger from "./lib/wintson.logger"; +import createLogger from "./lib/winston.logger"; import jwtRoutes from "./routes/jwt.routes"; import sessionRoutes from "./routes/session.routes"; import setupSession from "./config/session.config"; diff --git a/src/lib/wintson.logger.ts b/src/lib/winston.logger.ts similarity index 100% rename from src/lib/wintson.logger.ts rename to src/lib/winston.logger.ts diff --git a/src/middlewares/jwt.middleware.ts b/src/middlewares/jwt.middleware.ts index ec6edb9..2117762 100644 --- a/src/middlewares/jwt.middleware.ts +++ b/src/middlewares/jwt.middleware.ts @@ -2,8 +2,8 @@ import { Request, Response, NextFunction } from "express"; import jwt from "jsonwebtoken"; import { Config } from "../interfaces/config.interface"; -import createLogger from "../lib/wintson.logger"; -import { isTokenBlacklisted } from "../routes/jwt.routes"; +import createLogger from "../lib/winston.logger"; +import { isInBlacklist } from "../utils/jwt-blacklist"; /** * Express middleware for validating JWT access tokens. @@ -16,7 +16,7 @@ import { isTokenBlacklisted } from "../routes/jwt.routes"; * @returns {import("express").RequestHandler} Express middleware function. */ export default (config: Config) => { - return (req: Request, res: Response, next: NextFunction) => { + return async (req: Request, res: Response, next: NextFunction) => { const logger = createLogger(config); if (!config.jwt) { @@ -51,8 +51,10 @@ export default (config: Config) => { }); } + const isBlackisted = await isInBlacklist(token) + // Check if token is blacklisted (only if blacklisting is enabled) - if (config.jwt.tokenBlacklist?.enabled && isTokenBlacklisted(token)) { + if (config.jwt.tokenBlacklist?.enabled && isBlackisted) { logger.warn("JWT middleware: Blacklisted token used"); return res.status(401).json({ error: "Unauthorized", diff --git a/src/middlewares/session.middleware.ts b/src/middlewares/session.middleware.ts index a53a1c6..9e85e48 100644 --- a/src/middlewares/session.middleware.ts +++ b/src/middlewares/session.middleware.ts @@ -1,6 +1,6 @@ import { NextFunction, Request, Response } from "express"; import { Config } from "../interfaces/config.interface"; -import createLogger from "../lib/wintson.logger"; +import createLogger from "../lib/winston.logger"; /** * Express middleware for validating user sessions. diff --git a/src/routes/jwt.routes.ts b/src/routes/jwt.routes.ts index 773852a..135d811 100644 --- a/src/routes/jwt.routes.ts +++ b/src/routes/jwt.routes.ts @@ -3,7 +3,7 @@ import { Request, Response, Router } from "express"; import { Config } from "../interfaces/config.interface"; import jwt from "jsonwebtoken"; import express from "express"; -import createLogger from "../lib/wintson.logger"; +import createLogger from "../lib/winston.logger"; import apiResponse from "../utils/api-response"; import { createJwtTokens } from "../utils/jwt"; import twoFactorAuth, { @@ -15,35 +15,8 @@ import { setBlacklistStorage, } from "../utils/jwt-blacklist"; -/** - * In-memory token blacklist - * Used only if no custom storage is configured - */ -const tokenBlacklist = new Set(); - -/** - * Add a token to the in-memory blacklist - * @param token JWT token string - */ -export const blacklistToken = (token: string): void => { - tokenBlacklist.add(token); -}; -/** - * Check if a token exists in the in-memory blacklist - * @param token JWT token string - * @returns boolean indicating if token is blacklisted - */ -export const isTokenBlacklisted = (token: string): boolean => { - return tokenBlacklist.has(token); -}; -/** - * Clear all tokens from the in-memory blacklist - */ -export const clearBlacklist = (): void => { - tokenBlacklist.clear(); -}; /** * JWT Routes diff --git a/src/routes/oauth2.routes.ts b/src/routes/oauth2.routes.ts index 400bd57..d836a1b 100644 --- a/src/routes/oauth2.routes.ts +++ b/src/routes/oauth2.routes.ts @@ -2,10 +2,25 @@ import { NextFunction, Request, Response, Router } from "express"; import { Config, OAuth2CallbackInfo } from "../interfaces/config.interface"; import passport from "passport"; -import createLogger from "../lib/wintson.logger"; +import createLogger from "../lib/winston.logger"; import { createJwtTokens } from "../utils/jwt"; import { User } from "../interfaces/user.interface"; import { createSessionPayload } from "../utils/session"; +import crypto from "crypto"; + +// In-memory store for temporary authorization codes +// In production, use Redis or a database with TTL +const authCodeStore = new Map(); + +// Cleanup expired codes every 5 minutes +setInterval(() => { + const now = Date.now(); + for (const [code, data] of authCodeStore.entries()) { + if (data.expiresAt < now) { + authCodeStore.delete(code); + } + } +}, 5 * 60 * 1000); /** * OAuth2 Routes @@ -74,7 +89,7 @@ export default (router: Router, config: Config) => { failureRedirect: `${basePrefix}/error`, } as any)(req, res, next); }, - // Success handler + // Success handler - Generate temporary code async (req: Request, res: Response) => { try { logger.info(`Handling ${providerName} OAuth callback`, { @@ -158,57 +173,16 @@ export default (router: Router, config: Config) => { user.provider = callbackInfo.provider; user.providerId = callbackInfo.profile.providerId; - /** - * JWT Authentication - */ - if (config.jwt?.enabled) { - const { refreshToken, accessToken } = createJwtTokens( - config.jwt, - user - ); - - logger.info("JWT tokens created for OAuth user"); + // Generate temporary authorization code + const authCode = crypto.randomBytes(32).toString("hex"); + const expiresAt = Date.now() + 5 * 60 * 1000; // 5 minutes - if (config.jwt?.refresh && refreshToken) { - res.cookie("AuthRefreshToken", refreshToken, { - httpOnly: false, - secure: true, - sameSite: "strict", - maxAge: 5 * 60 * 1000, - path: "/", - }); - logger.info("Refresh token set as cookie"); - } else { - res.cookie("AuthToken", accessToken, { - httpOnly: false, - secure: true, - sameSite: "strict", - maxAge: 5 * 60 * 1000, - path: "/", - }); - logger.info("Access token set as cookie"); - } + // Store user data with the code + authCodeStore.set(authCode, { user, expiresAt }); + logger.info("Temporary auth code generated", { authCode }); - /** - * Session-based Authentication - */ - } else if (config.session?.enabled) { - const sessionPayload = createSessionPayload(user); - req.session.user = sessionPayload; - logger.info("Session created for OAuth user"); - } else { - logger.error("No authentication method configured"); - return handleOAuthFailure( - res, - config, - providerName, - "auth_not_configured", - "Authentication method not configured" - ); - } - - // Redirect to success URL - return handleOAuthSuccess(res, config, providerName, user); + // Redirect to frontend with the code + return handleOAuthSuccess(res, config, providerName, authCode); } catch (err: any) { logger.error(`Error during ${providerName} OAuth callback`, { error: err.message, @@ -235,6 +209,105 @@ export default (router: Router, config: Config) => { } ); + /** + * Token exchange endpoint + * Frontend calls this with the authorization code to get actual tokens + */ + router.post(`${basePrefix}/token`, async (req: Request, res: Response) => { + try { + const { code } = req.body; + + if (!code) { + logger.warn("Token exchange attempted without code"); + return res.status(400).json({ error: "Authorization code required" }); + } + + // Retrieve user data from code + const codeData = authCodeStore.get(code); + + if (!codeData) { + logger.warn("Invalid or expired authorization code", { code }); + return res.status(401).json({ error: "Invalid or expired code" }); + } + + // Check expiration + if (codeData.expiresAt < Date.now()) { + authCodeStore.delete(code); + logger.warn("Expired authorization code used", { code }); + return res.status(401).json({ error: "Code expired" }); + } + + const user = codeData.user; + + // Delete code after use (one-time use) + authCodeStore.delete(code); + logger.info("Authorization code exchanged successfully"); + + /** + * JWT Authentication + */ + if (config.jwt?.enabled) { + const { refreshToken, accessToken } = createJwtTokens(config.jwt, user); + + logger.info("JWT tokens created for OAuth user"); + + const response: any = { + success: true, + user, + accessToken, + }; + + if (config.jwt?.refresh && refreshToken) { + response.refreshToken = refreshToken; + + // Set refresh token as httpOnly cookie + res.cookie("AuthRefreshToken", refreshToken, { + httpOnly: true, + secure: true, + sameSite: "strict", + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days + path: "/", + }); + logger.info("Refresh token set as httpOnly cookie"); + } + + return res.json(response); + + /** + * Session-based Authentication + */ + } else if (config.session?.enabled) { + const sessionPayload = createSessionPayload(user); + req.session.user = sessionPayload; + + logger.info("Session created for OAuth user"); + + // Save session before sending response + req.session.save((err) => { + if (err) { + logger.error("Failed to save session", { error: err.message }); + return res.status(500).json({ error: "Failed to create session" }); + } + + return res.json({ + success: true, + user, + sessionId: req.sessionID, + }); + }); + } else { + logger.error("No authentication method configured"); + return res.status(500).json({ error: "Authentication not configured" }); + } + } catch (err: any) { + logger.error("Error during token exchange", { + error: err.message, + stack: err.stack, + }); + return res.status(500).json({ error: "Internal server error" }); + } + }); + /** * Internal error route */ @@ -262,21 +335,18 @@ export default (router: Router, config: Config) => { }; /** - * Handle OAuth success + * Handle OAuth success - redirect with authorization code */ const handleOAuthSuccess = ( res: Response, config: Config, providerName: string, - user?: User + authCode: string ) => { const successUrl = new URL(config.oauth2!.successRedirect); successUrl.searchParams.set("provider", providerName); - - if (user) { - successUrl.searchParams.set("user", JSON.stringify(user)); - } + successUrl.searchParams.set("code", authCode); res.redirect(successUrl.toString()); }; diff --git a/src/routes/session.routes.ts b/src/routes/session.routes.ts index 9bf71e8..725361f 100644 --- a/src/routes/session.routes.ts +++ b/src/routes/session.routes.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { Request, Response, Router } from "express"; import { Config } from "../interfaces/config.interface"; -import createLogger from "../lib/wintson.logger"; +import createLogger from "../lib/winston.logger"; import express from "express"; import apiResponse from "../utils/api-response"; import twoFactorAuth, { diff --git a/tests/index.test.ts b/tests/index.test.ts index 9e09491..b626cff 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -68,7 +68,7 @@ jest.mock("passport", () => ({ })); // Mock winston logger -jest.mock("../src/lib/wintson.logger", () => ({ +jest.mock("../src/lib/winston.logger", () => ({ __esModule: true, default: jest.fn(() => ({ info: jest.fn(), @@ -292,6 +292,9 @@ describe("AuthCore", () => { // Mock the blacklist functions const jwtRoutes = require("../src/routes/jwt.routes"); + // Ensure functions exist before spying to avoid Property does not exist errors + if (!jwtRoutes.blacklistToken) jwtRoutes.blacklistToken = jest.fn(); + if (!jwtRoutes.isTokenBlacklisted) jwtRoutes.isTokenBlacklisted = jest.fn(); jest.spyOn(jwtRoutes, "blacklistToken").mockImplementation((token) => { return blacklistedTokens.add(token as string); });