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
16 changes: 14 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
NODE_ENV=development
PORT=3000
# Optional path every route is served under, e.g. api/v1 (GET /health stays
# at the root). Leave empty to serve from /. See docs/CONFIGURATION.md.
API_PREFIX=
CORS_ORIGINS=http://localhost:3001

# Optional distributed tracing (OTLP/HTTP). See docs/TRACING.md.
Expand All @@ -10,10 +13,19 @@ OTEL_TRACING_ENABLED=false
# postgresql://user:password@host:5432/db
DATABASE_URL=

# Must be at least 32 characters.
# Where secrets are read from: env (default), file, or custom. See the
# "Secret providers" section of docs/CONFIGURATION.md.
SECRETS_PROVIDER=env

# Must be at least 32 characters. In production it must also not be a
# placeholder and must have 10+ distinct characters — use e.g.
# `openssl rand -base64 48`. Required when SECRETS_PROVIDER=env.
JWT_SECRET=
# Path to a file holding the JWT secret. Required when SECRETS_PROVIDER=file.
# JWT_SECRET_FILE=/run/secrets/jwt_secret

# Soroban RPC endpoint (e.g. https://soroban-testnet.stellar.org)
# Soroban RPC endpoint (e.g. https://soroban-testnet.stellar.org). Boot fails
# if the URL names a different network than STELLAR_NETWORK.
SOROBAN_RPC_URL=
STELLAR_NETWORK=testnet

Expand Down
4 changes: 4 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ services return. When they drift, the generated Bruno collection under
[`bruno/`](bruno/README.md) fails CI, so treat this file and that
collection as two views of the same source.

Paths are shown without a prefix. When `API_PREFIX` is set (e.g.
`api/v1`), every route except `GET /health` is served under it — see
`docs/CONFIGURATION.md`.

The examples assume:

```bash
Expand Down
82 changes: 80 additions & 2 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,81 @@ run with a missing or malformed value. See
[`docs/NON_CUSTODIAL.md`](NON_CUSTODIAL.md) for what `JWT_SECRET` and
`PLATFORM_SIGNER_SECRET` are actually used for.

## Checks beyond per-variable format

Besides each variable's own format, `validate()` refuses to start when:

- **`JWT_SECRET` is weak.** Every environment requires at least 32
characters. With `NODE_ENV=production` the secret must also contain at
least 10 distinct characters and must not look like a placeholder: after
lower-casing and removing punctuation, it may not contain `changeme`,
`replaceme`, `yoursecret`, `jwtsecret`, `secretkey`, `mysecret`,
`placeholder`, `example`, `default`, `insecure`, `donotuse` or `password`.
Generate one with `openssl rand -base64 48`. The same rules apply to a
secret loaded through a [secret provider](#secret-providers).
- **`SOROBAN_RPC_URL` and `STELLAR_NETWORK` disagree.** The network
passphrase that transactions are signed with comes from `STELLAR_NETWORK`.
The app can't reach the RPC at boot, so it infers the RPC's network from
its URL instead: a host or path segment named `testnet`, `futurenet`,
`mainnet` or `pubnet` (for example `soroban-testnet.stellar.org` or
`rpc-futurenet.stellar.org`). If that network differs from
`STELLAR_NETWORK`, startup fails. URLs that name no network, or more than
one, pass this check; that covers self-hosted or local nodes such as
`http://localhost:8000/soroban/rpc`. `SOROBAN_RPC_URL` must be an `http`
or `https` URL.

## API path prefix

Set `API_PREFIX` (for example `api` or `api/v1`) to serve every route under
that path when the API is hosted behind a reverse proxy at a sub-path. With
`API_PREFIX=api/v1`, `POST /auth/login` becomes `POST /api/v1/auth/login`.
`GET /health` stays at the root so load-balancer and orchestrator probes
don't have to change. Leave it unset or empty to keep the current root
paths. Leading and trailing slashes are ignored. Only URL-safe path segments
are accepted, and `.` or `..` segments are rejected.

## Secret providers

`JWT_SECRET` is read through a pluggable `SecretProvider`
(`src/config/secrets/secret-provider.ts`), chosen with `SECRETS_PROVIDER`:

| `SECRETS_PROVIDER` | Reads `JWT_SECRET` from | Required variables |
|---|---|---|
| `env` (default) | the `JWT_SECRET` environment variable | `JWT_SECRET` |
| `file` | the file at `JWT_SECRET_FILE`, with surrounding whitespace trimmed | `JWT_SECRET_FILE` |
| `custom` | the provider passed to `SecretsModule.forRoot({ provider })` | — |

`file` works with Docker and Kubernetes secrets, and with secrets-manager
sidecars that mount values as files, such as Vault Agent or the AWS and GCP
Secrets Store CSI drivers. With `file`, the secret never has to be in the
process environment.

To read the secret straight from a secrets manager instead, implement the
interface and register the class in `src/app.module.ts`:

```ts
@Injectable()
export class VaultSecretProvider implements SecretProvider {
readonly name = 'vault';

constructor(private readonly config: ConfigService) {}

async getSecret(key: string): Promise<string | undefined> {
// Fetch `key` from your secrets manager here.
}
}

// app.module.ts
SecretsModule.forRoot({ provider: VaultSecretProvider }),
```

Then start the app with `SECRETS_PROVIDER=custom`. The secret is resolved
once at boot (`src/auth/jwt-secret.module.ts`) and is used both to sign
tokens (`JwtModule`) and to verify them (`JwtStrategy`). If the secret is
missing, or fails the strength rules above, the app doesn't start. Rotating
the secret requires a restart, and a restart invalidates tokens already
issued.

## Environment variables

<!-- BEGIN GENERATED: env table (npm run docs:env) -->
Expand All @@ -19,6 +94,7 @@ run with a missing or malformed value. See
| `OTEL_SERVICE_NAME` | string | optional | `stellar-tickets-backend` \* | yes | Service name attached to exported spans; defaults to stellar-tickets-backend.
| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | string | optional | `http://localhost:4318/v1/traces` \* | yes | OTLP HTTP trace collector URL. Defaults to http://localhost:4318/v1/traces.
| `PORT` | integer | **required** | `3000` | yes | TCP port the HTTP server binds. Match it to the `port` in docker-compose.yml when running the API in a container.
| `API_PREFIX` | — | optional | — | yes | Optional path every route is mounted under (e.g. `api/v1`), for hosting behind a reverse-proxy path. Health routes stay at the root.
| `MAX_ACTIVE_RESALE_LISTINGS_PER_USER` | integer | optional | `5` | yes | Soft cap on how many `ACTIVE` resale listings one seller may hold at once. Listing beyond it is rejected, not queued. See docs/RESALE_EXPIRY.md.
| `DATABASE_URL` | string | **required** | — | yes | PostgreSQL connection string for the Prisma client. This is the single system of record; see docs/DATABASE.md.
| `RATE_LIMIT_STORE` | `memory \| redis` | optional | `memory` | yes | Where rate-limit counters live. `memory` (default) is per-process; `redis` shares them across instances. See docs/RATE_LIMITING.md.
Expand All @@ -29,12 +105,14 @@ run with a missing or malformed value. See
| `WEBHOOK_QUEUE_BACKOFF_MS` | integer | optional | `5000` \* | yes | Delay before the first webhook retry, doubling on each further retry. Default 5000.
| `SCHEDULER_ENABLED` | `true \| false` | optional | `true` | yes | Set to 'false' to switch off every cron job registered through SchedulerModule. On unless 'false'. See docs/SCHEDULER.md.
| `REDIS_URL` | string | conditional | — | yes (when required) | Redis connection URL (e.g. redis://localhost:6379). Required when RATE_LIMIT_STORE=redis, CACHE_DRIVER=redis or WEBHOOK_QUEUE_ENABLED=true.
| `JWT_SECRET` | string (min 32 chars) | **required** | — | yes | Secret used to sign and verify JWT access tokens. Must be at least 32 characters. Rotating it invalidates every issued token. See docs/AUTHENTICATION.md.
| `SECRETS_PROVIDER` | — | optional | `env` | yes | Where secrets such as JWT_SECRET are read from: `env` (default), `file` (`<KEY>_FILE` paths) or `custom` (a provider passed to `SecretsModule.forRoot`). See docs/CONFIGURATION.md.
| `JWT_SECRET` | string | conditional | — | yes (when required) | Required when SECRETS_PROVIDER is `env` (the default).
| `JWT_SECRET_FILE` | string | conditional | — | yes (when required) | Path of the file holding JWT_SECRET; required when SECRETS_PROVIDER=file.
| `CORS_ORIGINS` | string | **required** | `http://localhost:3001` | yes | Comma-separated list of allowed CORS origins (e.g. "https://app.example.com,https://staging.example.com"). Wildcard (*) is NOT allowed in production. Used as the CORS allow-list and to build links in outbound email/webhooks.
| `JSON_BODY_LIMIT` | string | optional | `100kb` | yes | Maximum accepted JSON request body, as an Express size string such as 100kb or 1mb. Larger payloads are rejected with 413. Default 100kb.
| `CSP_DIRECTIVES` | string | optional | `{"defaultSrc":["'self'"],"scriptSrc":["'self'"]}` \* | yes | Helmet Content-Security-Policy directives as a JSON object string, e.g. {"defaultSrc":["'self'"]}. Leave unset to keep helmet's defaults.
| `SOROBAN_RPC_URL` | string | **required** | — | yes | Soroban RPC endpoint the StellarService submits contract calls through.
| `STELLAR_NETWORK` | `testnet \| futurenet \| mainnet` | **required** | `testnet` | yes | Which Stellar network every contract call targets. Must match the network the `ticketing` contract is deployed on and the one user wallets are set to, or every submit will fail.
| `STELLAR_NETWORK` | — | **required** | `testnet` | yes | Which Stellar network every contract call targets. Must match the network the `ticketing` contract is deployed on and the one user wallets are set
| `TICKETING_CONTRACT_ID` | string | **required** | — | yes | Deployed `ticketing` contract's C... address.
| `PLATFORM_SIGNER_SECRET` | string | **required** | — | yes | Platform signer used for contract calls submitted on behalf of the backend itself (e.g. relaying an organizer's already-authorized op).
| `OFFLINE_SIGNING_KEY_ID` | string | **required** | `2026-01` | yes | Key id of the Ed25519 key currently used to sign offline verification tokens. Must have a matching entry in OFFLINE_SIGNING_PUBLIC_KEYS.
Expand Down
2 changes: 1 addition & 1 deletion docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Production configuration is driven by environment variables. **Never store produ
| `PORT` | Yes | HTTP listening port | `3000` |
| `APP_URL` | Yes | Allowed CORS origin URL | `https://app.example.com` |
| `DATABASE_URL` | Yes | PostgreSQL connection string | `postgresql://user:pass@db-host:5432/stellartickets?sslmode=require` |
| `JWT_SECRET` | Yes | Secret key for signing auth tokens | Minimum 32-character high-entropy secret |
| `JWT_SECRET` | Yes | Secret key for signing auth tokens | Minimum 32-character high-entropy secret; can instead be loaded via `SECRETS_PROVIDER=file` or a custom provider (see "Secret providers" in `docs/CONFIGURATION.md`) |
| `STELLAR_NETWORK` | Yes | Target Stellar network | `mainnet` (or `testnet` for staging) |
| `SOROBAN_RPC_URL` | Yes | Production Soroban RPC node URL | `https://mainnet.soroban.rpc.endpoint` |
| `TICKETING_CONTRACT_ID` | Yes | Deployed Stellar ticketing contract address | `C...` (Stellar C-address) |
Expand Down
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AppController } from './app.controller';
import { AppService } from './app.service';
import { validate } from './config/env.validation';
import { FeatureFlagsModule } from './config/feature-flags.module';
import { SecretsModule } from './config/secrets/secrets.module';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
Expand Down Expand Up @@ -34,6 +35,7 @@ import { RequestTimeoutInterceptor } from './common/interceptors/request-timeout
CacheModule,
WebhooksModule,
FeatureFlagsModule,
SecretsModule.forRoot(),
PrismaModule,
StellarModule,
AuthModule,
Expand Down
11 changes: 6 additions & 5 deletions src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JWT_SIGNING_SECRET, JwtSecretModule } from './jwt-secret.module';
import { JwtStrategy } from './strategies/jwt.strategy';

@Module({
imports: [
PassportModule,
JwtSecretModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.getOrThrow<string>('JWT_SECRET'),
imports: [JwtSecretModule],
inject: [JWT_SIGNING_SECRET],
useFactory: (secret: string) => ({
secret,
signOptions: { expiresIn: '1h' },
}),
}),
Expand Down
92 changes: 92 additions & 0 deletions src/auth/jwt-secret.module.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { Global, Injectable, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { Test } from '@nestjs/testing';
import { SecretProvider } from '../config/secrets/secret-provider';
import { SecretsModule } from '../config/secrets/secrets.module';
import { PrismaService } from '../prisma/prisma.service';
import { AuthModule } from './auth.module';
import { JWT_SIGNING_SECRET, resolveJwtSecret } from './jwt-secret.module';
import { JwtStrategy } from './strategies/jwt.strategy';

function providerReturning(secret: string | undefined): SecretProvider {
return { name: 'stub', getSecret: () => Promise.resolve(secret) };
}

describe('resolveJwtSecret', () => {
const strongSecret = 'k3Jq9vX2pL7mN4bR8tY1wZ6cF0hG5dSa';

it('returns the secret from the active provider', async () => {
await expect(
resolveJwtSecret(providerReturning(strongSecret), 'production'),
).resolves.toBe(strongSecret);
});

it('fails when the provider has no JWT_SECRET', async () => {
await expect(
resolveJwtSecret(providerReturning(undefined), 'development'),
).rejects.toThrow(/not found via the "stub" secret provider/);
});

it('applies the production strength rules to provider secrets', async () => {
await expect(
resolveJwtSecret(
providerReturning('changeme-changeme-changeme-changeme'),
'production',
),
).rejects.toThrow(/placeholder/);
});

it('enforces the minimum length in every environment', async () => {
await expect(
resolveJwtSecret(providerReturning('short'), 'development'),
).rejects.toThrow(/at least 32 characters/);
});
});

@Injectable()
class VaultStubProvider implements SecretProvider {
readonly name = 'vault-stub';

getSecret(key: string): Promise<string | undefined> {
return Promise.resolve(
key === 'JWT_SECRET' ? 'v4ULt-9xQ2mZ7pK3rT8wB1nC6yH0dF5j' : undefined,
);
}
}

@Global()
@Module({
providers: [{ provide: PrismaService, useValue: {} }],
exports: [PrismaService],
})
class PrismaStubModule {}

describe('AuthModule secret wiring', () => {
it('signs and verifies tokens with the secret from the active provider', async () => {
const moduleRef = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
isGlobal: true,
ignoreEnvFile: true,
ignoreEnvVars: true,
load: [
() => ({ NODE_ENV: 'production', SECRETS_PROVIDER: 'custom' }),
],
}),
SecretsModule.forRoot({ provider: VaultStubProvider }),
PrismaStubModule,
AuthModule,
],
}).compile();

const secret = 'v4ULt-9xQ2mZ7pK3rT8wB1nC6yH0dF5j';
expect(moduleRef.get(JWT_SIGNING_SECRET)).toBe(secret);

const token = moduleRef.get(JwtService).sign({ sub: 'user-1' });
expect(new JwtService().verify(token, { secret })).toMatchObject({
sub: 'user-1',
});
expect(moduleRef.get(JwtStrategy)).toBeDefined();
});
});
42 changes: 42 additions & 0 deletions src/auth/jwt-secret.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { assertStrongJwtSecret } from '../config/jwt-secret';
import {
SECRET_PROVIDER,
SecretProvider,
} from '../config/secrets/secret-provider';

/** Injection token for the resolved JWT signing secret. */
export const JWT_SIGNING_SECRET = Symbol('JWT_SIGNING_SECRET');

/**
* Resolves `JWT_SECRET` once at boot through the active secret provider and
* applies the same strength rules as env validation, so a weak secret fails
* startup whichever provider supplied it.
*/
export async function resolveJwtSecret(
provider: SecretProvider,
nodeEnv: string,
): Promise<string> {
const secret = await provider.getSecret('JWT_SECRET');
if (!secret) {
throw new Error(
`JWT_SECRET was not found via the "${provider.name}" secret provider`,
);
}
assertStrongJwtSecret(secret, nodeEnv);
return secret;
}

@Module({
providers: [
{
provide: JWT_SIGNING_SECRET,
inject: [SECRET_PROVIDER, ConfigService],
useFactory: (provider: SecretProvider, config: ConfigService) =>
resolveJwtSecret(provider, config.getOrThrow<string>('NODE_ENV')),
},
],
exports: [JWT_SIGNING_SECRET],
})
export class JwtSecretModule {}
12 changes: 3 additions & 9 deletions src/auth/strategies/jwt.strategy.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { ConfigService } from '@nestjs/config';
import { JwtStrategy } from './jwt.strategy';

describe('JwtStrategy', () => {
it('maps a decoded payload to the shape guards and controllers expect', () => {
const config = { getOrThrow: jest.fn().mockReturnValue('x'.repeat(32)) };
const strategy = new JwtStrategy(config as unknown as ConfigService);
const strategy = new JwtStrategy('x'.repeat(32));

const result = strategy.validate({
sub: 'user-1',
Expand All @@ -19,11 +17,7 @@ describe('JwtStrategy', () => {
});
});

it('reads JWT_SECRET from config at construction time', () => {
const config = { getOrThrow: jest.fn().mockReturnValue('x'.repeat(32)) };
const strategy = new JwtStrategy(config as unknown as ConfigService);
expect(strategy).toBeDefined();

expect(config.getOrThrow).toHaveBeenCalledWith('JWT_SECRET');
it('is constructed with the secret resolved by JwtSecretModule', () => {
expect(new JwtStrategy('x'.repeat(32))).toBeDefined();
});
});
8 changes: 4 additions & 4 deletions src/auth/strategies/jwt.strategy.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Inject, Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { JWT_SIGNING_SECRET } from '../jwt-secret.module';

export interface JwtPayload {
sub: string;
Expand All @@ -11,11 +11,11 @@ export interface JwtPayload {

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(config: ConfigService) {
constructor(@Inject(JWT_SIGNING_SECRET) secret: string) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.getOrThrow<string>('JWT_SECRET'),
secretOrKey: secret,
});
}

Expand Down
Loading