From e8a82796e08f26fbcdc915922ba6ae39741e27ca Mon Sep 17 00:00:00 2001 From: Jonathan Cooper Date: Fri, 6 Feb 2026 18:23:33 -0800 Subject: [PATCH] Add initial library --- package.json | 25 ++++++++++++++ src/callback.ts | 87 +++++++++++++++++++++++++++++++++++++++++++++++ src/constants.ts | 10 ++++++ src/index.ts | 5 +++ src/middleware.ts | 38 +++++++++++++++++++++ tsconfig.json | 15 ++++++++ 6 files changed, 180 insertions(+) create mode 100644 package.json create mode 100644 src/callback.ts create mode 100644 src/constants.ts create mode 100644 src/index.ts create mode 100644 src/middleware.ts create mode 100644 tsconfig.json diff --git a/package.json b/package.json new file mode 100644 index 0000000..9ffd5da --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "sf-auth-nextjs-middleware", + "version": "0.1.0", + "description": "Nextjs middleware and callback route for sf-auth", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "files": [ + "src" + ], + "keywords": [ + "nextjs", + "middleware", + "authentication", + "sf-auth" + ], + "license": "MIT", + "peerDependencies": { + "next": ">=13.4.0" + } +} diff --git a/src/callback.ts b/src/callback.ts new file mode 100644 index 0000000..1ef8823 --- /dev/null +++ b/src/callback.ts @@ -0,0 +1,87 @@ +import { NextResponse, type NextRequest } from "next/server"; + +import { AUTH_VALIDATE_URL, DEFAULT_COOKIE_NAMES } from "./constants"; +import type { SfAuthCookieNames } from "./middleware"; + +export type SfAuthCallbackOptions = { + redirectTo: string; + cookieNames?: SfAuthCookieNames; + validateEndpoint?: string; +}; + +type ValidateResponse = { + valid: boolean; + user_id: string; +}; + +const resolveCookieNames = (cookieNames?: SfAuthCookieNames) => ({ + userId: cookieNames?.userId ?? DEFAULT_COOKIE_NAMES.userId, + username: cookieNames?.username ?? DEFAULT_COOKIE_NAMES.username +}); + +const errorResponse = (message: string, status = 400) => + new NextResponse(message, { + status, + headers: { + "content-type": "text/plain; charset=utf-8" + } + }); + +export const createSfAuthCallbackRoute = ( + options: SfAuthCallbackOptions +) => { + const cookieNames = resolveCookieNames(options.cookieNames); + const validateUrl = options.validateEndpoint ?? AUTH_VALIDATE_URL; + + return async (request: NextRequest) => { + const url = new URL(request.url); + const userId = url.searchParams.get("user_id"); + const username = url.searchParams.get("username"); + const key = url.searchParams.get("key"); + + if (!userId || !username || !key) { + return errorResponse("Missing required query parameters."); + } + + let validateResponse: ValidateResponse; + + try { + const response = await fetch(validateUrl, { + method: "POST", + headers: { + "content-type": "application/json" + }, + body: JSON.stringify({ user_id: userId, key }) + }); + + if (!response.ok) { + return errorResponse("Failed to validate credentials."); + } + + validateResponse = (await response.json()) as ValidateResponse; + } catch (error) { + return errorResponse("Unable to validate credentials.", 500); + } + + if (!validateResponse.valid || validateResponse.user_id !== userId) { + return errorResponse("Invalid credentials."); + } + + const redirectTarget = new URL(options.redirectTo, request.url); + const response = NextResponse.redirect(redirectTarget); + + response.cookies.set(cookieNames.userId, userId, { + httpOnly: true, + sameSite: "lax", + path: "/" + }); + + response.cookies.set(cookieNames.username, username, { + httpOnly: true, + sameSite: "lax", + path: "/" + }); + + return response; + }; +}; diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..c4355b0 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,10 @@ +export const DEFAULT_COOKIE_NAMES = { + userId: "sf_user_id", + username: "sf_username" +} as const; + +export const AUTH_REDIRECT_BASE_URL = + "https://snazzyfellas.com/api/redirect/authenticate"; + +export const AUTH_VALIDATE_URL = + "https://snazzyfellas.com/api/redirect/validate"; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ff1a235 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,5 @@ +export { createSfAuthMiddleware } from "./middleware"; +export type { SfAuthMiddlewareOptions, SfAuthCookieNames } from "./middleware"; + +export { createSfAuthCallbackRoute } from "./callback"; +export type { SfAuthCallbackOptions } from "./callback"; diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..5764d2c --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,38 @@ +import { NextResponse, type NextRequest } from "next/server"; + +import { AUTH_REDIRECT_BASE_URL, DEFAULT_COOKIE_NAMES } from "./constants"; + +export type SfAuthCookieNames = { + userId?: string; + username?: string; +}; + +export type SfAuthMiddlewareOptions = { + cookieNames?: SfAuthCookieNames; +}; + +const resolveCookieNames = (cookieNames?: SfAuthCookieNames) => ({ + userId: cookieNames?.userId ?? DEFAULT_COOKIE_NAMES.userId, + username: cookieNames?.username ?? DEFAULT_COOKIE_NAMES.username +}); + +export const createSfAuthMiddleware = ( + redirectUri: string, + options: SfAuthMiddlewareOptions = {} +) => { + const cookieNames = resolveCookieNames(options.cookieNames); + const redirectUrl = `${AUTH_REDIRECT_BASE_URL}?redirect_uri=${encodeURIComponent( + redirectUri + )}`; + + return (request: NextRequest) => { + const userId = request.cookies.get(cookieNames.userId)?.value; + const username = request.cookies.get(cookieNames.username)?.value; + + if (!userId || !username) { + return NextResponse.redirect(redirectUrl); + } + + return NextResponse.next(); + }; +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..78e2db9 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "declaration": true, + "declarationMap": true, + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true + }, + "include": ["src"] +}