auth flow

This commit is contained in:
alma 2025-05-02 11:41:43 +02:00
parent 086ab9416a
commit 6c28c51373
14 changed files with 962 additions and 346 deletions

View File

@ -1,6 +1,8 @@
import NextAuth, { NextAuthOptions } from "next-auth";
import { JWT } from "next-auth/jwt";
import KeycloakProvider from "next-auth/providers/keycloak";
import { jwtDecode } from "jwt-decode";
import { DEFAULT_AUTH_COOKIE_OPTIONS } from "@/lib/cookies";
interface KeycloakProfile {
sub: string;
@ -63,23 +65,30 @@ function getRequiredEnvVar(name: string): string {
async function refreshAccessToken(token: JWT) {
try {
console.log('Refreshing access token...');
const response = await fetch(`${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: new URLSearchParams({
client_id: process.env.KEYCLOAK_CLIENT_ID!,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
grant_type: "refresh_token",
refresh_token: token.refreshToken,
refresh_token: token.refreshToken!,
}),
method: "POST",
});
const refreshedTokens = await response.json();
if (!response.ok) {
console.error('Token refresh failed:', refreshedTokens);
throw refreshedTokens;
}
console.log('Token refreshed successfully');
return {
...token,
accessToken: refreshedTokens.access_token,
@ -108,23 +117,20 @@ export const authOptions: NextAuthOptions = {
},
profile(profile) {
console.log('Keycloak profile callback:', {
rawProfile: profile,
rawRoles: profile.roles,
realmAccess: profile.realm_access,
groups: profile.groups
email: profile.email,
name: profile.name,
sub: profile.sub,
roles: profile.realm_access?.roles || []
});
// Get roles from realm_access
const roles = profile.realm_access?.roles || [];
console.log('Profile callback raw roles:', roles);
// Clean up roles by removing ROLE_ prefix and converting to lowercase
const cleanRoles = roles.map((role: string) =>
role.replace(/^ROLE_/, '').toLowerCase()
);
console.log('Profile callback cleaned roles:', cleanRoles);
return {
id: profile.sub,
name: profile.name ?? profile.preferred_username,
@ -141,8 +147,29 @@ export const authOptions: NextAuthOptions = {
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60, // 30 days
},
cookies: {
sessionToken: {
name: `next-auth.session-token`,
options: {
...DEFAULT_AUTH_COOKIE_OPTIONS,
httpOnly: true
}
},
callbackUrl: {
name: `next-auth.callback-url`,
options: DEFAULT_AUTH_COOKIE_OPTIONS
},
csrfToken: {
name: `next-auth.csrf-token`,
options: {
...DEFAULT_AUTH_COOKIE_OPTIONS,
httpOnly: true
}
}
},
callbacks: {
async jwt({ token, account, profile }) {
// Initial sign in
if (account && profile) {
const keycloakProfile = profile as KeycloakProfile;
const roles = keycloakProfile.realm_access?.roles || [];
@ -150,33 +177,25 @@ export const authOptions: NextAuthOptions = {
role.replace(/^ROLE_/, '').toLowerCase()
);
token.accessToken = account.access_token ?? '';
token.refreshToken = account.refresh_token ?? '';
token.accessTokenExpires = account.expires_at ?? 0;
token.sub = keycloakProfile.sub;
token.role = cleanRoles;
token.username = keycloakProfile.preferred_username ?? '';
token.first_name = keycloakProfile.given_name ?? '';
token.last_name = keycloakProfile.family_name ?? '';
} else if (token.accessToken) {
try {
const decoded = jwtDecode<DecodedToken>(token.accessToken);
if (decoded.realm_access?.roles) {
const roles = decoded.realm_access.roles;
const cleanRoles = roles.map((role: string) =>
role.replace(/^ROLE_/, '').toLowerCase()
);
token.role = cleanRoles;
}
} catch (error) {
console.error('Error decoding token:', error);
}
return {
...token,
accessToken: account.access_token,
refreshToken: account.refresh_token,
accessTokenExpires: account.expires_at ? account.expires_at * 1000 : 0,
sub: keycloakProfile.sub,
role: cleanRoles,
username: keycloakProfile.preferred_username ?? '',
first_name: keycloakProfile.given_name ?? '',
last_name: keycloakProfile.family_name ?? '',
};
}
if (Date.now() < (token.accessTokenExpires as number) * 1000) {
// Return the previous token if the access token has not expired
if (token.accessTokenExpires && Date.now() < token.accessTokenExpires) {
return token;
}
// Access token has expired, try to refresh it
return refreshAccessToken(token);
},
async session({ session, token }) {
@ -206,21 +225,21 @@ export const authOptions: NextAuthOptions = {
error: '/signin',
},
debug: process.env.NODE_ENV === 'development',
logger: {
error(code, metadata) {
console.error(`Auth error: ${code}`, metadata);
},
warn(code) {
console.warn(`Auth warning: ${code}`);
},
debug(code, metadata) {
if (process.env.NODE_ENV === 'development') {
console.debug(`Auth debug: ${code}`, metadata);
}
}
}
};
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
interface JWT {
accessToken: string;
refreshToken: string;
accessTokenExpires: number;
}
interface Profile {
sub?: string;
email?: string;
name?: string;
roles?: string[];
}

View File

@ -0,0 +1,123 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "../../auth/[...nextauth]/route";
/**
* Handler for getting a Keycloak admin token
* This replaces the previous proxy implementation
*/
export async function GET(request: NextRequest) {
const session = await getServerSession(authOptions);
// Check authentication
if (!session || !session.user) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
// Check if user has admin role
const isAdmin = session.user.role?.includes('admin');
if (!isAdmin) {
return NextResponse.json(
{ error: "Forbidden: Admin role required" },
{ status: 403 }
);
}
try {
// Get admin token
const tokenResponse = await fetch(
`${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`,
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: process.env.KEYCLOAK_CLIENT_ID!,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
}),
}
);
const data = await tokenResponse.json();
if (!tokenResponse.ok || !data.access_token) {
console.error("Token Error:", data);
return NextResponse.json(
{ error: "Failed to get admin token" },
{ status: 500 }
);
}
// Return the token
return NextResponse.json({
access_token: data.access_token,
expires_in: data.expires_in,
token_type: data.token_type
});
} catch (error) {
console.error("Token Error:", error);
return NextResponse.json(
{ error: "Server error obtaining token" },
{ status: 500 }
);
}
}
/**
* Handle refreshing a token
*/
export async function POST(request: NextRequest) {
try {
const { refresh_token } = await request.json();
if (!refresh_token) {
return NextResponse.json(
{ error: "Refresh token is required" },
{ status: 400 }
);
}
const response = await fetch(
`${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`,
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: process.env.KEYCLOAK_CLIENT_ID!,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
grant_type: "refresh_token",
refresh_token: refresh_token,
}),
}
);
const data = await response.json();
if (!response.ok) {
return NextResponse.json(
{ error: "Token refresh failed", details: data },
{ status: response.status }
);
}
return NextResponse.json({
access_token: data.access_token,
refresh_token: data.refresh_token,
expires_in: data.expires_in,
token_type: data.token_type
});
} catch (error) {
console.error("Token refresh error:", error);
return NextResponse.json(
{ error: "Server error refreshing token" },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,265 @@
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "../../auth/[...nextauth]/route";
/**
* Get an admin token
*/
async function getAdminToken() {
try {
const tokenResponse = await fetch(
`${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`,
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: process.env.KEYCLOAK_CLIENT_ID!,
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
}),
}
);
const data = await tokenResponse.json();
if (!tokenResponse.ok || !data.access_token) {
console.error("Token Error:", data);
return null;
}
return data.access_token;
} catch (error) {
console.error("Token Error:", error);
return null;
}
}
/**
* Get users from Keycloak
*/
export async function GET(request: NextRequest) {
const session = await getServerSession(authOptions);
// Check authentication
if (!session || !session.user) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
// Only administrators can list users
const isAdmin = session.user.role?.includes('admin');
if (!isAdmin) {
return NextResponse.json(
{ error: "Forbidden: Admin role required" },
{ status: 403 }
);
}
try {
// Get admin token
const token = await getAdminToken();
if (!token) {
return NextResponse.json(
{ error: "Failed to get admin token" },
{ status: 500 }
);
}
// Parse search params
const { searchParams } = new URL(request.url);
const query = searchParams.get('query') || '';
const max = searchParams.get('max') || '100';
// Fetch users from Keycloak
const keycloakUrl = `${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/users`;
const queryParams = new URLSearchParams();
if (query) {
queryParams.append('search', query);
}
queryParams.append('max', max);
const usersResponse = await fetch(
`${keycloakUrl}?${queryParams.toString()}`,
{
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
if (!usersResponse.ok) {
const errorData = await usersResponse.json();
return NextResponse.json(
{ error: "Failed to fetch users", details: errorData },
{ status: usersResponse.status }
);
}
const users = await usersResponse.json();
// Return user data
return NextResponse.json(users);
} catch (error) {
console.error("Error fetching users:", error);
return NextResponse.json(
{ error: "Server error fetching users" },
{ status: 500 }
);
}
}
/**
* Create a new user in Keycloak
*/
export async function POST(request: NextRequest) {
const session = await getServerSession(authOptions);
// Check authentication
if (!session || !session.user) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
// Only administrators can create users
const isAdmin = session.user.role?.includes('admin');
if (!isAdmin) {
return NextResponse.json(
{ error: "Forbidden: Admin role required" },
{ status: 403 }
);
}
try {
// Get admin token
const token = await getAdminToken();
if (!token) {
return NextResponse.json(
{ error: "Failed to get admin token" },
{ status: 500 }
);
}
// Get user data from request
const userData = await request.json();
// Validate user data
if (!userData.username || !userData.email) {
return NextResponse.json(
{ error: "Username and email are required" },
{ status: 400 }
);
}
// Create user in Keycloak
const keycloakUrl = `${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/users`;
const createResponse = await fetch(keycloakUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
username: userData.username,
email: userData.email,
enabled: true,
emailVerified: true,
firstName: userData.firstName || '',
lastName: userData.lastName || '',
credentials: userData.password ? [
{
type: "password",
value: userData.password,
temporary: false
}
] : undefined
}),
});
if (!createResponse.ok) {
const errorData = await createResponse.json().catch(() => ({}));
return NextResponse.json(
{ error: "Failed to create user", details: errorData },
{ status: createResponse.status }
);
}
// If user was created successfully, get the user ID
const userResponse = await fetch(
`${keycloakUrl}?username=${encodeURIComponent(userData.username)}`,
{
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
if (!userResponse.ok) {
return NextResponse.json(
{ error: "User created but failed to retrieve user details" },
{ status: 207 } // Partial success
);
}
const users = await userResponse.json();
const createdUser = users[0];
// If roles are specified, assign them
if (userData.roles && Array.isArray(userData.roles) && userData.roles.length > 0) {
// Get available roles
const rolesResponse = await fetch(
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/roles`,
{
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}
);
if (rolesResponse.ok) {
const availableRoles = await rolesResponse.json();
// Filter valid roles
const validRoles = userData.roles.map((roleName: string) =>
availableRoles.find((r: any) => r.name === roleName)
).filter(Boolean);
if (validRoles.length > 0) {
// Assign roles to user
await fetch(
`${process.env.KEYCLOAK_BASE_URL}/admin/realms/${process.env.KEYCLOAK_REALM}/users/${createdUser.id}/role-mappings/realm`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(validRoles),
}
);
}
}
}
return NextResponse.json({
success: true,
user: createdUser
});
} catch (error) {
console.error("Error creating user:", error);
return NextResponse.json(
{ error: "Server error creating user" },
{ status: 500 }
);
}
}

View File

@ -1,166 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
// Map of service prefixes to their base URLs
const SERVICE_URLS: Record<string, string> = {
'parole': process.env.NEXT_PUBLIC_IFRAME_PAROLE_URL || '',
'alma': process.env.NEXT_PUBLIC_IFRAME_AI_ASSISTANT_URL || '',
'crm': process.env.NEXT_PUBLIC_IFRAME_MEDIATIONS_URL || '',
'vision': process.env.NEXT_PUBLIC_IFRAME_CONFERENCE_URL || '',
'showcase': process.env.NEXT_PUBLIC_IFRAME_SHOWCASE_URL || '',
'agilite': process.env.NEXT_PUBLIC_IFRAME_AGILITY_URL || '',
'dossiers': process.env.NEXT_PUBLIC_IFRAME_DRIVE_URL || '',
'the-message': process.env.NEXT_PUBLIC_IFRAME_THEMESSAGE_URL || '',
'qg': process.env.NEXT_PUBLIC_IFRAME_MISSIONVIEW_URL || ''
};
// Check if a service is Rocket.Chat (they require special authentication)
function isRocketChat(serviceName: string): boolean {
return serviceName === 'parole'; // Assuming 'parole' is your Rocket.Chat service
}
export async function GET(
request: NextRequest,
context: { params: { path: string[] } }
) {
// Get the service prefix (first part of the path)
const paramsObj = await Promise.resolve(context.params);
const pathArray = await Promise.resolve(paramsObj.path);
const serviceName = pathArray[0];
const restOfPath = pathArray.slice(1).join('/');
// Get the base URL for this service
const baseUrl = SERVICE_URLS[serviceName];
if (!baseUrl) {
return NextResponse.json({ error: 'Service not found' }, { status: 404 });
}
// Get the user's session
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
// Extract search parameters
const searchParams = new URL(request.url).searchParams.toString();
const targetUrl = `${baseUrl}/${restOfPath}${searchParams ? `?${searchParams}` : ''}`;
// Prepare headers based on the service type
const headers: Record<string, string> = {};
if (isRocketChat(serviceName)) {
// For Rocket.Chat, use their specific authentication headers
if (session.rocketChatToken && session.rocketChatUserId) {
console.log('Using Rocket.Chat specific authentication');
headers['X-Auth-Token'] = session.rocketChatToken;
headers['X-User-Id'] = session.rocketChatUserId;
} else {
console.warn('Rocket.Chat tokens not available in session');
// Still try with standard authorization if available
if (session.accessToken) {
headers['Authorization'] = `Bearer ${session.accessToken}`;
}
}
} else {
// Standard OAuth Bearer token for other services
if (session.accessToken) {
headers['Authorization'] = `Bearer ${session.accessToken}`;
}
}
// Add other common headers
headers['Accept'] = 'application/json, text/html, */*';
// Forward the request to the target service with the authentication headers
const response = await fetch(targetUrl, { headers });
// Get response data
const data = await response.arrayBuffer();
// Create response with the same status and headers
const newResponse = new NextResponse(data, {
status: response.status,
statusText: response.statusText,
headers: {
'Content-Type': response.headers.get('Content-Type') || 'application/octet-stream',
}
});
return newResponse;
} catch (error) {
console.error('Proxy error:', error);
return NextResponse.json({ error: 'Proxy error' }, { status: 500 });
}
}
export async function POST(
request: NextRequest,
context: { params: { path: string[] } }
) {
// Get the service prefix (first part of the path)
const paramsObj = await Promise.resolve(context.params);
const pathArray = await Promise.resolve(paramsObj.path);
const serviceName = pathArray[0];
const restOfPath = pathArray.slice(1).join('/');
const baseUrl = SERVICE_URLS[serviceName];
if (!baseUrl) {
return NextResponse.json({ error: 'Service not found' }, { status: 404 });
}
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const searchParams = new URL(request.url).searchParams.toString();
const targetUrl = `${baseUrl}/${restOfPath}${searchParams ? `?${searchParams}` : ''}`;
// Get the request body
const body = await request.arrayBuffer();
// Prepare headers based on the service type
const headers: Record<string, string> = {
'Content-Type': request.headers.get('Content-Type') || 'application/json',
};
if (isRocketChat(serviceName)) {
// For Rocket.Chat, use their specific authentication headers
if (session.rocketChatToken && session.rocketChatUserId) {
headers['X-Auth-Token'] = session.rocketChatToken;
headers['X-User-Id'] = session.rocketChatUserId;
} else if (session.accessToken) {
headers['Authorization'] = `Bearer ${session.accessToken}`;
}
} else {
// Standard OAuth Bearer token for other services
if (session.accessToken) {
headers['Authorization'] = `Bearer ${session.accessToken}`;
}
}
const response = await fetch(targetUrl, {
method: 'POST',
headers,
body: body
});
const data = await response.arrayBuffer();
return new NextResponse(data, {
status: response.status,
statusText: response.statusText,
headers: {
'Content-Type': response.headers.get('Content-Type') || 'application/octet-stream',
}
});
} catch (error) {
console.error('Proxy error:', error);
return NextResponse.json({ error: 'Proxy error' }, { status: 500 });
}
}

View File

@ -2,7 +2,7 @@
import { useEffect } from "react";
import { useSession } from "next-auth/react";
import { clearAuthCookies } from "@/lib/session";
import { clearAuthCookiesClient } from "@/lib/cookies";
export function SignOutHandler() {
const { data: session } = useSession();
@ -11,7 +11,7 @@ export function SignOutHandler() {
const handleSignOut = async () => {
try {
// First, clear all auth-related cookies to ensure we break any local sessions
clearAuthCookies();
clearAuthCookiesClient();
// Get Keycloak logout URL
if (process.env.NEXT_PUBLIC_KEYCLOAK_ISSUER) {

193
lib/cookies.ts Normal file
View File

@ -0,0 +1,193 @@
import { serialize, parse } from 'cookie';
import { IncomingMessage } from 'http';
import { NextApiRequestCookies } from 'next/dist/server/api-utils';
export interface CookieOptions {
maxAge?: number;
expires?: Date;
path?: string;
domain?: string;
secure?: boolean;
httpOnly?: boolean;
sameSite?: 'strict' | 'lax' | 'none';
}
// Default cookie options for auth-related cookies
export const DEFAULT_AUTH_COOKIE_OPTIONS: CookieOptions = {
maxAge: 30 * 24 * 60 * 60, // 30 days
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax'
};
// Cookie names
export const COOKIE_NAMES = {
// NextAuth cookies
AUTH_TOKEN: 'next-auth.session-token',
AUTH_CSRF_TOKEN: 'next-auth.csrf-token',
AUTH_CALLBACK_URL: 'next-auth.callback-url',
AUTH_PKCE_CODE_CHALLENGE: 'next-auth.pkce.code_challenge',
// Keycloak cookies
KEYCLOAK_SESSION: 'KEYCLOAK_SESSION',
KEYCLOAK_IDENTITY: 'KEYCLOAK_IDENTITY',
KEYCLOAK_REMEMBER_ME: 'KEYCLOAK_REMEMBER_ME',
KC_RESTART: 'KC_RESTART',
// Custom cookies
USER_PREFERENCES: 'user-preferences',
THEME: 'theme',
// Function to create a namespaced cookie name
namespaced: (name: string) => `neah-front9.${name}`
};
/**
* Set a cookie with the specified options
*/
export function setCookie(
name: string,
value: string,
options: CookieOptions = {}
): string {
// Merge with default options
const cookieOptions = {
...DEFAULT_AUTH_COOKIE_OPTIONS,
...options
};
// For security, ensure secure flag is set if sameSite is 'none'
if (cookieOptions.sameSite === 'none' && cookieOptions.secure === undefined) {
cookieOptions.secure = true;
}
// Create the cookie string
return serialize(name, value, cookieOptions as any);
}
/**
* Get all cookies from the request
*/
export function getCookies(req: {
headers: { cookie?: string };
}): Record<string, string> {
const cookie = req.headers?.cookie;
return parse(cookie || '');
}
/**
* Get a specific cookie value
*/
export function getCookie(
req: { headers: { cookie?: string } },
name: string
): string | undefined {
const cookies = getCookies(req);
return cookies[name];
}
/**
* Delete a cookie by setting its expiration to the past
*/
export function deleteCookie(
name: string,
options: CookieOptions = {}
): string {
return setCookie(name, '', {
...options,
maxAge: 0,
expires: new Date(0)
});
}
/**
* Helper to generate SetCookie headers for multiple cookies
*/
export function createCookieHeaders(cookieStrings: string[]): [string, string][] {
return cookieStrings.map(cookie => ['Set-Cookie', cookie]);
}
/**
* Clear all auth-related cookies
*/
export function getAuthCookieClearingHeaders(): [string, string][] {
const authCookies = [
COOKIE_NAMES.AUTH_TOKEN,
COOKIE_NAMES.AUTH_CSRF_TOKEN,
COOKIE_NAMES.AUTH_CALLBACK_URL,
COOKIE_NAMES.AUTH_PKCE_CODE_CHALLENGE,
COOKIE_NAMES.KEYCLOAK_SESSION,
COOKIE_NAMES.KEYCLOAK_IDENTITY,
COOKIE_NAMES.KEYCLOAK_REMEMBER_ME,
COOKIE_NAMES.KC_RESTART,
// Also clear secure variants
`__Secure-${COOKIE_NAMES.AUTH_TOKEN}`,
`__Host-${COOKIE_NAMES.AUTH_TOKEN}`
];
// Create clearing headers for root path
const cookieHeaders = authCookies.flatMap(name => {
return [
deleteCookie(name, { path: '/' }),
deleteCookie(name, { path: '/auth' }),
deleteCookie(name, { path: '/api' })
];
});
return createCookieHeaders(cookieHeaders);
}
/**
* Client-side function to clear all auth cookies
*/
export function clearAuthCookiesClient(): void {
const authCookiePrefixes = [
'next-auth.',
'__Secure-next-auth.',
'__Host-next-auth.',
'KEYCLOAK_',
'KC_'
];
const specificCookies = [
COOKIE_NAMES.KEYCLOAK_SESSION,
COOKIE_NAMES.KEYCLOAK_IDENTITY,
COOKIE_NAMES.KEYCLOAK_REMEMBER_ME,
COOKIE_NAMES.KC_RESTART
];
const cookies = document.cookie.split(';');
for (const cookie of cookies) {
const [name] = cookie.split('=');
const trimmedName = name.trim();
const isAuthCookie =
authCookiePrefixes.some(prefix => trimmedName.startsWith(prefix)) ||
specificCookies.includes(trimmedName);
if (isAuthCookie) {
// Clear cookie for different paths and domains
const paths = ['/', '/auth', '/api'];
for (const path of paths) {
// Basic deletion
document.cookie = `${trimmedName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path};`;
// With Secure and SameSite
document.cookie = `${trimmedName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}; SameSite=None; Secure;`;
// Try with domain
const domain = window.location.hostname;
document.cookie = `${trimmedName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}; domain=${domain};`;
document.cookie = `${trimmedName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}; domain=${domain}; SameSite=None; Secure;`;
// Try with root domain
const rootDomain = `.${domain.split('.').slice(-2).join('.')}`;
document.cookie = `${trimmedName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}; domain=${rootDomain};`;
document.cookie = `${trimmedName}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}; domain=${rootDomain}; SameSite=None; Secure;`;
}
}
}
}

View File

@ -127,7 +127,11 @@ export const KEYS = {
EMAIL_LIST: (userId: string, accountId: string, folder: string, page: number, perPage: number) =>
`email:list:${userId}:${accountId}:${folder}:${page}:${perPage}`,
EMAIL_CONTENT: (userId: string, accountId: string, emailId: string) =>
`email:content:${userId}:${accountId}:${emailId}`
`email:content:${userId}:${accountId}:${emailId}`,
// Add auth-specific Redis keys
AUTH_STATE: (sessionId: string) => `auth:state:${sessionId}`,
AUTH_REFRESH_TOKEN: (userId: string) => `auth:refresh:${userId}`,
AUTH_SESSION: (sessionId: string) => `auth:session:${sessionId}`
};
// TTL constants in seconds
@ -135,7 +139,10 @@ export const TTL = {
CREDENTIALS: 60 * 60 * 24, // 24 hours
SESSION: 60 * 60 * 4, // 4 hours (increased from 30 minutes)
EMAIL_LIST: 60 * 5, // 5 minutes
EMAIL_CONTENT: 60 * 15 // 15 minutes
EMAIL_CONTENT: 60 * 15, // 15 minutes
AUTH_STATE: 60 * 10, // 10 minutes
AUTH_REFRESH_TOKEN: 60 * 60 * 24 * 30, // 30 days
AUTH_SESSION: 60 * 60 * 24 * 7 // 7 days
};
interface EmailCredentials {
@ -494,4 +501,81 @@ export async function getCachedEmailCredentials(
accountId: string
): Promise<EmailCredentials | null> {
return getEmailCredentials(userId, accountId);
}
/**
* Cache an auth state for PKCE
*/
export async function cacheAuthState(
sessionId: string,
state: string
): Promise<void> {
const redis = getRedisClient();
const key = KEYS.AUTH_STATE(sessionId);
await redis.set(key, state, 'EX', TTL.AUTH_STATE);
}
/**
* Get a cached auth state
*/
export async function getAuthState(
sessionId: string
): Promise<string | null> {
const redis = getRedisClient();
const key = KEYS.AUTH_STATE(sessionId);
return redis.get(key);
}
/**
* Delete auth state after use
*/
export async function deleteAuthState(
sessionId: string
): Promise<void> {
const redis = getRedisClient();
const key = KEYS.AUTH_STATE(sessionId);
await redis.del(key);
}
/**
* Cache a refresh token
*/
export async function cacheRefreshToken(
userId: string,
refreshToken: string
): Promise<void> {
const redis = getRedisClient();
const key = KEYS.AUTH_REFRESH_TOKEN(userId);
await redis.set(key, encryptData(refreshToken), 'EX', TTL.AUTH_REFRESH_TOKEN);
}
/**
* Get a cached refresh token
*/
export async function getRefreshToken(
userId: string
): Promise<string | null> {
const redis = getRedisClient();
const key = KEYS.AUTH_REFRESH_TOKEN(userId);
const token = await redis.get(key);
if (!token) return null;
try {
return decryptData(token);
} catch (error) {
console.error('Error decrypting refresh token:', error);
return null;
}
}
/**
* Delete a refresh token
*/
export async function deleteRefreshToken(
userId: string
): Promise<void> {
const redis = getRedisClient();
const key = KEYS.AUTH_REFRESH_TOKEN(userId);
await redis.del(key);
}

113
middleware.ts Normal file
View File

@ -0,0 +1,113 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getToken } from 'next-auth/jwt';
// Define paths that don't require authentication
const publicPaths = [
'/api/auth',
'/signin',
'/loggedout',
'/register',
'/error',
'/images',
'/fonts',
'/favicon.ico',
'/robots.txt',
'/sitemap.xml',
];
// Check if the requested path is public
function isPublicPath(path: string): boolean {
return publicPaths.some(publicPath => path.startsWith(publicPath));
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Check if the path is public (no authentication needed)
if (isPublicPath(pathname)) {
return NextResponse.next();
}
// For API routes, check for token in the request
if (pathname.startsWith('/api/')) {
try {
// Verify authentication token
const token = await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET
});
if (!token) {
// No token found, redirect to sign-in page or return 401 for API requests
return NextResponse.json(
{ error: 'Authentication required' },
{ status: 401 }
);
}
// Check if token is expired
if (token.accessTokenExpires && (token.accessTokenExpires as number) < Date.now()) {
return NextResponse.json(
{ error: 'Session expired' },
{ status: 401 }
);
}
// Token is valid, proceed
return NextResponse.next();
} catch (error) {
console.error('Auth middleware error:', error);
return NextResponse.json(
{ error: 'Authentication error' },
{ status: 401 }
);
}
}
// For page routes, verify session
try {
const token = await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET
});
// If no token found, redirect to sign-in page
if (!token) {
const url = new URL('/signin', request.url);
url.searchParams.set('callbackUrl', encodeURI(request.url));
return NextResponse.redirect(url);
}
// Check if token is expired
if (token.accessTokenExpires && (token.accessTokenExpires as number) < Date.now()) {
const url = new URL('/signin', request.url);
url.searchParams.set('callbackUrl', encodeURI(request.url));
url.searchParams.set('error', 'SessionExpired');
return NextResponse.redirect(url);
}
// If authorized, proceed to the requested page
return NextResponse.next();
} catch (error) {
console.error('Auth middleware error:', error);
const url = new URL('/signin', request.url);
url.searchParams.set('error', 'AuthError');
return NextResponse.redirect(url);
}
}
// Only run middleware on matching paths
export const config = {
matcher: [
/*
* Match all request paths except:
* 1. /_next (Next.js internals)
* 2. /static (static files)
* 3. /images (public images)
* 4. /fonts (public fonts)
* 5. /favicon.ico, /robots.txt, /sitemap.xml (SEO files)
*/
'/((?!_next/|static/|images/|fonts/|favicon.ico|robots.txt|sitemap.xml).*)',
],
};

22
node_modules/.package-lock.json generated vendored
View File

@ -2337,6 +2337,13 @@
"tslib": "^2.8.0"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/crypto-js": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz",
@ -3065,9 +3072,9 @@
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
@ -4958,6 +4965,15 @@
}
}
},
"node_modules/next-auth/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/next-themes": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.4.tgz",

183
node_modules/cookie/index.js generated vendored
View File

@ -21,69 +21,16 @@ exports.serialize = serialize;
*/
var __toString = Object.prototype.toString
var __hasOwnProperty = Object.prototype.hasOwnProperty
/**
* 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.
* RegExp to match field-content in RFC 7230 sec 3.2
*
* cookie-name = token
* token = 1*tchar
* tchar = "!" / "#" / "$" / "%" / "&" / "'" /
* "*" / "+" / "-" / "." / "^" / "_" /
* "`" / "|" / "~" / DIGIT / ALPHA
* field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ]
* field-vchar = VCHAR / obs-text
* obs-text = %x80-FF
*/
var cookieNameRegExp = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
/**
* 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
*/
var cookieValueRegExp = /^("?)[\u0021\u0023-\u002B\u002D-\u003A\u003C-\u005B\u005D-\u007E]*\1$/;
/**
* 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.)
*/
var 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
*/
var pathValueRegExp = /^[\u0020-\u003A\u003D-\u007E]*$/;
var fieldContentRegExp = /^[\u0009\u0020-\u007e\u0080-\u00ff]+$/;
/**
* Parse a cookie header.
@ -92,128 +39,107 @@ var pathValueRegExp = /^[\u0020-\u003A\u003D-\u007E]*$/;
* The object has the various cookies as keys(names) => values
*
* @param {string} str
* @param {object} [opt]
* @param {object} [options]
* @return {object}
* @public
*/
function parse(str, opt) {
function parse(str, options) {
if (typeof str !== 'string') {
throw new TypeError('argument str must be a string');
}
var obj = {};
var 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;
var obj = {}
var opt = options || {};
var dec = opt.decode || decode;
var dec = (opt && opt.decode) || decode;
var index = 0;
var eqIdx = 0;
var endIdx = 0;
var index = 0
while (index < str.length) {
var eqIdx = str.indexOf('=', index)
do {
eqIdx = str.indexOf('=', index);
if (eqIdx === -1) break; // No more cookie pairs.
endIdx = str.indexOf(';', index);
if (endIdx === -1) {
endIdx = len;
} else if (eqIdx > endIdx) {
// backtrack on prior semicolon
index = str.lastIndexOf(';', eqIdx - 1) + 1;
continue;
// no more cookie pairs
if (eqIdx === -1) {
break
}
var keyStartIdx = startIndex(str, index, eqIdx);
var keyEndIdx = endIndex(str, eqIdx, keyStartIdx);
var key = str.slice(keyStartIdx, keyEndIdx);
var endIdx = str.indexOf(';', index)
if (endIdx === -1) {
endIdx = str.length
} else if (endIdx < eqIdx) {
// backtrack on prior semicolon
index = str.lastIndexOf(';', eqIdx - 1) + 1
continue
}
var key = str.slice(index, eqIdx).trim()
// only assign once
if (!__hasOwnProperty.call(obj, key)) {
var valStartIdx = startIndex(str, eqIdx + 1, endIdx);
var valEndIdx = endIndex(str, endIdx, valStartIdx);
if (undefined === obj[key]) {
var val = str.slice(eqIdx + 1, endIdx).trim()
if (str.charCodeAt(valStartIdx) === 0x22 /* " */ && str.charCodeAt(valEndIdx - 1) === 0x22 /* " */) {
valStartIdx++;
valEndIdx--;
// quoted values
if (val.charCodeAt(0) === 0x22) {
val = val.slice(1, -1)
}
var val = str.slice(valStartIdx, valEndIdx);
obj[key] = tryDecode(val, dec);
}
index = endIdx + 1
} while (index < len);
}
return obj;
}
function startIndex(str, index, max) {
do {
var 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) {
var 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 the a name value pair into a cookie string suitable for
* http headers. An optional options object specified cookie parameters.
*
* serialize('foo', 'bar', { httpOnly: true })
* => "foo=bar; httpOnly"
*
* @param {string} name
* @param {string} val
* @param {object} [opt]
* @param {object} [options]
* @return {string}
* @public
*/
function serialize(name, val, opt) {
var enc = (opt && opt.encode) || encodeURIComponent;
function serialize(name, val, options) {
var opt = options || {};
var enc = opt.encode || encode;
if (typeof enc !== 'function') {
throw new TypeError('option encode is invalid');
}
if (!cookieNameRegExp.test(name)) {
if (!fieldContentRegExp.test(name)) {
throw new TypeError('argument name is invalid');
}
var value = enc(val);
if (!cookieValueRegExp.test(value)) {
if (value && !fieldContentRegExp.test(value)) {
throw new TypeError('argument val is invalid');
}
var str = name + '=' + value;
if (!opt) return str;
if (null != opt.maxAge) {
var maxAge = Math.floor(opt.maxAge);
var maxAge = opt.maxAge - 0;
if (!isFinite(maxAge)) {
if (isNaN(maxAge) || !isFinite(maxAge)) {
throw new TypeError('option maxAge is invalid')
}
str += '; Max-Age=' + maxAge;
str += '; Max-Age=' + Math.floor(maxAge);
}
if (opt.domain) {
if (!domainValueRegExp.test(opt.domain)) {
if (!fieldContentRegExp.test(opt.domain)) {
throw new TypeError('option domain is invalid');
}
@ -221,7 +147,7 @@ function serialize(name, val, opt) {
}
if (opt.path) {
if (!pathValueRegExp.test(opt.path)) {
if (!fieldContentRegExp.test(opt.path)) {
throw new TypeError('option path is invalid');
}
@ -252,7 +178,8 @@ function serialize(name, val, opt) {
if (opt.priority) {
var priority = typeof opt.priority === 'string'
? opt.priority.toLowerCase() : opt.priority;
? opt.priority.toLowerCase()
: opt.priority
switch (priority) {
case 'low':
@ -307,6 +234,17 @@ function decode (str) {
: str
}
/**
* URL-encode value.
*
* @param {string} val
* @returns {string}
*/
function encode (val) {
return encodeURIComponent(val)
}
/**
* Determine if value is a Date.
*
@ -315,7 +253,8 @@ function decode (str) {
*/
function isDate (val) {
return __toString.call(val) === '[object Date]';
return __toString.call(val) === '[object Date]' ||
val instanceof Date
}
/**

6
node_modules/cookie/package.json generated vendored
View File

@ -1,7 +1,7 @@
{
"name": "cookie",
"description": "HTTP server cookie parsing and serialization",
"version": "0.7.2",
"version": "0.6.0",
"author": "Roman Shtylman <shtylman@gmail.com>",
"contributors": [
"Douglas Christopher Wilson <doug@somethingdoug.com>"
@ -29,7 +29,6 @@
"SECURITY.md",
"index.js"
],
"main": "index.js",
"engines": {
"node": ">= 0.6"
},
@ -39,6 +38,7 @@
"test": "mocha --reporter spec --bail --check-leaks test/",
"test-ci": "nyc --reporter=lcov --reporter=text npm test",
"test-cov": "nyc --reporter=html --reporter=text npm test",
"update-bench": "node scripts/update-benchmark.js"
"update-bench": "node scripts/update-benchmark.js",
"version": "node scripts/version-history.js && git add HISTORY.md"
}
}

24
package-lock.json generated
View File

@ -53,6 +53,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.0.4",
"cookie": "^0.6.0",
"cookies-next": "^5.1.0",
"crypto-js": "^4.2.0",
"date-fns": "^3.6.0",
@ -94,6 +95,7 @@
"webdav": "^5.8.0"
},
"devDependencies": {
"@types/cookie": "^0.6.0",
"@types/crypto-js": "^4.2.2",
"@types/imapflow": "^1.0.20",
"@types/jsdom": "^21.1.7",
@ -3313,6 +3315,13 @@
"tslib": "^2.8.0"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/crypto-js": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz",
@ -4041,9 +4050,9 @@
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
@ -5934,6 +5943,15 @@
}
}
},
"node_modules/next-auth/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/next-themes": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.4.tgz",

View File

@ -54,6 +54,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.0.4",
"cookie": "^0.6.0",
"cookies-next": "^5.1.0",
"crypto-js": "^4.2.0",
"date-fns": "^3.6.0",
@ -95,6 +96,7 @@
"webdav": "^5.8.0"
},
"devDependencies": {
"@types/cookie": "^0.6.0",
"@types/crypto-js": "^4.2.2",
"@types/imapflow": "^1.0.20",
"@types/jsdom": "^21.1.7",

View File

@ -1034,6 +1034,11 @@
dependencies:
tslib "^2.8.0"
"@types/cookie@^0.6.0":
version "0.6.0"
resolved "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz"
integrity sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==
"@types/crypto-js@^4.2.2":
version "4.2.2"
resolved "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz"
@ -1495,6 +1500,11 @@ commander@^4.0.0:
resolved "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz"
integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==
cookie@^0.6.0:
version "0.6.0"
resolved "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz"
integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==
cookie@^0.7.0:
version "0.7.2"
resolved "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz"