Skip to content
Closed
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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,12 @@ LOCKOUT_DURATION_MINUTES=30
# Optional: SendGrid template ID for the lockout notification email.
# If omitted, an inline HTML fallback is used.
SENDGRID_LOCKOUT_TEMPLATE_ID=
# Optional: SendGrid template ID for the email verification email.
# If omitted, an inline HTML fallback is used.
SENDGRID_EMAIL_VERIFICATION_TEMPLATE_ID=
# Optional: Public base URL used to build the verification link sent to the
# user's email. When omitted the link is derived from the incoming request.
EMAIL_VERIFICATION_BASE_URL=
# ---------------------------------------------------------------------------
# Stellar Network
# ---------------------------------------------------------------------------
Expand Down
3,460 changes: 3,460 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions migrations/20260727000001_add_email_verified_to_users.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
-- Migration: 20260727000001_add_email_verified_to_users
-- Description: Add email_verified flag and timestamp to users table for
-- Issue #35 (developer email verification).
-- Up migration

ALTER TABLE users
ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT false;

ALTER TABLE users
ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMP NULL;

CREATE INDEX IF NOT EXISTS idx_users_email_verified
ON users(email_verified);
142 changes: 142 additions & 0 deletions src/auth/__tests__/emailVerificationToken.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { redisClient } from "../../config/redis";
import jwt from "jsonwebtoken";
import {
issueEmailVerificationToken,
consumeEmailVerificationToken,
revokeEmailVerificationToken,
decodeEmailVerificationToken,
EMAIL_VERIFICATION_TTL_SECONDS,
} from "../emailVerification";

jest.mock("../../config/redis", () => ({
redisClient: {
isOpen: true,
get: jest.fn(),
set: jest.fn(),
del: jest.fn(),
quit: jest.fn(),
},
}));

describe("Email Verification Token", () => {
beforeEach(() => {
process.env.NODE_ENV = "test";
process.env.JWT_SECRET = "test-secret-for-email-verification";
jest.clearAllMocks();
});

afterEach(() => {
delete process.env.JWT_SECRET;
});

describe("issueEmailVerificationToken", () => {
it("should issue a valid JWT with 24h expiry and store in Redis", async () => {
const { token, tokenId, expiresInSeconds } =
await issueEmailVerificationToken("user-1");

expect(expiresInSeconds).toBe(EMAIL_VERIFICATION_TTL_SECONDS);
expect(typeof token).toBe("string");
expect(token.split(".")).toHaveLength(3);
expect(typeof tokenId).toBe("string");

expect(redisClient.set).toHaveBeenCalledWith(
`verify_email:${tokenId}`,
"user-1",
{ EX: EMAIL_VERIFICATION_TTL_SECONDS },
);
});

it("should not store in Redis when Redis is not open", async () => {
(redisClient as any).isOpen = false;

const { token, tokenId } = await issueEmailVerificationToken("user-2");

expect(token).toBeDefined();
expect(tokenId).toBeDefined();
expect(redisClient.set).not.toHaveBeenCalled();

(redisClient as any).isOpen = true;
});
});

describe("decodeEmailVerificationToken", () => {
it("should decode a valid token and return remainingSeconds > 0", async () => {
const { token } = await issueEmailVerificationToken("user-42");
const decoded = decodeEmailVerificationToken(token);

expect(decoded.userId).toBe("user-42");
expect(decoded.purpose).toBe("email_verification");
expect(decoded.tokenId).toBeDefined();
expect(decoded.remainingSeconds).toBeGreaterThan(0);
});

it("should reject tokens with wrong purpose", async () => {
const token = jwt.sign(
{ userId: "user-1", purpose: "wrong_purpose", tokenId: "abc" },
process.env.JWT_SECRET!,
{ expiresIn: "24h" },
);

expect(() => decodeEmailVerificationToken(token)).toThrow(
"Token was not issued for email verification",
);
});

it("should throw on an invalid token", () => {
expect(() => decodeEmailVerificationToken("not.a.token")).toThrow(
"Invalid email verification token",
);
});

it("should throw on expired token", () => {
const expired = jwt.sign(
{ userId: "x", purpose: "email_verification", tokenId: "x" },
process.env.JWT_SECRET!,
{ expiresIn: 0 },
);

// allow a moment for the JWT to actually expire
expect(() => decodeEmailVerificationToken(expired)).toThrow(
"Email verification token has expired",
);
});
});

describe("consumeEmailVerificationToken", () => {
it("should consume a valid token and delete the Redis key", async () => {
const { token, tokenId } = await issueEmailVerificationToken("user-7");

(redisClient.get as jest.Mock).mockResolvedValueOnce("user-7");

const result = await consumeEmailVerificationToken(token);

expect(result.userId).toBe("user-7");
expect(result.tokenId).toBe(tokenId);
expect(redisClient.del).toHaveBeenCalledWith(`verify_email:${tokenId}`);
});

it("should throw if the Redis entry is missing (already consumed)", async () => {
const { token } = await issueEmailVerificationToken("user-8");

(redisClient.get as jest.Mock).mockResolvedValueOnce(null);

await expect(consumeEmailVerificationToken(token)).rejects.toThrow(
"Email verification token has been used or revoked",
);
});
});

describe("revokeEmailVerificationToken", () => {
it("should delete the Redis key when Redis is open", async () => {
await revokeEmailVerificationToken("tok-abc");
expect(redisClient.del).toHaveBeenCalledWith("verify_email:tok-abc");
});

it("should not attempt a Redis call when Redis is not open", async () => {
(redisClient as any).isOpen = false;
await revokeEmailVerificationToken("tok-xyz");
expect(redisClient.del).not.toHaveBeenCalled();
(redisClient as any).isOpen = true;
});
});
});
146 changes: 146 additions & 0 deletions src/auth/emailVerification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import jwt from "jsonwebtoken";
import dotenv from "dotenv";
import { v4 as uuidv4 } from "uuid";
import { redisClient } from "../config/redis";

dotenv.config();

export const EMAIL_VERIFICATION_TTL_SECONDS = 24 * 60 * 60; // 24h
export const EMAIL_VERIFICATION_PURPOSE = "email_verification" as const;

export interface EmailVerificationPayload {
userId: string;
purpose: typeof EMAIL_VERIFICATION_PURPOSE;
tokenId: string;
iat?: number;
exp?: number;
}

const REDIS_KEY_PREFIX = "verify_email:";

function getRedisKey(tokenId: string): string {
return `${REDIS_KEY_PREFIX}${tokenId}`;
}

function getJwtSecret(): string {
const secret = process.env.JWT_SECRET;
if (!secret) {
throw new Error("JWT_SECRET is not defined in environment variables");
}
return secret;
}

/**
* Issue a signed JWT-based email verification token for a user and persist
* its lookup key in Redis with a 24-hour TTL so requests can be matched
* without an extra DB read.
*/
export async function issueEmailVerificationToken(
userId: string,
): Promise<{ token: string; tokenId: string; expiresInSeconds: number }> {
const tokenId = uuidv4();
const payload: Omit<EmailVerificationPayload, "iat" | "exp"> = {
userId,
purpose: EMAIL_VERIFICATION_PURPOSE,
tokenId,
};

const token = jwt.sign(payload, getJwtSecret(), {
expiresIn: EMAIL_VERIFICATION_TTL_SECONDS,
});

if (redisClient.isOpen) {
await redisClient.set(getRedisKey(tokenId), userId, {
EX: EMAIL_VERIFICATION_TTL_SECONDS,
});
}

return {
token,
tokenId,
expiresInSeconds: EMAIL_VERIFICATION_TTL_SECONDS,
};
}

/**
* Verify the JWT signature and payload integrity of an email verification
* token. Returns the decoded payload alongside the time remaining before
* expiration (in seconds). Surfacing both gives callers enough context to
* decide whether to attempt Redis lookup, surface helpful errors, or trigger
* a resend flow.
*/
export function decodeEmailVerificationToken(
token: string,
): EmailVerificationPayload & { remainingSeconds: number } {
let decoded: EmailVerificationPayload;
try {
decoded = jwt.verify(token, getJwtSecret()) as EmailVerificationPayload;
} catch (error: unknown) {
if (error instanceof jwt.TokenExpiredError) {
throw new Error("Email verification token has expired", { cause: error });
}
if (error instanceof jwt.JsonWebTokenError) {
throw new Error("Invalid email verification token", { cause: error });
}
throw new Error("Email verification token verification failed", {
cause: error,
});
}

if (decoded.purpose !== EMAIL_VERIFICATION_PURPOSE) {
throw new Error("Token was not issued for email verification");
}

const nowSeconds = Math.floor(Date.now() / 1000);
const exp = decoded.exp ?? nowSeconds;

return {
...decoded,
remainingSeconds: Math.max(0, exp - nowSeconds),
};
}

export interface ConsumeResult {
userId: string;
tokenId: string;
}

/**
* Verify the token and atomically invalidate its Redis lookup entry. The
* Redis DEL is what guarantees single-use semantics for stale tokens — we
* intentionally avoid touching the JWT's signature here, since invalidating
* via Redis is what the acceptance criteria calls for ("invalidates the
* token").
*/
export async function consumeEmailVerificationToken(
token: string,
): Promise<ConsumeResult> {
const decoded = decodeEmailVerificationToken(token);

const storedUserId = redisClient.isOpen
? await redisClient.get(getRedisKey(decoded.tokenId))
: decoded.userId;

if (!storedUserId) {
throw new Error("Email verification token has been used or revoked");
}

if (redisClient.isOpen) {
await redisClient.del(getRedisKey(decoded.tokenId));
}

return {
userId: storedUserId === decoded.userId ? decoded.userId : storedUserId,
tokenId: decoded.tokenId,
};
}

/**
* Manually invalidate a still-valid verification token (e.g. on resend).
*/
export async function revokeEmailVerificationToken(
tokenId: string,
): Promise<void> {
if (!redisClient.isOpen) return;
await redisClient.del(getRedisKey(tokenId));
}
4 changes: 3 additions & 1 deletion src/constants/errorCodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const ERROR_CODES = {
CONFLICT: "CONFLICT",
DUPLICATE_REQUEST: "DUPLICATE_REQUEST",
TRANSACTION_EXISTS: "TRANSACTION_EXISTS",
EMAIL_UNVERIFIED: "EMAIL_UNVERIFIED",

// Security / abuse-prevention errors (4290-4299) - HTTP 429
ACCOUNT_LOCKED: "ACCOUNT_LOCKED",
Expand Down Expand Up @@ -105,7 +106,8 @@ export const getHttpStatus = (code: string): number => {
}
if (
code === ERROR_CODES.FORBIDDEN ||
code === ERROR_CODES.INSUFFICIENT_PERMISSIONS
code === ERROR_CODES.INSUFFICIENT_PERMISSIONS ||
code === ERROR_CODES.EMAIL_UNVERIFIED
) {
return 403;
}
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,7 @@ app.use(
);

if (process.env.SENTRY_DSN) {
app.use(Sentry.expressErrorHandler());
Sentry.expressErrorHandler()
}

app.use(timeoutErrorHandler);
Expand Down
1 change: 1 addition & 0 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"TRANSACTION_FAILED": "Transaction processing failed",
"PROVIDER_ERROR": "Mobile money provider error",
"RATE_LIMIT": "Too many requests. Please try again later",
"EMAIL_UNVERIFIED": "Email address is not verified",
"INTERNAL_ERROR": "Internal server error",
"SERVICE_UNAVAILABLE": "Service temporarily unavailable",
"DATABASE_ERROR": "Database operation failed",
Expand Down
Loading
Loading