Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 1 addition & 10 deletions .env.local.template
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,4 @@ NEXTAUTH_URL=http://localhost:3000/
NEXTAUTH_SECRET=SECRET
NEXT_PUBLIC_GOOGLE_ANALYTICS=ANALYTICS
PORT=3000

ENCRYPTION_KEY=SECRET

PAT_FALKORDB_HOST=localhost
PAT_FALKORDB_PORT=6380
PAT_FALKORDB_USERNAME=default
PAT_FALKORDB_PASSWORD=
PAT_FALKORDB_TLS=false
PAT_FALKORDB_CA=
PAT_FALKORDB_GRAPH_NAME=token_management
API_TOKEN_STORAGE_PATH=.data/api_tokens.json
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ yarn-error.log*
.env*.local
.env

# local token storage
/.data/

# vercel
.vercel

Expand Down
72 changes: 13 additions & 59 deletions app/api/auth/tokenUtils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { jwtVerify } from "jose";
import crypto from "crypto";
import { executePATQuery } from "@/lib/token-storage";
import StorageFactory from "@/lib/token-storage/StorageFactory";

/**
* Validates JWT secret exists in environment
Expand Down Expand Up @@ -30,7 +30,7 @@ export function getTokenId(payload: Record<string, unknown>): string {
return (payload.jti as string) || `${payload.sub}-${payload.iat}`;
}

// Helper function to check if a token is active using FalkorDB PAT instance
// Helper function to check if a token is active
export async function isTokenActive(
token: string
): Promise<boolean> {
Expand All @@ -47,48 +47,18 @@ export async function isTokenActive(
// Hash the token to match storage format
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');

// Helper to escape strings for Cypher (FalkorDB doesn't support parameterized queries)
const escapeString = (str: string) => str.replace(/'/g, "''");
// Check if token exists and is active using storage abstraction
const storage = StorageFactory.getStorage();
const isActive = await storage.isTokenActive(tokenHash);

// Check if token exists in FalkorDB and is active
const now = Math.floor(Date.now() / 1000); // Unix timestamp
const query = `
MATCH (t:Token {token_hash: '${escapeString(tokenHash)}'})
WHERE t.is_active = true
AND (t.expires_at = -1 OR t.expires_at > ${now})
RETURN t.token_id as token_id, t.last_used as last_used
`;

const result = await executePATQuery(query);

if (!result || !result.data || result.data.length === 0) {
if (!isActive) {
return false; // Token not found, expired, or revoked
}

// Token exists and is active, update last_used timestamp (throttled)
try {
const UPDATE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
// FalkorDB returns objects, not arrays
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tokenData = result.data[0] as any;
const lastUsed = tokenData.last_used ? tokenData.last_used * 1000 : 0; // Convert to ms
const nowMs = Date.now();

// Only update if more than 5 minutes have passed since last update
if (nowMs - lastUsed > UPDATE_THRESHOLD_MS) {
const nowUnix = Math.floor(nowMs / 1000);
const updateQuery = `
MATCH (t:Token {token_hash: '${escapeString(tokenHash)}'})
SET t.last_used = ${nowUnix}
`;

await executePATQuery(updateQuery);
}
} catch (updateError) {
// If we can't update, still consider token active if it exists
// eslint-disable-next-line no-console
console.warn("Failed to update last_used timestamp:", updateError);
}
// Note: updateLastUsed in storage implementations should handle throttling
// For now, we'll skip the throttling logic here and just update
// This could be improved by adding a getTokenByHash method to get the token_id

return true;

Expand All @@ -111,28 +81,12 @@ export async function getPasswordFromTokenDB(tokenId: string): Promise<string> {
// Import decrypt function dynamically to avoid circular dependencies
const { decrypt } = await import('./encryption');

// Helper to escape strings for Cypher
const escapeString = (str: string) => str.replace(/'/g, "''");

// Query Token DB for the encrypted password
const query = `
MATCH (t:Token {token_id: '${escapeString(tokenId)}'})
WHERE t.is_active = true
RETURN t.encrypted_password as encrypted_password, t.token_id as token_id
`;

const result = await executePATQuery(query);

if (!result || !result.data || result.data.length === 0) {
throw new Error(`Token not found or inactive: ${tokenId}`);
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tokenData = result.data[0] as any;
const encryptedPassword = tokenData.encrypted_password;
// Get encrypted password using storage abstraction
const storage = StorageFactory.getStorage();
const encryptedPassword = await storage.getEncryptedPassword(tokenId);

if (!encryptedPassword) {
throw new Error(`No password stored for token: ${tokenId}`);
throw new Error(`Token not found or inactive: ${tokenId}`);
}

// Decrypt and return password
Expand Down
73 changes: 22 additions & 51 deletions app/api/auth/tokens/[tokenId]/route.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import { executePATQuery } from "@/lib/token-storage";
import StorageFactory from "@/lib/token-storage/StorageFactory";
import { getClient } from "../../[...nextauth]/options";

/**
* Fetches token details from FalkorDB by token_id
* Fetches token details by token_id using storage abstraction
*/
async function fetchTokenById(tokenId: string): Promise<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tokenData?: any;
error?: NextResponse;
}> {
const escapeString = (str: string) => str.replace(/'/g, "''");
const query = `
MATCH (t:Token {token_id: '${escapeString(tokenId)}'})-[:BELONGS_TO]->(u:User)
RETURN t.token_hash as token_hash,
t.token_id as token_id,
t.user_id as user_id,
t.username as username,
t.name as name,
t.role as role,
t.host as host,
t.port as port,
t.created_at as created_at,
t.expires_at as expires_at,
t.last_used as last_used,
t.is_active as is_active
`;
const storage = StorageFactory.getStorage();
const token = await storage.fetchTokenById(tokenId);

const result = await executePATQuery(query);

if (!result.data || result.data.length === 0) {
if (!token) {
return {
error: NextResponse.json(
{ message: "Token not found" },
Expand All @@ -38,23 +22,20 @@ async function fetchTokenById(tokenId: string): Promise<{
};
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const row = result.data[0] as any;

return {
tokenData: {
token_hash: row.token_hash,
token_id: row.token_id,
user_id: row.user_id,
username: row.username,
name: row.name,
role: row.role,
host: row.host,
port: row.port,
created_at: new Date(row.created_at * 1000).toISOString(),
expires_at: row.expires_at > 0 ? new Date(row.expires_at * 1000).toISOString() : null,
last_used: row.last_used > 0 ? new Date(row.last_used * 1000).toISOString() : null,
is_active: row.is_active,
token_hash: token.token_hash,
token_id: token.token_id,
user_id: token.user_id,
username: token.username,
name: token.name,
role: token.role,
host: token.host,
port: token.port,
created_at: new Date(token.created_at * 1000).toISOString(),
expires_at: token.expires_at > 0 ? new Date(token.expires_at * 1000).toISOString() : null,
last_used: token.last_used > 0 ? new Date(token.last_used * 1000).toISOString() : null,
is_active: token.is_active,
},
};
}
Expand Down Expand Up @@ -218,25 +199,15 @@ export async function DELETE(
return permissionCheck.error!;
}

// Revoke token in database
const escapeString = (str: string) => str.replace(/'/g, "''");
const nowUnix = Math.floor(Date.now() / 1000);
// Revoke token using storage abstraction
const storage = StorageFactory.getStorage();
const revokerUsername = authenticatedUser.username || "default";

const revokeQuery = `
MATCH (t:Token {token_id: '${escapeString(tokenId)}'})-[:BELONGS_TO]->(u:User)
MATCH (revoker:User {username: '${escapeString(revokerUsername)}'})
SET t.is_active = false
CREATE (t)-[:REVOKED_BY {at: ${nowUnix}}]->(revoker)
RETURN t.token_id as token_id
`;

const revokeResult = await executePATQuery(revokeQuery);
const success = await storage.revokeToken(tokenId, revokerUsername);

// Validate query succeeded
if (!revokeResult.data || revokeResult.data.length === 0) {
if (!success) {
return NextResponse.json(
{ message: "Failed to revoke token - token or user not found" },
{ message: "Failed to revoke token" },
{ status: 500 }
);
}
Expand Down
48 changes: 21 additions & 27 deletions app/api/auth/tokens/credentials/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
// eslint-disable-next-line import/no-extraneous-dependencies
import { SignJWT } from "jose";
import crypto from "crypto";
import { executePATQuery } from "@/lib/token-storage";
import StorageFactory from "@/lib/token-storage/StorageFactory";
import { newClient, generateTimeUUID } from "../../[...nextauth]/options";
import { encrypt } from "../../encryption";
import { login, validateBody } from "../../../validate-body";
Expand Down Expand Up @@ -163,48 +163,42 @@ export async function POST(request: NextRequest) {

const token = await signer.sign(jwtSecret);

// 7. Encrypt password and store token in Token DB (6380)
// 7. Encrypt password and store token using storage abstraction
try {
const storage = StorageFactory.getStorage();

const encryptedPassword = encrypt(userPassword);
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const nowUnix = Math.floor(Date.now() / 1000);
const expiresAtUnix = expiresAtDate ? Math.floor(expiresAtDate.getTime() / 1000) : -1;

const escapeString = (str: string) => str.replace(/'/g, "''");

// Normalize host and port with defaults
const tokenUsername = authenticatedUser.username || "default";
const tokenHost = authenticatedUser.host || "localhost";
const tokenPort = authenticatedUser.port || 6379;
const { role: tokenRole } = authenticatedUser;

const query = `
MERGE (u:User {username: '${escapeString(tokenUsername)}', user_id: '${escapeString(authenticatedUser.id)}'})
CREATE (t:Token {
token_hash: '${escapeString(tokenHash)}',
token_id: '${escapeString(tokenId)}',
user_id: '${escapeString(authenticatedUser.id)}',
username: '${escapeString(tokenUsername)}',
name: '${escapeString(name)}',
role: '${escapeString(tokenRole)}',
host: '${escapeString(tokenHost)}',
port: ${tokenPort},
created_at: ${nowUnix},
expires_at: ${expiresAtUnix},
last_used: -1,
is_active: true,
encrypted_password: '${escapeString(encryptedPassword)}'
})
CREATE (t)-[:BELONGS_TO]->(u)
RETURN t.token_id as token_id
`;

await executePATQuery(query);
await storage.createToken({
token_hash: tokenHash,
token_id: tokenId,
user_id: authenticatedUser.id,
username: tokenUsername,
name,
role: tokenRole,
host: tokenHost,
port: tokenPort,
created_at: nowUnix,
expires_at: expiresAtUnix,
last_used: -1,
is_active: true,
encrypted_password: encryptedPassword,
});

// eslint-disable-next-line no-console
console.log('Token stored successfully for user:', authenticatedUser.username, 'tokenId:', tokenId);
} catch (storageError) {
// eslint-disable-next-line no-console
console.error('Failed to store token in FalkorDB for user:', authenticatedUser.username, storageError);
console.error('Failed to store token for user:', authenticatedUser.username, storageError);
// Continue - token will still work but can't be managed via UI
}

Expand Down
Loading
Loading