@light-auth/core
Version:
light auth core framework agnostic, using arctic
1,163 lines (1,147 loc) • 81.1 kB
JavaScript
/*! @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