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
33 changes: 28 additions & 5 deletions src/modules/auth/auth.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { checkUserEmailExists, createNewUserWithPassword } from './auth.utils';
import { SendMailAsync } from '../../utils/mail.utils';
import { HTTP_STATUS } from '../../utils/logger.utils';
import bcrypt from 'bcrypt';
import { refreshAccessToken } from './token-refresh.utils';

export const httpRegisterUserWithPassword: AsyncController = async (
req,
Expand Down Expand Up @@ -139,13 +140,35 @@ export const httpResetPassword: AsyncController = async (req, res, next) => {

export const httpRefreshToken: AsyncController = async (req, res, next) => {
try {
console.log(req);
res.status(200).json({
const authHeader = req.headers.authorization;
const token =
(authHeader && authHeader.startsWith('Bearer ')
? authHeader.slice('Bearer '.length)
: undefined) ?? req.body?.token;

if (!token) {
return res.status(HTTP_STATUS.UNAUTHORIZED).json({
success: false,
code: 'invalid_token',
message: 'No token provided',
});
}

const result = refreshAccessToken(token);

if (!result.success) {
return res.status(result.status).json({
success: false,
code: result.code,
message: 'Token could not be refreshed',
});
}

return res.status(HTTP_STATUS.OK).json({
success: true,
message: 'Login Successful',
message: 'Token refreshed',
data: {
name: 'Anioke Sebastian',
age: 23,
accessToken: result.token,
},
});
} catch (error) {
Expand Down
7 changes: 6 additions & 1 deletion src/modules/auth/auth.routes.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { Router } from 'express';
import { httpLogin, httpRegisterUserWithPassword } from './auth.controllers';
import {
httpLogin,
httpRegisterUserWithPassword,
httpRefreshToken,
} from './auth.controllers';

const authRouter = Router();

authRouter.post('/login', httpLogin);
authRouter.post('/register', httpRegisterUserWithPassword);
authRouter.post('/refresh', httpRefreshToken);

export default authRouter;
84 changes: 84 additions & 0 deletions src/modules/auth/token-refresh.utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { signJwt } from '../../utils/jwt.utils';
import {
refreshAccessToken,
REFRESH_WINDOW_SECONDS,
} from './token-refresh.utils';

describe('refreshAccessToken', () => {
it('issues a new token when the current token has 10 minutes remaining (inside the refresh window)', () => {
const token = signJwt({ sub: 'user-123' }, 10 * 60);

const result = refreshAccessToken(token);

expect(result.success).toBe(true);
if (result.success) {
expect(typeof result.token).toBe('string');
expect(result.token).not.toBe(token);
}
});

it('returns 400 refresh_not_due for a token with 2 hours remaining (outside the refresh window)', () => {
const token = signJwt({ sub: 'user-123' }, 2 * 60 * 60);

const result = refreshAccessToken(token);

expect(result.success).toBe(false);
if (!result.success) {
expect(result.status).toBe(400);
expect(result.code).toBe('refresh_not_due');
}
});

it('returns 401 token_expired for an already-expired token', () => {
const token = signJwt({ sub: 'user-123' }, -60);

const result = refreshAccessToken(token);

expect(result.success).toBe(false);
if (!result.success) {
expect(result.status).toBe(401);
expect(result.code).toBe('token_expired');
}
});

it("the new token's sub matches the original token's sub", () => {
const token = signJwt({ sub: 'user-abc-999' }, 5 * 60);

const result = refreshAccessToken(token);

expect(result.success).toBe(true);
if (result.success) {
expect(result.sub).toBe('user-abc-999');
}
});

it('does not invalidate the old token after refresh (stateless refresh)', () => {
const token = signJwt({ sub: 'user-123' }, 10 * 60);

const firstRefresh = refreshAccessToken(token);
expect(firstRefresh.success).toBe(true);

// The original token is not tracked/blacklisted, so refreshing again
// with the same original token still succeeds.
const secondRefresh = refreshAccessToken(token);
expect(secondRefresh.success).toBe(true);
});

it('treats a token exactly at the edge of the refresh window as due for refresh', () => {
const token = signJwt({ sub: 'user-123' }, REFRESH_WINDOW_SECONDS - 1);

const result = refreshAccessToken(token);

expect(result.success).toBe(true);
});

it('returns 401 invalid_token for a malformed token', () => {
const result = refreshAccessToken('not-a-real-token');

expect(result.success).toBe(false);
if (!result.success) {
expect(result.status).toBe(401);
expect(result.code).toBe('invalid_token');
}
});
});
63 changes: 63 additions & 0 deletions src/modules/auth/token-refresh.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { decodeJwt, signJwt, JwtError } from '../../utils/jwt.utils';

/** How long a freshly issued access token is valid for, in seconds (1 hour). */
export const ACCESS_TOKEN_TTL_SECONDS = 60 * 60;

/**
* The window, in seconds, before expiry during which a token is eligible
* for refresh. A token with more than this much time remaining is not yet
* due for refresh.
*/
export const REFRESH_WINDOW_SECONDS = 30 * 60;

export interface TokenRefreshSuccess {
success: true;
token: string;
sub: string;
}

export interface TokenRefreshFailure {
success: false;
status: 400 | 401;
code: 'refresh_not_due' | 'token_expired' | 'invalid_token';
}

export type TokenRefreshResult = TokenRefreshSuccess | TokenRefreshFailure;

/**
* Issues a new access token for a valid, still-live token that is within its
* refresh window (i.e. close to expiring but not expired yet).
*
* Refresh is stateless: the original token is not tracked or invalidated,
* so it remains valid until its own expiry even after a refresh is issued.
*
* - Token with more than REFRESH_WINDOW_SECONDS remaining -> 400 refresh_not_due
* - Token already past its exp -> 401 token_expired
* - Token within the refresh window -> a new token is issued with the same `sub`
*/
export function refreshAccessToken(token: string): TokenRefreshResult {
let payload;
try {
payload = decodeJwt(token);
} catch (error) {
if (error instanceof JwtError) {
return { success: false, status: 401, code: 'invalid_token' };
}
throw error;
}

const nowSeconds = Math.floor(Date.now() / 1000);
const secondsRemaining = payload.exp - nowSeconds;

if (secondsRemaining <= 0) {
return { success: false, status: 401, code: 'token_expired' };
}

if (secondsRemaining > REFRESH_WINDOW_SECONDS) {
return { success: false, status: 400, code: 'refresh_not_due' };
}

const newToken = signJwt({ sub: payload.sub }, ACCESS_TOKEN_TTL_SECONDS);

return { success: true, token: newToken, sub: payload.sub };
}
25 changes: 25 additions & 0 deletions src/modules/indexer/indexer-pipeline.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { updateIndexedLedger } from './ledger-gap-detection.service';
import { logger } from '../../utils/logger.utils';
import { processIndexerChainEvents, IndexerChainEvent } from '../../utils/indexer-event-processor.utils';
import { dedupeChainEvents } from '../../utils/indexer-dedupe.utils';
import { logSellTransactionConfirmed } from '../../utils/sell-transaction-logger.utils';

/**
* Processes a batch of on-chain trade events (KEY_BOUGHT or KEY_SOLD).
Expand Down Expand Up @@ -68,6 +69,30 @@ export async function processTradeEvents(events: IndexerChainEvent[]): Promise<v
tradeAt: new Date(tradeAt),
ledger: Number(ledger),
});

// 4. Emit a structured log for confirmed sells, mirroring buy-side logging.
if (event.eventType === 'KEY_SOLD') {
const [creatorProfile, supplyAggregate] = await Promise.all([
prisma.creatorProfile.findUnique({
where: { id: creatorId },
select: { user: { select: { stellarWallet: { select: { address: true } } } } },
}),
prisma.keyOwnership.aggregate({
where: { creatorId },
_sum: { balance: true },
}),
]);

logSellTransactionConfirmed({
sellerWallet: actor,
creatorWallet: creatorProfile?.user?.stellarWallet?.address ?? '',
keyAmount: Number(amount),
xlmReceivedStroops: BigInt(price),
newSupply: Number(supplyAggregate._sum.balance ?? 0),
txHash: event.txHash,
confirmedAt: new Date(tradeAt),
});
}
});

const uniqueEvents = dedupeChainEvents(events);
Expand Down
1 change: 1 addition & 0 deletions src/modules/wallets/wallet-activity.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export const WalletActivityItemSchema = z.object({
amount: z.any(),
price_at_trade: z.any(),
fee_paid: z.any(),
xlm_delta: z.string().nullable().optional(),
ledger_sequence: z.number().nullable(),
timestamp: z.date(),
});
Expand Down
19 changes: 18 additions & 1 deletion src/modules/wallets/wallet-activity.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
* { amount, price_at_trade, fee_paid, ledger_sequence }
*/
import { decodeCursor, encodeCursor } from '../../utils/cursor.utils';
import { formatXlmDelta } from '../../utils/xlm-delta.utils';

/**
* Shape of decoded activity cursor
Expand Down Expand Up @@ -106,16 +107,32 @@ export async function fetchWalletActivity(
createdAt: Date;
}) => {
const payload = (row.payload ?? {}) as Record<string, any>;
const type = row.type === 'KEY_BOUGHT' ? 'buy' : 'sell';

// A buy spends XLM (outgoing); a sell receives XLM (incoming).
let xlmDelta: string | null = null;
if (payload.price_at_trade != null) {
try {
xlmDelta = formatXlmDelta(
BigInt(payload.price_at_trade),
type === 'buy' ? 'out' : 'in'
);
} catch (_e) {
xlmDelta = null;
}
}

return {
id: row.id,
type: row.type === 'KEY_BOUGHT' ? 'buy' : 'sell',
type,
creator_id: row.creatorId ?? '',
creator_handle: row.creatorId
? (handleMap.get(row.creatorId) ?? null)
: null,
amount: payload.amount ?? null,
price_at_trade: payload.price_at_trade ?? null,
fee_paid: payload.fee_paid ?? null,
xlm_delta: xlmDelta,
ledger_sequence:
payload.ledger_sequence != null
? Number(payload.ledger_sequence)
Expand Down
95 changes: 95 additions & 0 deletions src/utils/jwt.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { createHmac, timingSafeEqual } from 'crypto';

const JWT_SECRET = process.env.JWT_SECRET || 'dev-only-insecure-secret';

export interface JwtPayload {
sub: string;
iat: number;
exp: number;
[key: string]: unknown;
}

function base64UrlEncode(input: string): string {
return Buffer.from(input, 'utf8')
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}

function base64UrlDecode(input: string): string {
const padded = input.replace(/-/g, '+').replace(/_/g, '/');
const padding = padded.length % 4 === 0 ? '' : '='.repeat(4 - (padded.length % 4));
return Buffer.from(padded + padding, 'base64').toString('utf8');
}

function sign(data: string): string {
return createHmac('sha256', JWT_SECRET).update(data).digest('base64url');
}

/**
* Signs a minimal HMAC-SHA256 JWT-style token. Not a full JWT implementation
* (no header alg negotiation), but structurally compatible: header.payload.signature.
*/
export function signJwt(
payload: { sub: string; [key: string]: unknown },
expiresInSeconds: number
): string {
const now = Math.floor(Date.now() / 1000);
const fullPayload: JwtPayload = {
...payload,
sub: payload.sub,
iat: now,
exp: now + expiresInSeconds,
};

const header = base64UrlEncode(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const body = base64UrlEncode(JSON.stringify(fullPayload));
const signature = sign(`${header}.${body}`);

return `${header}.${body}.${signature}`;
}

export class JwtError extends Error {
code: 'malformed' | 'invalid_signature';

constructor(code: 'malformed' | 'invalid_signature', message: string) {
super(message);
this.name = 'JwtError';
this.code = code;
}
}

/**
* Decodes and verifies the signature of a token WITHOUT checking expiry.
* Callers that need to distinguish "expired" from "not yet due for refresh"
* must check `exp` against the current time themselves.
*
* @throws {JwtError} when the token is malformed or the signature is invalid
*/
export function decodeJwt(token: string): JwtPayload {
const parts = token.split('.');
if (parts.length !== 3) {
throw new JwtError('malformed', 'Token must have three segments');
}

const [header, body, signature] = parts;

const expectedSignature = sign(`${header}.${body}`);
const providedSigBuf = Buffer.from(signature);
const expectedSigBuf = Buffer.from(expectedSignature);

const signaturesMatch =
providedSigBuf.length === expectedSigBuf.length &&
timingSafeEqual(providedSigBuf, expectedSigBuf);

if (!signaturesMatch) {
throw new JwtError('invalid_signature', 'Token signature is invalid');
}

try {
return JSON.parse(base64UrlDecode(body)) as JwtPayload;
} catch {
throw new JwtError('malformed', 'Token payload is not valid JSON');
}
}
Loading
Loading