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
7 changes: 7 additions & 0 deletions aastar/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,10 @@ PRICE_FEED_ADDRESS=0x694AA1769357215DE4FAC081bf1f309aDC325306
# Domain aastar.io is verified; send from hi@aastar.io.
RESEND_API_KEY=re_your_key_here
EMAIL_FROM=hi@aastar.io

# MyVote SSO (MV-1). SSO_JWT_SECRET is REQUIRED (backend fails fast at boot without it)
# and MUST differ from JWT_SECRET — it signs the short-lived (10min, audience "myvote")
# SSO session tokens handed to MyVote. SSO_ALLOWED_REDIRECTS is a comma-separated list of
# allowed redirect_uri prefixes (fail-closed: empty/unset rejects every SSO authorize).
SSO_JWT_SECRET=<32-byte-hex-secret-distinct-from-JWT_SECRET>
SSO_ALLOWED_REDIRECTS=http://localhost:5175,https://myvote.example.com
2 changes: 2 additions & 0 deletions aastar/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { OperatorModule } from "./operator/operator.module";
import { AdminModule } from "./admin/admin.module";
import { SaleModule } from "./sale/sale.module";
import { IndexerModule } from "./indexer/indexer.module";
import { SsoModule } from "./sso/sso.module";

@Module({
imports: [
Expand All @@ -46,6 +47,7 @@ import { IndexerModule } from "./indexer/indexer.module";
AdminModule,
SaleModule,
IndexerModule,
SsoModule,
],
controllers: [AppController],
providers: [AppService],
Expand Down
17 changes: 16 additions & 1 deletion aastar/src/auth/strategies/jwt.strategy.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { ExtractJwt, Strategy } from "passport-jwt";
import { PassportStrategy } from "@nestjs/passport";
import { Injectable } from "@nestjs/common";
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { SSO_TOKEN_AUDIENCE } from "../../sso/sso.constants";

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
Expand All @@ -16,10 +17,24 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: jwtSecret,
// Pin the algorithm — never trust the token header's alg claim.
algorithms: ["HS256"],
});
}

async validate(payload: any) {
// Reject MyVote SSO tokens (audience "myvote") on the cos72 session path. This is
// defense-in-depth: SSO tokens are signed with a distinct SSO_JWT_SECRET (enforced
// != JWT_SECRET at boot), so their signature already fails here — this keeps the
// token-domain boundary even under secret misconfiguration. Deliberately an
// audience-EXCLUSION rather than a required audience: existing cos72 session tokens
// carry no `aud` claim, and requiring one would invalidate every live session.
const aud = payload?.aud;
const audiences: unknown[] = Array.isArray(aud) ? aud : aud !== undefined ? [aud] : [];
if (audiences.includes(SSO_TOKEN_AUDIENCE)) {
throw new UnauthorizedException("SSO session tokens cannot be used for cos72 APIs");
}

return {
sub: payload.sub,
email: payload.email,
Expand Down
34 changes: 34 additions & 0 deletions aastar/src/sso/dto/sso.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsString, Length, Matches, MaxLength } from "class-validator";

export class SsoAuthorizeDto {
@ApiProperty({
example: "https://myvote.example.com/sso/callback",
description:
"Absolute URL MyVote will receive the one-time code on. Must match the SSO_ALLOWED_REDIRECTS whitelist (fail-closed).",
})
@IsString()
@MaxLength(2048)
redirect_uri: string;
}

export class SsoExchangeDto {
@ApiProperty({
example: "a".repeat(64),
description:
"One-time code obtained from POST /sso/authorize (32-byte hex, TTL 60s, single use)",
})
@IsString()
@Length(64, 64)
@Matches(/^[0-9a-f]{64}$/, { message: "code must be 64 lowercase hex characters" })
code: string;

@ApiProperty({
example: "https://myvote.example.com/sso/callback",
description:
"Must be exactly the redirect_uri the code was authorized for (OAuth code↔redirect binding); any mismatch is a 401.",
})
@IsString()
@MaxLength(2048)
redirect_uri: string;
}
93 changes: 93 additions & 0 deletions aastar/src/sso/guards/sso-rate-limit.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import {
CanActivate,
ExecutionContext,
HttpException,
HttpStatus,
Injectable,
Type,
mixin,
} from "@nestjs/common";

// In-memory sliding-window rate limit keyed per (bucket, client IP), for the PUBLIC SSO
// endpoints (/sso/exchange is a code-guessing surface, /sso/verify a token-oracle surface).
// Modeled on auth/guards/otp-rate-limit.guard.ts, but IP-keyed since these endpoints carry
// no email/user identity before authentication.
//
// NOTE: per-process memory — correct for the single-instance backend. Multi-replica
// deployments must move the store to a shared backend (e.g. Redis).
//
// SCOPE: `req.ip` is the socket peer unless Express `trust proxy` is configured, so behind
// the Next.js rewrite / a reverse proxy all clients may share the proxy's IP (one shared
// budget). That is fail-closed (throttles too much, never too little); per-real-client
// limiting belongs at the edge.
const store = new Map<string, number[]>();

const MAX_WINDOW_MS = 60 * 60_000;
const SWEEP_MS = 10 * 60_000;
// Hard cap on tracked keys so spoofed/rotating source IPs can't grow the store unbounded
// (memory DoS). Eviction drops oldest-inserted keys — a few limit resets, never OOM.
const MAX_KEYS = 50_000;

const sweep = setInterval(() => {
const cutoff = Date.now() - MAX_WINDOW_MS;
for (const [key, hits] of store) {
if (hits.length === 0 || hits[hits.length - 1] <= cutoff) store.delete(key);
}
}, SWEEP_MS);
// Don't keep the event loop (or test runner / graceful shutdown) alive for the sweep.
sweep.unref?.();

function evictIfOverCap(): void {
if (store.size <= MAX_KEYS) return;
let toDrop = store.size - MAX_KEYS;
for (const key of store.keys()) {
store.delete(key);
if (--toDrop <= 0) break;
}
}

/**
* Per-IP SSO rate-limit guard factory. `bucket` separates the exchange vs verify counters.
* Exceeding `max` hits within `windowMs` yields a 429 with a `Retry-After` header.
*/
export function SsoRateLimit(bucket: string, max: number, windowMs: number): Type<CanActivate> {
@Injectable()
class SsoRateLimitGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const http = context.switchToHttp();
const request = http.getRequest();
const ip = String(request?.ip ?? request?.socket?.remoteAddress ?? "unknown");

const key = `${bucket}:${ip}`;
const now = Date.now();
const hits = (store.get(key) ?? []).filter(t => t > now - windowMs);

if (hits.length >= max) {
store.set(key, hits); // persist the pruned window even when blocking
const retrySec = Math.ceil((windowMs - (now - hits[0])) / 1000);
http.getResponse()?.header?.("Retry-After", String(retrySec));
throw new HttpException(
{
statusCode: HttpStatus.TOO_MANY_REQUESTS,
error: "Too Many Requests",
message: `Too many SSO ${bucket} attempts. Try again in ${retrySec}s.`,
},
HttpStatus.TOO_MANY_REQUESTS
);
}

hits.push(now);
store.set(key, hits);
evictIfOverCap();
return true;
}
}
return mixin(SsoRateLimitGuard);
}

// Test-only: reset the shared store between unit tests. No-ops in production so a stray
// import/call can't clear live rate-limit state.
export function __resetSsoRateLimit(): void {
if (process.env.NODE_ENV === "production") return;
store.clear();
}
8 changes: 8 additions & 0 deletions aastar/src/sso/sso.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Dependency-free constants shared between the SSO module and the cos72 session
// JwtStrategy (which must REJECT SSO tokens without importing the heavy sso.service chain).

/** Audience claim stamped on every SSO session token. */
export const SSO_TOKEN_AUDIENCE = "myvote";

/** SSO session token TTL — 10 minutes. */
export const SSO_TOKEN_TTL_SECONDS = 600;
62 changes: 62 additions & 0 deletions aastar/src/sso/sso.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { Body, Controller, Get, Headers, HttpCode, Post, Request, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { JwtAuthGuard } from "../auth/guards/jwt-auth.guard";
import { SsoAuthorizeDto, SsoExchangeDto } from "./dto/sso.dto";
import { SsoRateLimit } from "./guards/sso-rate-limit.guard";
import { SsoService } from "./sso.service";

// exchange/verify are public (MyVote calls them cross-origin before it has any session),
// so both get a per-IP sliding-window limit: 20 req/min per bucket.
const SSO_RATE_MAX = 20;
const SSO_RATE_WINDOW_MS = 60_000;

@ApiTags("sso")
@Controller("sso")
export class SsoController {
constructor(private readonly ssoService: SsoService) {}

@Post("authorize")
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(200)
@ApiOperation({ summary: "Mint a one-time SSO code for the logged-in user (MyVote handoff)" })
@ApiResponse({ status: 200, description: "{ code, redirectUri } — code TTL 60s, single use" })
@ApiResponse({
status: 400,
description: "redirect_uri not whitelisted, or user has no AirAccount",
})
async authorize(@Request() req, @Body() dto: SsoAuthorizeDto) {
// userId comes from the cos72 JWT — never from the request body.
return this.ssoService.authorize(req.user.sub, dto.redirect_uri);
}

@Post("exchange")
@UseGuards(SsoRateLimit("exchange", SSO_RATE_MAX, SSO_RATE_WINDOW_MS))
@HttpCode(200)
@ApiOperation({
summary: "Swap a one-time SSO code for a short-lived SSO session token (public)",
})
@ApiResponse({
status: 200,
description: "{ token, aaAddress, expiresIn } — JWT audience 'myvote', TTL 10min",
})
@ApiResponse({
status: 401,
description: "Code invalid / expired / already consumed / redirect_uri mismatch (unified)",
})
async exchange(@Body() dto: SsoExchangeDto) {
return this.ssoService.exchange(dto.code, dto.redirect_uri);
}

@Get("verify")
@UseGuards(SsoRateLimit("verify", SSO_RATE_MAX, SSO_RATE_WINDOW_MS))
@ApiOperation({ summary: "Validate an SSO session token (public, for MyVote backend/edge)" })
@ApiResponse({ status: 200, description: "{ valid, aaAddress } — never throws on a bad token" })
async verify(@Headers("authorization") authorization?: string) {
const token = authorization?.startsWith("Bearer ") ? authorization.slice("Bearer ".length) : "";
if (!token) {
return { valid: false, aaAddress: null };
}
return this.ssoService.verify(token);
}
}
18 changes: 18 additions & 0 deletions aastar/src/sso/sso.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { AccountModule } from "../account/account.module";
import { SsoController } from "./sso.controller";
import { SsoService } from "./sso.service";

/**
* MyVote SSO channel (MV-1, cos72 half): one-time code issuance + code→token exchange +
* token verification. JwtModule is registered bare on purpose — the SSO token uses its own
* secret (SSO_JWT_SECRET) and options passed explicitly per sign/verify call, never the
* cos72 session JWT defaults.
*/
@Module({
imports: [JwtModule.register({}), AccountModule],
controllers: [SsoController],
providers: [SsoService],
})
export class SsoModule {}
Loading
Loading