UNPKG

@light-auth/core

Version:

light auth core framework agnostic, using arctic

1,163 lines (1,147 loc) 81.1 kB
/*! @light-auth/core v0.3.5 2025-11-13 */ 'use strict'; import * as sha2 from '@oslojs/crypto/sha2'; import * as encoding from '@oslojs/encoding'; import { generateState, generateCodeVerifier, decodeIdToken } from 'arctic'; import { EncryptJWT, jwtDecrypt } from 'jose'; // export async function createJwt(payload: JWTPayload): Promise<JWTPayload> { // const token = await new SignJWT(payload).setProtectedHeader({ alg: "HS256" }).setExpirationTime("30 days").setIssuedAt().sign(SECRET); // const jwt = await parseJwt(token); // return jwt; // } // export async function stringifyJwt(payload: JWTPayload): Promise<string> { // const token = await new SignJWT(payload).setProtectedHeader({ alg: "HS256" }).setExpirationTime("30 days").setIssuedAt().sign(SECRET); // return token; // } // export async function parseJwt(token: string): Promise<JWTPayload> { // const { payload } = await jwtVerify(token, SECRET); // return payload; // } async function encryptJwt(payload, secret) { const token = await new EncryptJWT(payload).setProtectedHeader({ alg: "dir", enc: "A128CBC-HS256" }).encrypt(new TextEncoder().encode(secret)); return token; } async function decryptJwt(token, secret) { const { payload } = await jwtDecrypt(token, new TextEncoder().encode(secret)); return payload; } const DEFAULT_SESSION_NAME = "light_auth_session"; const DEFAULT_SESSION_EXPIRATION = 60 * 60 * 24 * 30; // 30 days const INTERNAL_SECRET_VALUE = "_HARD_CODED_SECRET_32_CHARS_LONG_!@#123"; const DEFAULT_BASE_PATH = "/api/auth"; /** * Helper function to create an OAuth provider with explicit type. * This ensures backward compatibility and makes the type explicit. * * @param provider The OAuth provider configuration * @returns A typed OAuth provider */ function createOAuthProvider(provider) { return { type: "oauth", ...provider, }; } /** * Helper function to create a credentials provider with explicit type. * * @param provider The credentials provider configuration * @returns A typed credentials provider */ function createCredentialsProvider(provider) { return { type: "credentials", ...provider, }; } /** * Backward compatibility: Converts a legacy provider (without type field) to OAuth provider. * This allows existing code to continue working without changes. * * @param provider A provider that may or may not have the type field * @returns A properly typed provider */ function ensureProviderType(provider) { // If it already has a type, return as-is if ("type" in provider && (provider.type === "oauth" || provider.type === "credentials")) { return provider; } // If it has an arctic property, it's an OAuth provider (legacy format) if ("arctic" in provider) { return { type: "oauth", ...provider, }; } // If it has verifyCredentials, it's a credentials provider (shouldn't happen but for safety) if ("verifyCredentials" in provider) { return provider; } throw new Error("light-auth: Invalid provider configuration. Provider must be either OAuth or Credentials type."); } /** * Checks the configuration and throws an error if any required fields are missing. * @param config The configuration object to check. * @returns The checked configuration object. * @throws Error if any required fields are missing. */ function checkConfig(config, providerName) { if (!config.env) throw new Error("light-auth: env is required"); if (!config.env["LIGHT_AUTH_SECRET_VALUE"]) throw new Error("LIGHT_AUTH_SECRET_VALUE is required in environment variables"); if (!config.basePath) config.basePath = resolveBasePath(config.basePath, config.env); if (!Array.isArray(config.providers) || config.providers.length === 0) throw new Error("light-auth: At least one provider is required"); if (config.router == null) throw new Error("light-auth: router is required"); if (config.sessionStore == null) throw new Error("light-auth: sessionStore is required"); // Ensure all providers have the correct type field for backward compatibility config.providers = config.providers.map((p) => ensureProviderType(p)); // if providerName is provider, check if the provider is in the config if (providerName && !config.providers.some((p) => p.providerName.toLocaleLowerCase() == providerName.toLocaleLowerCase())) throw new Error(`light-auth: Provider ${providerName} not found`); const provider = !providerName ? config.providers[0] : config.providers.find((p) => p.providerName.toLocaleLowerCase() == providerName.toLocaleLowerCase()); if (!provider) throw new Error(`light-auth: Provider ${providerName} not found`); return { ...config, provider, }; } /** get the max age from the environment variable or use the default value */ function getSessionExpirationMaxAge() { let maxAge = process.env.LIGHT_AUTH_SESSION_EXPIRATION ? parseInt(process.env.LIGHT_AUTH_SESSION_EXPIRATION, 10) : DEFAULT_SESSION_EXPIRATION; if (isNaN(maxAge) || maxAge <= 0) maxAge = DEFAULT_SESSION_EXPIRATION; // Fallback if invalid return maxAge; } /** Resolves the basePath, defaults to "/api/default" if not provided or falsy */ function resolveBasePath(basePath, env) { let resolvedBasePath = basePath || env?.["LIGHT_AUTH_BASE_PATH"] || DEFAULT_BASE_PATH; if (!resolvedBasePath.startsWith("/")) resolvedBasePath = `/${resolvedBasePath}`; // Ensure the base path does not end with "/" if (resolvedBasePath.endsWith("/")) resolvedBasePath = resolvedBasePath.slice(0, -1); // Ensure the base path does not contain double slashes if (resolvedBasePath.includes("//")) resolvedBasePath = resolvedBasePath.replace(/\/\//g, "/"); return resolvedBasePath; } function buildSecret(env) { if (!env) throw new Error("light-auth: config.env is required"); if (!env.LIGHT_AUTH_SECRET_VALUE) throw new Error("light-auth: environment variable LIGHT_AUTH_SECRET_VALUE is required"); const secret = (env.LIGHT_AUTH_SECRET_VALUE + INTERNAL_SECRET_VALUE).slice(0, 32); if (secret.length < 32) throw new Error("light-auth: secret must be at least 32 characters long"); return secret; } function buildFullUrl({ url, incomingHeaders }) { if (url.startsWith("http")) return new URL(url).toString(); let reqHost = incomingHeaders?.get("host") ?? incomingHeaders?.get("x-forwarded-host"); const isServerSide = typeof window === "undefined"; // If the host is not present or not on server side, return the url as is if (!reqHost || !isServerSide) return url; // build the full url from the url and the host const sanitizedEndpoint = url.startsWith("/") ? url : `/${url}`; let protocol = "http"; // Prefer x-forwarded-proto headers for protocol const xfp = incomingHeaders?.get("x-forwarded-proto") || incomingHeaders?.get("x-forwarded-protocol"); if (xfp && xfp.split(",")[0].trim() === "https") { protocol = "https"; } //TODO : if x-forwarded-host is not present, check if we are in production or not const host = reqHost ?? "localhost:3000"; const sanitizedHost = host.endsWith("/") ? host.slice(0, -1) : host; return new URL(sanitizedEndpoint, `${protocol}://${sanitizedHost}`).toString(); } /** * Type guard to check if a provider is an OAuth provider. * @param provider The provider to check * @returns True if the provider is an OAuth provider */ function isOAuthProvider(provider) { return provider.type === "oauth"; } /** * Type guard to check if a provider is a credentials provider. * @param provider The provider to check * @returns True if the provider is a credentials provider */ function isCredentialsProvider(provider) { return provider.type === "credentials"; } /** * RegExp to match cookie-name in RFC 6265 sec 4.1.1 * This refers out to the obsoleted definition of token in RFC 2616 sec 2.2 * which has been replaced by the token definition in RFC 7230 appendix B. * * cookie-name = token * token = 1*tchar * tchar = "!" / "#" / "$" / "%" / "&" / "'" / * "*" / "+" / "-" / "." / "^" / "_" / * "`" / "|" / "~" / DIGIT / ALPHA * * Note: Allowing more characters - https://github.com/jshttp/cookie/issues/191 * Allow same range as cookie value, except `=`, which delimits end of name. */ const cookieNameRegExp = /^[\u0021-\u003A\u003C\u003E-\u007E]+$/; /** * RegExp to match cookie-value in RFC 6265 sec 4.1.1 * * cookie-value = *cookie-octet / ( DQUOTE *cookie-octet DQUOTE ) * cookie-octet = %x21 / %x23-2B / %x2D-3A / %x3C-5B / %x5D-7E * ; US-ASCII characters excluding CTLs, * ; whitespace DQUOTE, comma, semicolon, * ; and backslash * * Allowing more characters: https://github.com/jshttp/cookie/issues/191 * Comma, backslash, and DQUOTE are not part of the parsing algorithm. */ const cookieValueRegExp = /^[\u0021-\u003A\u003C-\u007E]*$/; /** * RegExp to match domain-value in RFC 6265 sec 4.1.1 * * domain-value = <subdomain> * ; defined in [RFC1034], Section 3.5, as * ; enhanced by [RFC1123], Section 2.1 * <subdomain> = <label> | <subdomain> "." <label> * <label> = <let-dig> [ [ <ldh-str> ] <let-dig> ] * Labels must be 63 characters or less. * 'let-dig' not 'letter' in the first char, per RFC1123 * <ldh-str> = <let-dig-hyp> | <let-dig-hyp> <ldh-str> * <let-dig-hyp> = <let-dig> | "-" * <let-dig> = <letter> | <digit> * <letter> = any one of the 52 alphabetic characters A through Z in * upper case and a through z in lower case * <digit> = any one of the ten digits 0 through 9 * * Keep support for leading dot: https://github.com/jshttp/cookie/issues/173 * * > (Note that a leading %x2E ("."), if present, is ignored even though that * character is not permitted, but a trailing %x2E ("."), if present, will * cause the user agent to ignore the attribute.) */ const domainValueRegExp = /^([.]?[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)([.][a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i; /** * RegExp to match path-value in RFC 6265 sec 4.1.1 * * path-value = <any CHAR except CTLs or ";"> * CHAR = %x01-7F * ; defined in RFC 5234 appendix B.1 */ const pathValueRegExp = /^[\u0020-\u003A\u003D-\u007E]*$/; const __toString = Object.prototype.toString; const NullObject = /* @__PURE__ */ (() => { const C = function () { }; C.prototype = Object.create(null); return C; })(); /** * Parse a cookie header. * * Parse the given cookie header string into an object * The object has the various cookies as keys(names) => values */ function parse(str, options) { const obj = new NullObject(); const len = str.length; // RFC 6265 sec 4.1.1, RFC 2616 2.2 defines a cookie name consists of one char minimum, plus '='. if (len < 2) return obj; const dec = decode; let index = 0; do { const eqIdx = str.indexOf("=", index); if (eqIdx === -1) break; // No more cookie pairs. const colonIdx = str.indexOf(";", index); const endIdx = colonIdx === -1 ? len : colonIdx; if (eqIdx > endIdx) { // backtrack on prior semicolon index = str.lastIndexOf(";", eqIdx - 1) + 1; continue; } const keyStartIdx = startIndex(str, index, eqIdx); const keyEndIdx = endIndex(str, eqIdx, keyStartIdx); const key = str.slice(keyStartIdx, keyEndIdx); // only assign once if (obj[key] === undefined) { let valStartIdx = startIndex(str, eqIdx + 1, endIdx); let valEndIdx = endIndex(str, endIdx, valStartIdx); const value = dec(str.slice(valStartIdx, valEndIdx)); obj[key] = value; } index = endIdx + 1; } while (index < len); return obj; } function startIndex(str, index, max) { do { const code = str.charCodeAt(index); if (code !== 0x20 /* */ && code !== 0x09 /* \t */) return index; } while (++index < max); return max; } function endIndex(str, index, min) { while (index > min) { const code = str.charCodeAt(--index); if (code !== 0x20 /* */ && code !== 0x09 /* \t */) return index + 1; } return min; } /** * Serialize data into a cookie header. * * Serialize a name value pair into a cookie string suitable for * http headers. An optional options object specifies cookie parameters. * * serialize('foo', 'bar', { httpOnly: true }) * => "foo=bar; httpOnly" */ function serialize(name, val, options) { const enc = options?.encode || encodeURIComponent; if (!cookieNameRegExp.test(name)) { throw new TypeError(`argument name is invalid: ${name}`); } const value = enc(val); if (!cookieValueRegExp.test(value)) { throw new TypeError(`argument val is invalid: ${val}`); } let str = name + "=" + value; if (!options) return str; if (options.maxAge !== undefined) { if (!Number.isInteger(options.maxAge)) { throw new TypeError(`option maxAge is invalid: ${options.maxAge}`); } str += "; Max-Age=" + options.maxAge; } if (options.domain) { if (!domainValueRegExp.test(options.domain)) { throw new TypeError(`option domain is invalid: ${options.domain}`); } str += "; Domain=" + options.domain; } if (options.path) { if (!pathValueRegExp.test(options.path)) { throw new TypeError(`option path is invalid: ${options.path}`); } str += "; Path=" + options.path; } if (options.expires) { if (!isDate(options.expires) || !Number.isFinite(options.expires.valueOf())) { throw new TypeError(`option expires is invalid: ${options.expires}`); } str += "; Expires=" + options.expires.toUTCString(); } if (options.httpOnly) { str += "; HttpOnly"; } if (options.secure) { str += "; Secure"; } if (options.partitioned) { str += "; Partitioned"; } if (options.priority) { const priority = typeof options.priority === "string" ? options.priority.toLowerCase() : undefined; switch (priority) { case "low": str += "; Priority=Low"; break; case "medium": str += "; Priority=Medium"; break; case "high": str += "; Priority=High"; break; default: throw new TypeError(`option priority is invalid: ${options.priority}`); } } if (options.sameSite) { const sameSite = typeof options.sameSite === "string" ? options.sameSite.toLowerCase() : options.sameSite; switch (sameSite) { case true: case "strict": str += "; SameSite=Strict"; break; case "lax": str += "; SameSite=Lax"; break; case "none": str += "; SameSite=None"; break; default: throw new TypeError(`option sameSite is invalid: ${options.sameSite}`); } } return str; } /** * URL-decode string value. Optimized to skip native call when no %. */ function decode(str) { if (str.indexOf("%") === -1) return str; try { return decodeURIComponent(str); } catch (e) { return str; } } /** * Determine if value is a Date. */ function isDate(val) { return __toString.call(val) === "[object Date]"; } const createLightAuthRouter = () => { return { redirectTo({ url, init }) { const status = init?.status ?? 302; const headers = new Headers(init?.headers); const response = new Response("Redirecting...", { ...(init ?? {}), status, headers, }); if (!headers.has("location")) { headers.set("Location", url); } return response; }, getCookies({ req, search }) { const cookies = req?.headers.get("cookie"); if (!cookies) return []; const requestCookies = parse(cookies); const result = []; const searchRegex = typeof search === "string" ? new RegExp(search, "i") : search; for (const [key, value] of Object.entries(requestCookies)) { if (!search || !searchRegex || searchRegex.test(key)) result.push({ name: key.trim(), value: value || "" }); } return result; }, setCookies({ res, cookies, init }) { const status = init?.status ?? 200; const headers = new Headers(init?.headers); const response = new Response(null, { ...(init ?? {}), status, headers, }); if (!cookies || cookies.length === 0) { return response; } for (const cookie of cookies) { const stateCookie = serialize(cookie.name, cookie.value, { path: cookie.path, httpOnly: cookie.httpOnly, secure: cookie.secure, sameSite: cookie.sameSite, maxAge: cookie.maxAge, }); response.headers.set("Set-Cookie", stateCookie); } return res; }, returnJson({ res, data, init }) { const json = data ? JSON.stringify(data) : undefined; const status = init?.status ?? 200; const headers = new Headers(init?.headers); if (!headers.has("content-length")) { const encoder = new TextEncoder(); headers.set("content-length", encoder.encode(json).byteLength.toString()); } if (!headers.has("content-type")) { headers.set("content-type", "application/json"); } return new Response(json, { ...(init ?? {}), status, headers, }); }, getHeaders({ req, search }) { const headers = req?.headers; if (!headers) return new Headers(); const result = new Headers(); const searchRegex = typeof search === "string" ? new RegExp(search, "i") : search; for (const [key, value] of headers.entries()) { if (!search || !searchRegex) result.set(key, value); else if (searchRegex.test(key)) result.set(key, value); } return result; }, getUrl({ endpoint, req }) { const url = endpoint ?? req?.url; if (!url) throw new Error("light-auth: No url provided and no request object available in getUrl of nextJsLightAuthRouter."); if (url.startsWith("http") || !req) return url; const fullUrl = buildFullUrl({ url, incomingHeaders: req.headers }); return fullUrl.toString(); }, getRequest({ req }) { if (!req) throw new Error("light-auth: No request object provided in getRequest of nextJsLightAuthRouter."); return req; }, }; }; const createLightAuthSessionStore = () => { return { getSession: async ({ env, req, }) => { if (!req) throw new Error("light-auth: Request is required in getSession function of light-auth session store"); const cookieHeader = req.headers.get("Cookie"); if (!cookieHeader) return null; const requestCookies = parse(cookieHeader); if (!requestCookies) return null; const session = requestCookies[DEFAULT_SESSION_NAME]; if (!session) return null; try { const decryptedSession = await decryptJwt(session, buildSecret(env)); return decryptedSession; } catch (error) { console.error("Failed to decrypt session:", error); return null; } }, setSession: async ({ env, session, res, }) => { if (!res) throw new Error("light-auth: Response is required in setSession of light-auth session store"); const value = await encryptJwt(session, buildSecret(env)); // Check the size of the cookie value in bytes const encoder = new TextEncoder(); const valueBytes = encoder.encode(value); if (valueBytes.length > 4096) throw new Error("light-auth: Cookie value exceeds 4096 bytes, which may not be supported by your browser."); // get the cookie expiration time const maxAge = getSessionExpirationMaxAge(); const cookieString = serialize(DEFAULT_SESSION_NAME, value, { httpOnly: true, secure: true, sameSite: "lax", path: "/", maxAge: maxAge, }); res.headers.append("Set-Cookie", cookieString); return session; }, deleteSession: async ({ res, }) => { if (!res) throw new Error("light-auth: Response is required in deleteSessions of light-auth session store"); const serialized = serialize(DEFAULT_SESSION_NAME, "", { httpOnly: true, path: "/", maxAge: 0, }); res.headers.append("Set-Cookie", serialized); }, generateSessionId() { return Math.random().toString(36).slice(2); }, }; }; /** * this function is used to get the client IP address from the request headers. * @param headers - The headers from the request. * @returns The client IP address or null if not found. */ function getClientIp(headers) { const forwardedFor = headers.get("x-forwarded-for"); if (forwardedFor) { // x-forwarded-for can contain multiple IPs, we take the first one return forwardedFor.split(",")[0].trim(); } const remoteAddress = headers.get("x-real-ip") || headers.get("remote-addr"); if (remoteAddress) { return remoteAddress.trim(); } // Fallback to the connection's remote address return headers.get("host") || null; } function isLocalIp(ip) { // Check if the IP is a loopback address if (ip === "127.0.1" || ip === "::1") { return true; } // Check if the IP is in the private IP ranges const privateIpRanges = [ /^10\./, // /^172\.(1[6-9]|2[0-9]|3[0-1])\./, // /^192\.168\./, // /^169\.254\./, // Link-local addresses /^fc00:/, // Unique local addresses (ULA) /^fe80:/, // Link-local addresses in IPv6 /^::1$/, // IPv6 loopback address /^::ffff:127\.0\.0\.1$/, // IPv6 representation of IPv4 loopback /^::ffff:0:0:0:1$/, // IPv6 representation of IPv4 loopback ]; return privateIpRanges.some((range) => range.test(ip)); } function isValidIp(ip) { // Simple regex to validate IPv4 and IPv6 addresses const ipv4Pattern = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; const ipv6Pattern = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::([0-9a-fA-F]{1,4}:){1,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:(:[0-9a-fA-F]{1,4}){7}|:|::/; return ipv4Pattern.test(ip) || ipv6Pattern.test(ip); } function isValidLocalIp(ip) { return isLocalIp(ip) && isValidIp(ip); } function isValidPublicIp(ip) { return !isLocalIp(ip) && isValidIp(ip); } const defaultOptions = { maxRequestsPerTimeWindowsMs: 10, timeWindowMs: 1000, // 1 sec errorMessage: "Too many requests. Please try again later.", statusCode: 429, shouldApplyRateLimit: () => true, // Default to always apply rate limit }; const memory = new Map(); const createLightAuthRateLimiter = (opt = defaultOptions) => { return { onRateLimit(args) { const { url, headers } = args; // define the key for the rate limit based on the request IP and URL const rateLimitKey = `${getClientIp(headers)}__${url}`; // by default, we apply the rate limit unless the function exists and returns false if (opt.shouldApplyRateLimit && !opt.shouldApplyRateLimit(args)) { return undefined; // Skip rate limiting if the function returns false } // Get the current time and the rate limit data from memory const now = Date.now(); const rateLimit = memory.get(rateLimitKey) || { key: rateLimitKey, count: 0, lastRequestDateTime: now, }; const timeWindowMs = opt.timeWindowMs || defaultOptions.timeWindowMs; // check if we have already over the time window limit if (rateLimit.lastRequestDateTime + timeWindowMs < now) { // Reset the count if the last request was made outside the time window rateLimit.count = 0; rateLimit.lastRequestDateTime = now; memory.set(rateLimitKey, rateLimit); return undefined; // No rate limit exceeded, continue processing the request } // get the time window and max requests per time window from options or use defaults const maxRequestsPerTimeWindowsMs = opt.maxRequestsPerTimeWindowsMs || defaultOptions.maxRequestsPerTimeWindowsMs; // the retryAfter defines how long the client should wait before making another request const retryAfter = rateLimit.lastRequestDateTime + timeWindowMs - now; // Check if the rate limit has been exceeded const isRateLimitExceeded = rateLimit.count >= maxRequestsPerTimeWindowsMs && now - rateLimit.lastRequestDateTime < timeWindowMs; // If the last request was made within the time window, increment the count if (!isRateLimitExceeded) { rateLimit.count += 1; rateLimit.lastRequestDateTime = now; memory.set(rateLimitKey, rateLimit); return undefined; // No rate limit exceeded, continue processing the request } // If the rate limit is exceeded, return a response const data = typeof opt.errorMessage === "string" ? { message: opt.errorMessage ?? defaultOptions.errorMessage } : opt.errorMessage ?? { message: defaultOptions.errorMessage }; return { data: data, init: { status: opt.statusCode || defaultOptions.statusCode, statusText: "Too Many Requests", headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(JSON.stringify(data)).toString(), "X-Retry-After": Math.ceil(retryAfter / 1000).toString(), }, }, }; }, }; }; /** * this function is used to make a request to the light auth server * it can be done from the server side or the client side * * it will use the router to get the url and the headers (if server side) */ async function internalFetch(args) { const { config, body, method = "GET", headers } = args; const { router } = config; const env = config.env; const basePath = resolveBasePath(config.basePath, env); // check if we are on the server side or client side // if we are on the server side, we need to use the router to get the url and headers // if we are on the client side, we can use the window object to get the url and headers const isServerSide = typeof window === "undefined"; const bodyBytes = body ? new TextEncoder().encode(body.toString()) : undefined; // get all the headers from the request let requestHeaders = headers ?? null; if (router && isServerSide) requestHeaders = await router.getHeaders({ env, basePath, ...args }); // get the full url from the router if available let url = args.endpoint; if (router && isServerSide) url = await router.getUrl({ env, basePath, ...args }); const request = bodyBytes ? new Request(url.toString(), { method: method, headers: requestHeaders ?? new Headers(), body: bodyBytes }) : new Request(url.toString(), { method: method, headers: requestHeaders ?? new Headers() }); let response = null; try { response = await fetch(request); } catch (error) { console.error("Error:", error); throw new Error(`light-auth: Request failed with error ${error}`); } if (!response || !response.ok) { throw new Error(`light-auth: Request failed with status ${response?.status}`); } const contentType = response.headers.get("Content-Type"); if (contentType && contentType.includes("application/x-www-form-urlencoded")) { const formResponse = await response.text(); const formData = new URLSearchParams(formResponse); const result = {}; for (const [key, value] of formData.entries()) { result[key] = value; } return result; } if (contentType && (contentType.includes("application/json") || contentType.includes("text/plain"))) { const jsonResponse = await response.json(); return jsonResponse; } if (contentType && contentType.includes("application/octet-stream")) { const blobResponse = await response.blob(); return blobResponse; } return null; } function createSha256(value) { const codeChallengeBytes = sha2.sha256(new TextEncoder().encode(value)); return encoding.encodeBase64urlNoPadding(codeChallengeBytes); } function generateRandomCsrf() { const randomValues = new Uint8Array(32); crypto.getRandomValues(randomValues); return encoding.encodeBase64urlNoPadding(randomValues); } async function getCsrfToken$1(args) { const isServerSide = typeof window === "undefined"; if (isServerSide) return; const { config } = args; // Get a csrf token from the server const endpoint = `${config.basePath}/csrf`; const csrfToken = await internalFetch({ endpoint, method: "POST", ...args }); if (!csrfToken) throw new Error("light-auth: Failed to get csrf token"); // Check if the csrf token cookie, called light_auth_csrf_token exist const csrfTokenCookie = document.cookie.split("; ").find((row) => row.startsWith("light_auth_csrf_token=")); if (csrfTokenCookie) window.document.cookie = `light_auth_csrf_token=; path=/; max-age=0;`; // Set the csrf token in the cookie store window.document.cookie = `light_auth_csrf_token=${csrfToken.csrfTokenHash}.${csrfToken.csrfToken}; path=/; secure=true}`; } /** * Using the origin header to verify if the request is coming from a trusted source * See https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations#allowed-origins-advanced * TODO: Add support for reverse proxies or multi-layered backend architectures */ async function checkCsrfOrigin(headers) { if (!headers || Array.from(headers.entries()).length === 0) return true; const origin = headers.get("origin"); // if we have no origin or referer, we are not in a CORS request // and we are not in a CORS request, so we can skip the check if (!origin) return true; const host = headers.get("host") ?? headers.get("x-forwarded-host"); // if we have no host, we are not in a CORS request // and we are not in a CORS request, so we can skip the check if (!host) throw new Error("light-auth: Missing host header for CSRF verification"); // Allow for proxies or load balancers by checking x-forwarded-host and x-forwarded-proto // Parse host and origin to compare their hostnames try { const originUrl = new URL(origin); // Allow for multiple hosts in x-forwarded-host (comma separated) const hostList = host.split(",").map((h) => h.trim().toLowerCase()); const originHost = originUrl.host.toLowerCase(); // Allow if the origin host matches any of the hosts in the header if (!hostList.includes(originHost)) { throw new Error(`light-auth: CSRF verification failed. Origin (${originHost}) does not match host header (${hostList.join(", ")})`); } } catch (err) { throw new Error("light-auth: Invalid origin header for CSRF verification"); } return true; } function createCsrfToken(secret) { // New CSRF token const csrfToken = generateRandomCsrf(); const csrfTokenHash = createSha256(`${csrfToken}${secret}`); return { csrfToken, csrfTokenHash }; } function validateCsrfToken(cookies, secret) { const cookieValue = cookies.find((cookie) => cookie.name === "light_auth_csrf_token")?.value; const csrfTokenHash = cookieValue?.split(".")[0]; const csrfToken = cookieValue?.split(".")[1]; const expectedCsrfTokenHash = createSha256(`${csrfToken}${secret}`); if (!csrfTokenHash || csrfTokenHash !== expectedCsrfTokenHash) return false; return true; } async function logoutAndRevokeTokenHandler(args) { const { config, revokeToken = true, callbackUrl = "/", checkCsrf = true } = args; const { userAdapter, router, sessionStore, env, basePath } = checkConfig(config); // get the session const session = await sessionStore.getSession({ env, basePath, ...args }); if (!session || !session.id) return await router.redirectTo({ env, basePath, url: callbackUrl, ...args }); // Check if CSRF validation is required // it could be disable for direct call from a post action issued by the SSR framework if (checkCsrf) { const secret = buildSecret(env); const cookies = await router.getCookies({ env, basePath, ...args }); const csrfIsValid = validateCsrfToken(cookies, secret); if (!csrfIsValid) throw new Error("Invalid CSRF token"); } // get the provider name from the session const providerName = session?.providerName; // get the provider from the config const provider = config.providers?.find((p) => p.providerName === providerName); // get the user from the session store if (userAdapter) { const user = await userAdapter.getUser({ env, basePath, providerUserId: session.providerUserId.toString(), ...args }); if (user) { // delete the user if (user) await userAdapter.deleteUser({ env, basePath, user, ...args }); var token = user?.accessToken; // revoke the token if the provider supports it (OAuth providers only) if (token && provider && revokeToken && provider.type === "oauth") { // Revoke the token if the provider supports it if (typeof provider.arctic.revokeToken === "function") { try { await provider.arctic.revokeToken(token); } catch (e) { console.warn("Failed to revoke token:", e); } } } } } try { // delete the session cookie await sessionStore.deleteSession({ env, basePath, session, ...args }); } catch { } try { const stateCookieDelete = { name: `${providerName}_light_auth_state`, value: "", path: "/", maxAge: 0 }; const codeVerifierCookieDelete = { name: `${providerName}_light_auth_code_verifier`, value: "", path: "/", maxAge: 0 }; const callbackUrlCookieDelete = { name: `${providerName}_light_auth_callback_url`, value: "", path: "/", maxAge: 0 }; const csrfCookieDelete = { name: "light_auth_csrf_token", value: "", maxAge: 0 }; // delete the cookies await router.setCookies({ env, basePath, cookies: [stateCookieDelete, codeVerifierCookieDelete, callbackUrlCookieDelete, csrfCookieDelete], ...args }); } catch { } return await router.redirectTo({ env, basePath, url: callbackUrl, ...args }); } /** * Redirects the user to the provider login page. */ async function redirectToProviderLoginHandler(args) { const { config, providerName, checkCsrf = true } = args; const { provider, router, env, basePath } = checkConfig(config, providerName); const state = generateState(); const codeVerifier = generateCodeVerifier(); // Check if CSRF validation is required // it could be disable for direct call from a post action issued by the SSR framework if (checkCsrf) { const secret = buildSecret(env); const cookies = await router.getCookies({ env, basePath, ...args }); const csrfIsValid = validateCsrfToken(cookies, secret); if (!csrfIsValid) throw new Error("Invalid CSRF token"); } // Handle credentials provider differently - it doesn't use OAuth redirect flow if (provider.type === "credentials") { throw new Error("light-auth: Credentials provider login should be handled through POST to /credentials/login endpoint, not redirect-to-provider"); } // OAuth provider flow // Using Set to ensure unique scopes // and adding default scopes const scopeSet = new Set(provider.scopes ?? []); scopeSet.add("openid"); scopeSet.add("profile"); scopeSet.add("email"); const scopes = Array.from(scopeSet); const url = provider.arctic.createAuthorizationURL(state, codeVerifier, scopes); // add additional params to the url if (provider.searchParams) { for (const [key, value] of provider.searchParams) { url.searchParams.append(key, value); } } const newHeaders = new Headers(); // add additional headers if (provider.headers) for (const [key, value] of provider.headers) newHeaders.append(key, value); const stateCookie = { name: `${provider.providerName}_light_auth_state`, value: state, path: "/", httpOnly: true, secure: env["NODE_ENV"] === "production", sameSite: "lax", maxAge: 60 * 10, // 10 minutes }; const codeVerifierCookie = { name: `${provider.providerName}_light_auth_code_verifier`, value: codeVerifier, path: "/", httpOnly: true, secure: env["NODE_ENV"] === "production", sameSite: "lax", maxAge: 60 * 10, // 10 minutes }; const callbackUrlCookie = { name: `${provider.providerName}_light_auth_callback_url`, value: args.callbackUrl ? decodeURIComponent(args.callbackUrl) : "/", path: "/", httpOnly: true, secure: env["NODE_ENV"] === "production", sameSite: "lax", maxAge: 60 * 10, // 10 minutes }; // delete the csrf token cookie const csrfCookieDelete = { name: "light_auth_csrf_token", value: "", maxAge: 0 }; // set the cookies in the response const res = await router.setCookies({ env, basePath, cookies: [stateCookie, codeVerifierCookie, callbackUrlCookie, csrfCookieDelete], ...args }); return await router.redirectTo({ env, basePath, url: url.toString(), headers: newHeaders, res, ...args }); } function createSigninServerFunction(config) { return async (args = {}) => { // check if we are on the server side or client side const isServerSide = typeof window === "undefined"; if (!isServerSide) throw new Error("light-auth: signin function should not be called on the client side. prefer to use the client version"); const { providerName, callbackUrl = "/" } = args; return await redirectToProviderLoginHandler({ config, providerName, callbackUrl: encodeURIComponent(callbackUrl), checkCsrf: false, ...args }); }; } function createSignoutServerFunction(config) { return async (args = {}) => { const isServerSide = typeof window === "undefined"; if (!isServerSide) throw new Error("light-auth: signin function should not be called on the client side. prefer to use the client version"); const { revokeToken = true, callbackUrl = "/" } = args; return await logoutAndRevokeTokenHandler({ config, revokeToken, callbackUrl: encodeURIComponent(callbackUrl), checkCsrf: false, ...args }); }; } function createFetchSessionServerFunction(config) { return async (args) => { try { const isServerSide = typeof window === "undefined"; if (!isServerSide) throw new Error("light-auth: signin function should not be called on the client side. prefer to use the client version"); // get the session from the server using the api endpoint const session = await internalFetch({ config, method: "POST", endpoint: `${config.basePath}/session`, ...args }); return session ?? null; } catch (error) { console.error("Error:", error); return null; } }; } function createSetSessionServerFunction(config) { return async (args) => { try { const isServerSide = typeof window === "undefined"; if (!isServerSide) throw new Error("light-auth: signin function should not be called on the client side. prefer to use the client version"); // get the session from the server using the api endpoint const newSession = await internalFetch({ config, method: "POST", body: JSON.stringify(args?.session), endpoint: `${config.basePath}/set_session`, ...args, }); return newSession ?? null; } catch (error) { console.error("Error:", error); return null; } }; } function createSetUserServerFunction(config) { return async (args) => { try { const isServerSide = typeof window === "undefined"; if (!isServerSide) throw new Error("light-auth: signin function should not be called on the client side. prefer to use the client version"); const newUser = await internalFetch({ config, method: "POST", body: JSON.stringify(args?.user), endpoint: `${config.basePath}/set_user`, ...args, }); return newUser ?? null; } catch (error) { console.error("Error:", error); return null; } }; } function createFetchUserServerFunction(config) { return async (args) => { try { const isServerSide = typeof window === "undefined"; if (!isServerSide) throw new Error("light-auth: signin function should not be called on the client side. prefer to use the client version"); const endpoint = args?.providerUserId ? `${config.basePath}/user/${args.providerUserId}` : `${config.basePath}/user`; // get the user from the user adapter const user = await internalFetch({ config, method: "POST", endpoint, ...args }); return user ?? null; } catch (error) { console.error("light-auth: Error in createLightAuthUserFunction:", error); return null; } }; } async function providerCallbackHandler(args) { const { config, providerName } = args; let currentRouter = null; let callbackUrl = "/"; const { router, userAdapter, provider, sessionStore, env, basePath } = checkConfig(config, providerName); try { currentRouter = router; // Credentials providers don't use OAuth callback if (provider.type === "credentials") { throw new Error("light-auth: Credentials provider should not use callback handler"); } const url = await currentRouter.getUrl({ env, basePath, ...args }); const reqUrl = new URL(url); const code = reqUrl.searchParams.get("code"); const state = reqUrl.searchParams.get("state"); if (code === null || state === null) throw new Error("light-auth: state or code are missing from the request"); // get the cookies from headers const cookies = await currentRouter.getCookies({ env, basePath, search: new RegExp(`^${provider.providerName}_light_auth_(code_verifier|state|callback_url)$`), ...args, }); if (!cookies) throw new Error("light-auth: Cookies are missing from the request"); const storedStateCookie = cookies.find((c) => c.name == `${provider.providerName}_light_auth_state`)?.value; const codeVerifierCookie = cookies.find((c) => c.name == `${provider.providerName}_light_auth_code_verifier`)?.value; const callbackUrlCookie = cookies.find((c) => c.name == `${provider.providerName}_light_auth_callback_url`)?.value; callbackUrl = callbackUrlCookie ?? "/"; if (storedStateCookie == null || codeVerifierCookie == null) throw new Error("light-auth: Invalid state or code verifier or callback URL"); // validate the state if (storedStateCookie !== state) throw new Error("light-auth: Invalid state"); // validate the authorization code let tokens = await provider.arctic.validateAuthorizationCode(code, codeVerifierCookie); if (provider.onGetOAuth2Tokens) tokens = await provider.onGetOAuth2Tokens(tokens, args); if (tokens === null) throw new Error("light-auth: Failed to fetch tokens"); // Calculate the access token expiration time // The access token expiration time is the number of seconds until the token expires // The default expiration time is 3600 seconds (1 hour) // https://www.ietf.org/rfc/rfc6749.html#section-4.2.2 let accessTokenExpiresIn = 3600; // default to 1 hour if ("expires_in" in tokens.data && typeof tokens.data.expires_in === "number") { accessTokenExpiresIn = Number(tokens.data.expires_in); } const accessTokenExpiresAt = new Date(Date.now() + accessTokenExpiresIn * 1000); // get the access token const accessToken = tokens.accessToken(); // get the claims from the id token const claims = decodeIdToken(tokens.idToken()); let refresh_token; if (tokens.hasRefreshToken()) refresh_token = tokens.refreshToken(); const id = sessionStore.generateSessionId(); const maxAge = getSessionExpirationMaxAge(); const expiresAt = new Date(Date.now() + maxAge * 1000); let session = { id: id, providerUserId: claims.sub, email: claims.email, name: claims.name, expiresAt: expiresAt, // 30 days providerName: provider.providerName, }; if (config.onSessionSaving) { const sessionSaving = await config.onSessionSaving(session, tokens, args); session = sessionSaving ?? session; } session = await sessionStore.setSession({ env, basePath, session, ...args }); if (config.onSessionSaved) await config.onSessionSaved(session, args); if (userAdapter) { // Omit expiresAt from session when creating user const { expiresAt: sessionExpiresAt, id: sessionId, ...sessionWithoutExpiresAt } = session; let user = { ...sessionWithoutExpiresAt, picture: claims.picture, accessToken: accessToken, accessTokenExpiresAt: new Date(accessTokenExpiresAt), refreshToken: refresh_token, }; if (config.onUserSaving) { const userSaving = await config.onUserSaving(user, tokens, args); // if the user is not null, use it // if the user is null, use the original user user = userSaving ?? user; } user = await userAdapter.setUser({ user, env, basePath, ...args }); if (config.onUserSaved) await config.onUserSaved(user, args); } // delete the cookies try { const stateCookieDelete = { name: `${provider.providerName}_light_auth_state`, value: "", path: "/", maxAge: 0 }; const codeVerifierCookieDelete = { name: `${provider.providerName}_light_auth_code_verifier`, value: "", path: "/", maxAge: 0 }; const callbackUrlCookieDelete = { name: `${provider.pr