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
27 changes: 9 additions & 18 deletions src/config/logger.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,10 @@
import { Logger, LogLevel } from "@/utils/logger/index.ts";
import { loadOptionalEnv } from "@/utils/env/loadEnv.ts";

const LOG_LEVEL =
(loadOptionalEnv("LOG_LEVEL") ?? "INFO") as keyof typeof LogLevel;

if (LOG_LEVEL !== undefined && LOG_LEVEL in LogLevel) {
// Valid log level
} else {
console.warn(
`Invalid LOG_LEVEL: "${LOG_LEVEL}". Falling back to INFO. Valid values: ${
Object.keys(LogLevel).filter((k) => isNaN(Number(k))).join(", ")
}`,
);
import { type Logger, newLogger, parseLevel } from "@/utils/logger/index.ts";

/**
* Creates the root logger from `LOG_LEVEL` env var. Called once in main.ts;
* the returned logger is threaded through to every service and route handler
* via dependency injection. There is no module-level singleton.
*/
export function createLogger(): Logger {
return newLogger(parseLevel(Deno.env.get("LOG_LEVEL")));
}

const LOG = new Logger(LogLevel[LOG_LEVEL] ?? LogLevel.INFO);

export { LOG };
5 changes: 2 additions & 3 deletions src/core/service/auth/service/service-auth-secret.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,8 @@ if (!SERVICE_AUTH_SECRET || SERVICE_AUTH_SECRET.trim().length === 0) {
"SERVICE_AUTH_SECRET must be set and non-empty in production. A random secret would invalidate all JWTs on restart.",
);
}
console.warn(
"WARNING: SERVICE_AUTH_SECRET is not set. Generating a random secret. This is NOT recommended for production environments.",
);
// Dev mode: a random secret is generated below. main.ts emits an event
// when bootstrap detects this so the logger can carry the notice.
}

export const authSecret = SERVICE_AUTH_SECRET || generateSecret();
Expand Down
29 changes: 23 additions & 6 deletions src/core/service/auth/wallet-auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Keypair } from "stellar-sdk";
import { Buffer } from "buffer";
import { LOG } from "@/config/logger.ts";
import type { Logger } from "@/utils/logger/index.ts";
import { withSpan } from "@/core/tracing.ts";

const MAX_PENDING_CHALLENGES = 1000;
Expand All @@ -18,15 +18,22 @@ interface PendingChallenge {

const pendingChallenges = new Map<string, PendingChallenge>();

export function createWalletChallenge(publicKey: string): { nonce: string } {
cleanupExpiredChallenges();
export function createWalletChallenge(
publicKey: string,
deps: { log: Logger },
): { nonce: string } {
const log = deps.log.scope("createWalletChallenge");
log.info("createWalletChallenge");
log.debug("publicKey", publicKey);

cleanupExpiredChallenges(deps);
if (pendingChallenges.size >= MAX_PENDING_CHALLENGES) {
throw new Error("Too many pending challenges. Try again later.");
}
const nonceBytes = crypto.getRandomValues(new Uint8Array(32));
const nonce = btoa(String.fromCharCode(...nonceBytes));
pendingChallenges.set(nonce, { nonce, publicKey, createdAt: Date.now() });
LOG.debug("Wallet challenge created", { publicKey });
log.event("wallet challenge created");
return { nonce };
}

Expand All @@ -39,7 +46,12 @@ export function verifyWalletChallenge(
signature: string,
publicKey: string,
config: WalletAuthConfig,
deps: { log: Logger },
): Promise<{ token: string }> {
const log = deps.log.scope("verifyWalletChallenge");
log.info("verifyWalletChallenge");
log.debug("publicKey", publicKey);

return withSpan("WalletAuth.verify", async (span) => {
span.setAttribute("wallet.public_key", publicKey);
const challenge = pendingChallenges.get(nonce);
Expand Down Expand Up @@ -112,16 +124,21 @@ export function verifyWalletChallenge(
).join("");
const token = await config.generateToken(publicKey, hashedSessionId);

LOG.info("Wallet auth successful", { publicKey });
log.event("wallet auth successful");
return { token };
});
}

function cleanupExpiredChallenges(): void {
function cleanupExpiredChallenges(deps: { log: Logger }): void {
const log = deps.log.scope("cleanupExpiredChallenges");
log.info("cleanupExpiredChallenges");
const now = Date.now();
let removed = 0;
for (const [nonce, challenge] of pendingChallenges) {
if (now - challenge.createdAt > challengeTtlMs) {
pendingChallenges.delete(nonce);
removed++;
}
}
log.debug("removed", removed);
}
45 changes: 33 additions & 12 deletions src/core/service/auth/wallet-auth_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import {
verifyWalletChallenge,
type WalletAuthConfig,
} from "./wallet-auth.ts";
import { newNoop } from "@/utils/logger/index.ts";

const TEST_TOKEN = "test-jwt-token";
const config: WalletAuthConfig = {
generateToken: (_subject: string, _sessionId: string) =>
Promise.resolve(TEST_TOKEN),
};
const deps = { log: newNoop() };

function signNonceRaw(kp: Keypair, nonce: string): string {
// Raw format: sign the decoded nonce bytes (matches the wallet
Expand All @@ -23,21 +25,22 @@ function signNonceRaw(kp: Keypair, nonce: string): string {

Deno.test("createWalletChallenge returns a base64 nonce", () => {
const kp = Keypair.random();
const { nonce } = createWalletChallenge(kp.publicKey());
const { nonce } = createWalletChallenge(kp.publicKey(), deps);
// 32 random bytes → 44 char base64 (with padding)
assertEquals(nonce.length, 44);
});

Deno.test("verifyWalletChallenge succeeds with a valid raw signature", async () => {
const kp = Keypair.random();
const { nonce } = createWalletChallenge(kp.publicKey());
const { nonce } = createWalletChallenge(kp.publicKey(), deps);
const signature = signNonceRaw(kp, nonce);

const { token } = await verifyWalletChallenge(
nonce,
signature,
kp.publicKey(),
config,
deps,
);
assertEquals(token, TEST_TOKEN);
});
Expand All @@ -51,6 +54,7 @@ Deno.test("verifyWalletChallenge rejects an unknown nonce", async () => {
"irrelevant",
kp.publicKey(),
config,
deps,
),
Error,
"Challenge not found or expired",
Expand All @@ -63,11 +67,18 @@ Deno.test("verifyWalletChallenge rejects on public key mismatch", async () => {
// must be rejected before the signature check.
const owner = Keypair.random();
const attacker = Keypair.random();
const { nonce } = createWalletChallenge(owner.publicKey());
const { nonce } = createWalletChallenge(owner.publicKey(), deps);
const signature = signNonceRaw(attacker, nonce);

await assertRejects(
() => verifyWalletChallenge(nonce, signature, attacker.publicKey(), config),
() =>
verifyWalletChallenge(
nonce,
signature,
attacker.publicKey(),
config,
deps,
),
Error,
"Public key mismatch",
);
Expand All @@ -76,12 +87,19 @@ Deno.test("verifyWalletChallenge rejects on public key mismatch", async () => {
Deno.test("verifyWalletChallenge rejects an invalid signature", async () => {
const kp = Keypair.random();
const other = Keypair.random();
const { nonce } = createWalletChallenge(kp.publicKey());
const { nonce } = createWalletChallenge(kp.publicKey(), deps);
// Signature from a different key — should fail across all 3 verification formats.
const badSignature = signNonceRaw(other, nonce);

await assertRejects(
() => verifyWalletChallenge(nonce, badSignature, kp.publicKey(), config),
() =>
verifyWalletChallenge(
nonce,
badSignature,
kp.publicKey(),
config,
deps,
),
Error,
"Invalid signature",
);
Expand All @@ -91,12 +109,13 @@ Deno.test("verifyWalletChallenge rejects an expired challenge", async () => {
setChallengeTtlMs(1); // 1ms TTL
try {
const kp = Keypair.random();
const { nonce } = createWalletChallenge(kp.publicKey());
const { nonce } = createWalletChallenge(kp.publicKey(), deps);
await new Promise((r) => setTimeout(r, 5));
const signature = signNonceRaw(kp, nonce);

await assertRejects(
() => verifyWalletChallenge(nonce, signature, kp.publicKey(), config),
() =>
verifyWalletChallenge(nonce, signature, kp.publicKey(), config, deps),
Error,
"Challenge expired",
);
Expand All @@ -107,14 +126,14 @@ Deno.test("verifyWalletChallenge rejects an expired challenge", async () => {

Deno.test("verifyWalletChallenge consumes the nonce on success (single-use)", async () => {
const kp = Keypair.random();
const { nonce } = createWalletChallenge(kp.publicKey());
const { nonce } = createWalletChallenge(kp.publicKey(), deps);
const signature = signNonceRaw(kp, nonce);

// First call succeeds…
await verifyWalletChallenge(nonce, signature, kp.publicKey(), config);
await verifyWalletChallenge(nonce, signature, kp.publicKey(), config, deps);
// …second call with the same nonce must fail (replay protection).
await assertRejects(
() => verifyWalletChallenge(nonce, signature, kp.publicKey(), config),
() => verifyWalletChallenge(nonce, signature, kp.publicKey(), config, deps),
Error,
"Challenge not found or expired",
);
Expand All @@ -123,7 +142,7 @@ Deno.test("verifyWalletChallenge consumes the nonce on success (single-use)", as
Deno.test("verifyWalletChallenge does NOT consume the nonce on a bad signature", async () => {
const kp = Keypair.random();
const other = Keypair.random();
const { nonce } = createWalletChallenge(kp.publicKey());
const { nonce } = createWalletChallenge(kp.publicKey(), deps);

// First, fail with a bad signature.
await assertRejects(
Expand All @@ -133,6 +152,7 @@ Deno.test("verifyWalletChallenge does NOT consume the nonce on a bad signature",
signNonceRaw(other, nonce),
kp.publicKey(),
config,
deps,
),
Error,
"Invalid signature",
Expand All @@ -145,6 +165,7 @@ Deno.test("verifyWalletChallenge does NOT consume the nonce on a bad signature",
goodSignature,
kp.publicKey(),
config,
deps,
);
assertEquals(token, TEST_TOKEN);
});
20 changes: 15 additions & 5 deletions src/core/service/payment/payment-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
*
* Sessions expire after 5 minutes if not submitted.
*/
import { LOG } from "@/config/logger.ts";
import type { Logger } from "@/utils/logger/index.ts";

const SESSION_TTL_MS = 5 * 60 * 1000;

Expand Down Expand Up @@ -47,17 +47,27 @@ export interface PaymentSession {

const sessions = new Map<string, PaymentSession>();

export function createSession(session: PaymentSession): void {
export function createSession(
session: PaymentSession,
deps: { log: Logger },
): void {
const log = deps.log.scope("paymentSession");
sessions.set(session.id, session);
LOG.debug("Payment session created", { id: session.id });
log.debug("id", session.id);
log.event("payment session created");
}

export function getSession(id: string): PaymentSession | undefined {
export function getSession(
id: string,
deps: { log: Logger },
): PaymentSession | undefined {
const log = deps.log.scope("paymentSession");
const session = sessions.get(id);
if (!session) return undefined;
if (Date.now() - session.createdAt > SESSION_TTL_MS) {
sessions.delete(id);
LOG.debug("Payment session expired", { id });
log.debug("id", id);
log.event("payment session expired");
return undefined;
}
return session;
Expand Down
20 changes: 16 additions & 4 deletions src/core/service/provider-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
*/
import { Keypair, Transaction } from "stellar-sdk";
import { PAY_SERVICE_SK } from "@/config/env.ts";
import { LOG } from "@/config/logger.ts";
import type { Logger } from "@/utils/logger/index.ts";
import { withSpan } from "@/core/tracing.ts";

interface CachedAuth {
Expand Down Expand Up @@ -51,7 +51,14 @@ function parseJwtExpiry(jwt: string): number {
* Get a valid JWT for the given provider-platform URL.
* Returns a cached JWT if still valid, otherwise authenticates fresh.
*/
export function getProviderJwt(ppUrl: string): Promise<string> {
export function getProviderJwt(
ppUrl: string,
deps: { log: Logger },
): Promise<string> {
const log = deps.log.scope("getProviderJwt");
log.info("getProviderJwt");
log.debug("ppUrl", ppUrl);

return withSpan("ProviderAuth.getJwt", async (span) => {
span.setAttribute("provider.url", ppUrl);
const cached = cache.get(ppUrl);
Expand All @@ -65,7 +72,8 @@ export function getProviderJwt(ppUrl: string): Promise<string> {
const publicKey = keypair.publicKey();
span.setAttribute("provider.public_key", publicKey);

LOG.debug("Authenticating with provider-platform", { ppUrl, publicKey });
log.debug("publicKey", publicKey);
log.event("requesting challenge");

// 1. Get challenge
const challengeRes = await fetch(
Expand All @@ -83,6 +91,8 @@ export function getProviderJwt(ppUrl: string): Promise<string> {
throw new Error("Provider returned no challenge XDR");
}

log.event("challenge received");

// 2. Co-sign the challenge transaction
// The provider uses "Standalone Network ; February 2017" for local,
// but we parse the XDR without needing the passphrase for signing —
Expand All @@ -94,6 +104,8 @@ export function getProviderJwt(ppUrl: string): Promise<string> {
tx.sign(keypair);
const signedXdr = tx.toXDR();

log.event("submitting signed challenge");

// 3. Submit co-signed challenge
const verifyRes = await fetch(`${ppUrl}/api/v1/stellar/auth`, {
method: "POST",
Expand All @@ -113,7 +125,7 @@ export function getProviderJwt(ppUrl: string): Promise<string> {
}

cache.set(ppUrl, { jwt, expiresAt: parseJwtExpiry(jwt) });
LOG.info("Authenticated with provider-platform", { ppUrl, publicKey });
log.event("authenticated with provider-platform");

return jwt;
});
Expand Down
Loading
Loading