@frank-auth/react
Version:
Flexible and customizable React UI components for Frank Authentication
659 lines (559 loc) • 16.8 kB
text/typescript
import type {XID} from '../types';
// URL parsing and manipulation utilities
export interface ParsedURL {
protocol: string;
hostname: string;
port?: string;
pathname: string;
search: string;
hash: string;
query: Record<string, string | string[]>;
origin: string;
href: string;
}
export const parseURL = (url: string): ParsedURL | null => {
try {
const urlObj = new URL(url);
const query = parseQueryString(urlObj.search);
return {
protocol: urlObj.protocol,
hostname: urlObj.hostname,
port: urlObj.port || undefined,
pathname: urlObj.pathname,
search: urlObj.search,
hash: urlObj.hash,
query,
origin: urlObj.origin,
href: urlObj.href,
};
} catch {
return null;
}
};
export const buildURL = (
base: string,
params?: Record<string, string | number | boolean | undefined | null>
): string => {
try {
const url = new URL(base);
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
url.searchParams.set(key, String(value));
}
}
}
return url.toString();
} catch {
return base;
}
};
export const addQueryParams = (
url: string,
params: Record<string, string | number | boolean | undefined | null>
): string => {
return buildURL(url, params);
};
export const parseQueryString = (queryString: string): Record<string, string | string[]> => {
const params = new URLSearchParams(queryString);
const result: Record<string, string | string[]> = {};
for (const [key, value] of params.entries()) {
if (key in result) {
if (Array.isArray(result[key])) {
(result[key] as string[]).push(value);
} else {
result[key] = [result[key] as string, value];
}
} else {
result[key] = value;
}
}
return result;
};
export const buildQueryString = (params: Record<string, any>): string => {
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
if (Array.isArray(value)) {
for (const item of value) {
searchParams.append(key, String(item));
}
} else {
searchParams.set(key, String(value));
}
}
}
return searchParams.toString();
};
export const removeQueryParams = (url: string, keys: string[]): string => {
try {
const urlObj = new URL(url);
for (const key of keys) {
urlObj.searchParams.delete(key);
}
return urlObj.toString();
} catch {
return url;
}
};
export const getQueryParam = (url: string, key: string): string | null => {
try {
const urlObj = new URL(url);
return urlObj.searchParams.get(key);
} catch {
return null;
}
};
export const hasQueryParam = (url: string, key: string): boolean => {
try {
const urlObj = new URL(url);
return urlObj.searchParams.has(key);
} catch {
return false;
}
};
// Auth-specific URL utilities
export const buildAuthURL = (
baseUrl: string,
type: 'signin' | 'signup' | 'reset' | 'verify',
params?: {
organizationId?: XID;
redirectUrl?: string;
invitationToken?: string;
emailAddress?: string;
mode?: string;
[key: string]: any;
}
): string => {
const paths = {
signin: '/sign-in',
signup: '/sign-up',
reset: '/reset-password',
verify: '/verify',
};
const url = new URL(paths[type], baseUrl);
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
url.searchParams.set(key, String(value));
}
}
}
return url.toString();
};
export const buildOAuthURL = (
provider: string,
clientId: string,
redirectUri: string,
options?: {
state?: string;
scope?: string | string[];
responseType?: string;
codeChallenge?: string;
codeChallengeMethod?: string;
organizationId?: XID;
[key: string]: any;
}
): string => {
const baseUrls: Record<string, string> = {
google: 'https://accounts.google.com/o/oauth2/v2/auth',
github: 'https://github.com/login/oauth/authorize',
microsoft: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
facebook: 'https://www.facebook.com/v18.0/dialog/oauth',
apple: 'https://appleid.apple.com/auth/authorize',
twitter: 'https://twitter.com/i/oauth2/authorize',
linkedin: 'https://www.linkedin.com/oauth/v2/authorization',
discord: 'https://discord.com/api/oauth2/authorize',
slack: 'https://slack.com/oauth/v2/authorize',
spotify: 'https://accounts.spotify.com/authorize',
};
const baseUrl = baseUrls[provider.toLowerCase()];
if (!baseUrl) {
throw new Error(`Unsupported OAuth provider: ${provider}`);
}
const params: Record<string, string> = {
client_id: clientId,
redirect_uri: redirectUri,
response_type: options?.responseType || 'code',
};
if (options?.state) {
params.state = options.state;
}
if (options?.scope) {
params.scope = Array.isArray(options.scope)
? options.scope.join(' ')
: options.scope;
}
if (options?.codeChallenge && options?.codeChallengeMethod) {
params.code_challenge = options.codeChallenge;
params.code_challenge_method = options.codeChallengeMethod;
}
// Add provider-specific parameters
if (options?.organizationId) {
params.organization_id = options.organizationId;
}
// Add any additional parameters
if (options) {
for (const [key, value] of Object.entries(options)) {
if (value !== undefined && value !== null &&
!['state', 'scope', 'responseType', 'codeChallenge', 'codeChallengeMethod', 'organizationId'].includes(key)) {
params[key] = String(value);
}
}
}
return buildURL(baseUrl, params);
};
export const parseOAuthCallback = (url: string): {
code?: string;
state?: string;
error?: string;
errorDescription?: string;
organizationId?: XID;
} => {
const parsed = parseURL(url);
if (!parsed) return {};
return {
code: typeof parsed.query.code === 'string' ? parsed.query.code : undefined,
state: typeof parsed.query.state === 'string' ? parsed.query.state : undefined,
error: typeof parsed.query.error === 'string' ? parsed.query.error : undefined,
errorDescription: typeof parsed.query.error_description === 'string'
? parsed.query.error_description
: undefined,
organizationId: typeof parsed.query.organization_id === 'string'
? parsed.query.organization_id as XID
: undefined,
};
};
export const buildMagicLinkURL = (
baseUrl: string,
token: string,
options?: {
redirectUrl?: string;
organizationId?: XID;
mode?: string;
}
): string => {
const url = new URL('/auth/magic-link', baseUrl);
url.searchParams.set('token', token);
if (options?.redirectUrl) {
url.searchParams.set('redirect_url', options.redirectUrl);
}
if (options?.organizationId) {
url.searchParams.set('organization_id', options.organizationId);
}
if (options?.mode) {
url.searchParams.set('mode', options.mode);
}
return url.toString();
};
export const buildVerificationURL = (
baseUrl: string,
token: string,
type: 'email' | 'phone',
options?: {
redirectUrl?: string;
organizationId?: XID;
}
): string => {
const url = new URL(`/auth/verify-${type}`, baseUrl);
url.searchParams.set('token', token);
if (options?.redirectUrl) {
url.searchParams.set('redirect_url', options.redirectUrl);
}
if (options?.organizationId) {
url.searchParams.set('organization_id', options.organizationId);
}
return url.toString();
};
export const buildPasswordResetURL = (
baseUrl: string,
token: string,
options?: {
redirectUrl?: string;
organizationId?: XID;
}
): string => {
const url = new URL('/auth/reset-password', baseUrl);
url.searchParams.set('token', token);
if (options?.redirectUrl) {
url.searchParams.set('redirect_url', options.redirectUrl);
}
if (options?.organizationId) {
url.searchParams.set('organization_id', options.organizationId);
}
return url.toString();
};
export const buildInvitationURL = (
baseUrl: string,
token: string,
options?: {
redirectUrl?: string;
organizationId?: XID;
}
): string => {
const url = new URL('/auth/invitation', baseUrl);
url.searchParams.set('token', token);
if (options?.redirectUrl) {
url.searchParams.set('redirect_url', options.redirectUrl);
}
if (options?.organizationId) {
url.searchParams.set('organization_id', options.organizationId);
}
return url.toString();
};
// URL validation utilities
export const isValidURL = (url: string): boolean => {
try {
new URL(url);
return true;
} catch {
return false;
}
};
export const isValidHttpURL = (url: string): boolean => {
try {
const urlObj = new URL(url);
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:';
} catch {
return false;
}
};
export const isValidHttpsURL = (url: string): boolean => {
try {
const urlObj = new URL(url);
return urlObj.protocol === 'https:';
} catch {
return false;
}
};
export const isDomainAllowed = (url: string, allowedDomains: string[]): boolean => {
try {
const urlObj = new URL(url);
const hostname = urlObj.hostname.toLowerCase();
return allowedDomains.some(domain => {
const normalizedDomain = domain.toLowerCase();
return hostname === normalizedDomain ||
hostname.endsWith(`.${normalizedDomain}`);
});
} catch {
return false;
}
};
export const isSubdomain = (url: string, parentDomain: string): boolean => {
try {
const urlObj = new URL(url);
const hostname = urlObj.hostname.toLowerCase();
const parent = parentDomain.toLowerCase();
return hostname === parent || hostname.endsWith(`.${parent}`);
} catch {
return false;
}
};
// URL manipulation utilities
export const extractDomain = (url: string): string | null => {
try {
const urlObj = new URL(url);
return urlObj.hostname;
} catch {
return null;
}
};
export const extractRootDomain = (url: string): string | null => {
const domain = extractDomain(url);
if (!domain) return null;
const parts = domain.split('.');
if (parts.length >= 2) {
return parts.slice(-2).join('.');
}
return domain;
};
export const normalizeURL = (url: string): string => {
try {
const urlObj = new URL(url);
// Remove default ports
if ((urlObj.protocol === 'http:' && urlObj.port === '80') ||
(urlObj.protocol === 'https:' && urlObj.port === '443')) {
urlObj.port = '';
}
// Remove trailing slash from pathname
if (urlObj.pathname !== '/' && urlObj.pathname.endsWith('/')) {
urlObj.pathname = urlObj.pathname.slice(0, -1);
}
// Sort query parameters
const params = Array.from(urlObj.searchParams.entries())
.sort(([a], [b]) => a.localeCompare(b));
urlObj.search = '';
for (const [key, value] of params) {
urlObj.searchParams.append(key, value);
}
return urlObj.toString();
} catch {
return url;
}
};
export const joinURL = (...parts: string[]): string => {
if (parts.length === 0) return '';
const [base, ...rest] = parts;
let result = base.replace(/\/+$/, '');
for (const part of rest) {
const cleanPart = part.replace(/^\/+|\/+$/g, '');
if (cleanPart) {
result += '/' + cleanPart;
}
}
return result;
};
export const getURLPath = (url: string): string => {
try {
const urlObj = new URL(url);
return urlObj.pathname;
} catch {
return '';
}
};
export const getURLParams = (url: string): Record<string, string | string[]> => {
try {
const urlObj = new URL(url);
return parseQueryString(urlObj.search);
} catch {
return {};
}
};
// Redirect utilities
export const isSafeRedirectURL = (
url: string,
allowedDomains: string[],
allowRelative = true
): boolean => {
if (!url) return false;
// Allow relative URLs if enabled
if (allowRelative && url.startsWith('/')) {
return true;
}
// Check if it's a valid absolute URL
if (!isValidHttpURL(url)) {
return false;
}
// Check if domain is allowed
return isDomainAllowed(url, allowedDomains);
};
export const sanitizeRedirectURL = (
url: string,
allowedDomains: string[],
fallbackURL = '/'
): string => {
if (isSafeRedirectURL(url, allowedDomains)) {
return url;
}
return fallbackURL;
};
// Browser utilities
export const getCurrentURL = (): string => {
return typeof window !== 'undefined' ? window.location.href : '';
};
export const getCurrentPath = (): string => {
return typeof window !== 'undefined' ? window.location.pathname : '';
};
export const getCurrentDomain = (): string => {
return typeof window !== 'undefined' ? window.location.hostname : '';
};
export const getCurrentOrigin = (): string => {
return typeof window !== 'undefined' ? window.location.origin : '';
};
export const getCurrentParams = (): Record<string, string | string[]> => {
return typeof window !== 'undefined'
? parseQueryString(window.location.search)
: {};
};
export const redirectTo = (url: string, replace = false): void => {
if (typeof window !== 'undefined') {
if (replace) {
window.location.replace(url);
} else {
window.location.href = url;
}
}
};
export const openInNewTab = (url: string): void => {
if (typeof window !== 'undefined') {
window.open(url, '_blank', 'noopener,noreferrer');
}
};
// Hash utilities
export const getHash = (): string => {
return typeof window !== 'undefined' ? window.location.hash : '';
};
export const setHash = (hash: string): void => {
if (typeof window !== 'undefined') {
window.location.hash = hash;
}
};
export const removeHash = (): void => {
if (typeof window !== 'undefined') {
window.history.replaceState('', document.title, window.location.pathname + window.location.search);
}
};
// URL encoding utilities
export const encodeURIComponentSafe = (str: string): string => {
return encodeURIComponent(str)
.replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
};
export const decodeURIComponentSafe = (str: string): string => {
try {
return decodeURIComponent(str);
} catch {
return str;
}
};
// Export utilities object
export const URLUtils = {
// Parsing
parseURL,
parseQueryString,
parseOAuthCallback,
// Building
buildURL,
buildQueryString,
buildAuthURL,
buildOAuthURL,
buildMagicLinkURL,
buildVerificationURL,
buildPasswordResetURL,
buildInvitationURL,
// Manipulation
addQueryParams,
removeQueryParams,
getQueryParam,
hasQueryParam,
joinURL,
normalizeURL,
// Validation
isValidURL,
isValidHttpURL,
isValidHttpsURL,
isDomainAllowed,
isSubdomain,
isSafeRedirectURL,
// Extraction
extractDomain,
extractRootDomain,
getURLPath,
getURLParams,
// Browser
getCurrentURL,
getCurrentPath,
getCurrentDomain,
getCurrentOrigin,
getCurrentParams,
redirectTo,
openInNewTab,
// Hash
getHash,
setHash,
removeHash,
// Encoding
encodeURIComponentSafe,
decodeURIComponentSafe,
// Security
sanitizeRedirectURL,
};