diff --git a/aastar/.env.example b/aastar/.env.example index 02c0a7d..a43bd44 100644 --- a/aastar/.env.example +++ b/aastar/.env.example @@ -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 diff --git a/aastar/src/app.module.ts b/aastar/src/app.module.ts index c9462f1..d9d6049 100644 --- a/aastar/src/app.module.ts +++ b/aastar/src/app.module.ts @@ -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: [ @@ -46,6 +47,7 @@ import { IndexerModule } from "./indexer/indexer.module"; AdminModule, SaleModule, IndexerModule, + SsoModule, ], controllers: [AppController], providers: [AppService], diff --git a/aastar/src/auth/strategies/jwt.strategy.ts b/aastar/src/auth/strategies/jwt.strategy.ts index c5d6f42..948737d 100644 --- a/aastar/src/auth/strategies/jwt.strategy.ts +++ b/aastar/src/auth/strategies/jwt.strategy.ts @@ -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) { @@ -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, diff --git a/aastar/src/sso/dto/sso.dto.ts b/aastar/src/sso/dto/sso.dto.ts new file mode 100644 index 0000000..4bfa7e7 --- /dev/null +++ b/aastar/src/sso/dto/sso.dto.ts @@ -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; +} diff --git a/aastar/src/sso/guards/sso-rate-limit.guard.ts b/aastar/src/sso/guards/sso-rate-limit.guard.ts new file mode 100644 index 0000000..05fc92b --- /dev/null +++ b/aastar/src/sso/guards/sso-rate-limit.guard.ts @@ -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(); + +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 { + @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(); +} diff --git a/aastar/src/sso/sso.constants.ts b/aastar/src/sso/sso.constants.ts new file mode 100644 index 0000000..e9025a4 --- /dev/null +++ b/aastar/src/sso/sso.constants.ts @@ -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; diff --git a/aastar/src/sso/sso.controller.ts b/aastar/src/sso/sso.controller.ts new file mode 100644 index 0000000..b32017c --- /dev/null +++ b/aastar/src/sso/sso.controller.ts @@ -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); + } +} diff --git a/aastar/src/sso/sso.module.ts b/aastar/src/sso/sso.module.ts new file mode 100644 index 0000000..2f2f36f --- /dev/null +++ b/aastar/src/sso/sso.module.ts @@ -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 {} diff --git a/aastar/src/sso/sso.service.spec.ts b/aastar/src/sso/sso.service.spec.ts new file mode 100644 index 0000000..c712135 --- /dev/null +++ b/aastar/src/sso/sso.service.spec.ts @@ -0,0 +1,421 @@ +import { BadRequestException, HttpException, UnauthorizedException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { JwtService } from "@nestjs/jwt"; +import { Test, TestingModule } from "@nestjs/testing"; + +// Mock the account module so this spec doesn't load its real import chain +// (sdk.providers → auth.service → ESM-only `uuid`), which Jest cannot parse. +// SsoService only calls accountService.getAccountAddress(userId). +jest.mock("../account/account.service", () => ({ + AccountService: class AccountService {}, +})); + +import { AccountService } from "../account/account.service"; +import { JwtStrategy } from "../auth/strategies/jwt.strategy"; +import { SsoRateLimit, __resetSsoRateLimit } from "./guards/sso-rate-limit.guard"; +import { SSO_TOKEN_AUDIENCE, SSO_TOKEN_TTL_SECONDS } from "./sso.constants"; +import { SsoService } from "./sso.service"; + +const TEST_SECRET = "test-sso-secret"; +const SESSION_SECRET = "test-session-secret"; +const ALLOWED = "https://myvote.example.com,http://localhost:5175/sso/callback"; +const AA_ADDRESS = "0x1111111111111111111111111111111111111111"; +const USER_ID = "user-1"; +const REDIRECT = "https://myvote.example.com/cb"; + +function makeConfig(env: Record): ConfigService { + return { + get: jest.fn().mockImplementation((key: string) => env[key]), + } as unknown as ConfigService; +} + +const accountServiceMock = { + getAccountAddress: jest.fn().mockResolvedValue(AA_ADDRESS), +}; + +describe("SsoService", () => { + let service: SsoService; + let jwtService: JwtService; + + beforeEach(async () => { + jest.clearAllMocks(); + accountServiceMock.getAccountAddress.mockResolvedValue(AA_ADDRESS); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SsoService, + JwtService, + { + provide: ConfigService, + useValue: makeConfig({ + SSO_JWT_SECRET: TEST_SECRET, + JWT_SECRET: SESSION_SECRET, + SSO_ALLOWED_REDIRECTS: ALLOWED, + }), + }, + { provide: AccountService, useValue: accountServiceMock }, + ], + }).compile(); + + service = module.get(SsoService); + jwtService = module.get(JwtService); + }); + + afterEach(() => { + service.onModuleDestroy(); // clear the sweep interval + jest.useRealTimers(); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + it("fails fast at construction when SSO_JWT_SECRET is missing", () => { + expect( + () => + new SsoService( + makeConfig({ SSO_ALLOWED_REDIRECTS: ALLOWED }), + jwtService, + accountServiceMock as unknown as AccountService + ) + ).toThrow("SSO_JWT_SECRET environment variable is required"); + }); + + it("fails fast when SSO_JWT_SECRET equals JWT_SECRET (token-domain isolation)", () => { + expect( + () => + new SsoService( + makeConfig({ + SSO_JWT_SECRET: "same-secret", + JWT_SECRET: "same-secret", + SSO_ALLOWED_REDIRECTS: ALLOWED, + }), + jwtService, + accountServiceMock as unknown as AccountService + ) + ).toThrow("SSO_JWT_SECRET must differ from JWT_SECRET"); + }); + + describe("authorize — redirect whitelist (fail-closed)", () => { + it("rejects every redirect when SSO_ALLOWED_REDIRECTS is unset", async () => { + const closed = new SsoService( + makeConfig({ SSO_JWT_SECRET: TEST_SECRET, JWT_SECRET: SESSION_SECRET }), + jwtService, + accountServiceMock as unknown as AccountService + ); + await expect(closed.authorize(USER_ID, REDIRECT)).rejects.toThrow(BadRequestException); + closed.onModuleDestroy(); + }); + + it("rejects a redirect_uri outside the whitelist", async () => { + await expect(service.authorize(USER_ID, "https://evil.example.com/cb")).rejects.toThrow( + BadRequestException + ); + }); + + it("rejects a lookalike origin that merely embeds an allowed host", async () => { + await expect( + service.authorize(USER_ID, "https://myvote.example.com.evil.com/cb") + ).rejects.toThrow(BadRequestException); + }); + + it("rejects a non-URL redirect_uri", async () => { + await expect(service.authorize(USER_ID, "not-a-url")).rejects.toThrow(BadRequestException); + }); + + it("accepts a whitelisted redirect_uri and mints a 32-byte hex code", async () => { + const result = await service.authorize(USER_ID, "https://myvote.example.com/sso/callback"); + expect(result.redirectUri).toBe("https://myvote.example.com/sso/callback"); + expect(result.code).toMatch(/^[0-9a-f]{64}$/); + }); + + it("rejects when the user has no AirAccount", async () => { + accountServiceMock.getAccountAddress.mockRejectedValue(new Error("no account")); + await expect(service.authorize(USER_ID, REDIRECT)).rejects.toThrow(BadRequestException); + }); + + describe("path boundary matching (entry http://localhost:5175/sso/callback)", () => { + it("rejects a same-prefix but different path segment (/sso/callback-evil)", async () => { + await expect( + service.authorize(USER_ID, "http://localhost:5175/sso/callback-evil") + ).rejects.toThrow(BadRequestException); + }); + + it("allows a sub-path below the entry (/sso/callback/sub)", async () => { + const result = await service.authorize(USER_ID, "http://localhost:5175/sso/callback/sub"); + expect(result.code).toMatch(/^[0-9a-f]{64}$/); + }); + + it("allows the exact entry path (/sso/callback)", async () => { + const result = await service.authorize(USER_ID, "http://localhost:5175/sso/callback"); + expect(result.code).toMatch(/^[0-9a-f]{64}$/); + }); + + it("rejects an encoded-slash traversal (/sso/callback/%2f..%2fevil)", async () => { + await expect( + service.authorize(USER_ID, "http://localhost:5175/sso/callback/%2f..%2fevil") + ).rejects.toThrow(BadRequestException); + }); + + it("rejects an uppercase encoded-slash traversal (%2F)", async () => { + await expect( + service.authorize(USER_ID, "http://localhost:5175/sso/callback/%2F..%2Fevil") + ).rejects.toThrow(BadRequestException); + }); + + it("rejects an encoded-backslash variant (%5c)", async () => { + await expect( + service.authorize(USER_ID, "http://localhost:5175/sso/callback/%5c..%5cevil") + ).rejects.toThrow(BadRequestException); + }); + + it("rejects encoded dot-segments (%2e%2e)", async () => { + await expect( + service.authorize(USER_ID, "http://localhost:5175/sso/callback/%2e%2e/evil") + ).rejects.toThrow(BadRequestException); + }); + + it("still allows a normal sub-path after the encoding checks (/sso/callback/sub)", async () => { + const result = await service.authorize(USER_ID, "http://localhost:5175/sso/callback/sub"); + expect(result.code).toMatch(/^[0-9a-f]{64}$/); + }); + + it("normalizes trailing slashes on whitelist entries", async () => { + const slashy = new SsoService( + makeConfig({ + SSO_JWT_SECRET: TEST_SECRET, + JWT_SECRET: SESSION_SECRET, + SSO_ALLOWED_REDIRECTS: "https://a.example.com/path/", + }), + jwtService, + accountServiceMock as unknown as AccountService + ); + const result = await slashy.authorize(USER_ID, "https://a.example.com/path"); + expect(result.code).toMatch(/^[0-9a-f]{64}$/); + slashy.onModuleDestroy(); + }); + }); + }); + + describe("exchange — one-time code consumption + redirect binding", () => { + it("exchanges a fresh code for a token bound to the user's AA address", async () => { + const { code } = await service.authorize(USER_ID, REDIRECT); + const result = service.exchange(code, REDIRECT); + + expect(result.aaAddress).toBe(AA_ADDRESS); + expect(result.expiresIn).toBe(SSO_TOKEN_TTL_SECONDS); + + const payload = jwtService.verify(result.token, { + secret: TEST_SECRET, + audience: SSO_TOKEN_AUDIENCE, + }); + expect(payload.sub).toBe(USER_ID); + expect(payload.aa).toBe(AA_ADDRESS); + }); + + it("consumes the code — a second exchange with the same code fails", async () => { + const { code } = await service.authorize(USER_ID, REDIRECT); + service.exchange(code, REDIRECT); + expect(() => service.exchange(code, REDIRECT)).toThrow(UnauthorizedException); + }); + + it("rejects an unknown code", () => { + expect(() => service.exchange("f".repeat(64), REDIRECT)).toThrow(UnauthorizedException); + }); + + it("rejects an expired code (TTL 60s) and keeps it consumed", async () => { + jest.useFakeTimers(); + const { code } = await service.authorize(USER_ID, REDIRECT); + jest.advanceTimersByTime(61_000); + expect(() => service.exchange(code, REDIRECT)).toThrow(UnauthorizedException); + // expired attempt consumed it too — still single-use even if the clock is rolled back + expect(() => service.exchange(code, REDIRECT)).toThrow(UnauthorizedException); + }); + + it("rejects an exchange whose redirect_uri differs from the one bound at authorize", async () => { + const { code } = await service.authorize(USER_ID, REDIRECT); + expect(() => service.exchange(code, "https://myvote.example.com/other")).toThrow( + UnauthorizedException + ); + // the mismatch attempt burned the code — the correct redirect can no longer redeem it + expect(() => service.exchange(code, REDIRECT)).toThrow(UnauthorizedException); + }); + + it("matches redirect_uri after URL normalization (host case-insensitive)", async () => { + const { code } = await service.authorize(USER_ID, REDIRECT); + const result = service.exchange(code, "https://MYVOTE.example.com/cb"); + expect(result.aaAddress).toBe(AA_ADDRESS); + }); + + it("returns one identical error for unknown, consumed, and expired codes (no oracle)", async () => { + const grab = (fn: () => unknown): { status: number; message: string } => { + try { + fn(); + } catch (error) { + const http = error as HttpException; + return { status: http.getStatus(), message: http.message }; + } + throw new Error("expected the exchange to throw"); + }; + + // unknown + const unknownErr = grab(() => service.exchange("a".repeat(64), REDIRECT)); + + // consumed + const { code: usedCode } = await service.authorize(USER_ID, REDIRECT); + service.exchange(usedCode, REDIRECT); + const consumedErr = grab(() => service.exchange(usedCode, REDIRECT)); + + // expired + jest.useFakeTimers(); + const { code: staleCode } = await service.authorize(USER_ID, REDIRECT); + jest.advanceTimersByTime(61_000); + const expiredErr = grab(() => service.exchange(staleCode, REDIRECT)); + jest.useRealTimers(); + + expect(unknownErr).toEqual({ status: 401, message: "Invalid SSO code" }); + expect(consumedErr).toEqual(unknownErr); + expect(expiredErr).toEqual(unknownErr); + }); + }); + + describe("verify — SSO token validation", () => { + it("accepts a token minted by exchange", async () => { + const { code } = await service.authorize(USER_ID, REDIRECT); + const { token } = service.exchange(code, REDIRECT); + expect(service.verify(token)).toEqual({ valid: true, aaAddress: AA_ADDRESS }); + }); + + it("rejects a token with the wrong audience even when the secret matches", () => { + const token = jwtService.sign( + { sub: USER_ID, aa: AA_ADDRESS }, + { secret: TEST_SECRET, audience: "not-myvote", expiresIn: 600 } + ); + expect(service.verify(token)).toEqual({ valid: false, aaAddress: null }); + }); + + it("rejects a token signed with a different secret", () => { + const token = jwtService.sign( + { sub: USER_ID, aa: AA_ADDRESS }, + { secret: "other-secret", audience: SSO_TOKEN_AUDIENCE, expiresIn: 600 } + ); + expect(service.verify(token)).toEqual({ valid: false, aaAddress: null }); + }); + + it("rejects a token signed with a non-pinned algorithm (HS512), same secret and audience", () => { + const token = jwtService.sign( + { sub: USER_ID, aa: AA_ADDRESS }, + { + secret: TEST_SECRET, + algorithm: "HS512", + audience: SSO_TOKEN_AUDIENCE, + expiresIn: 600, + } + ); + expect(service.verify(token)).toEqual({ valid: false, aaAddress: null }); + }); + + it("rejects an expired token", async () => { + jest.useFakeTimers(); + const { code } = await service.authorize(USER_ID, REDIRECT); + const { token } = service.exchange(code, REDIRECT); + jest.advanceTimersByTime((SSO_TOKEN_TTL_SECONDS + 1) * 1000); + expect(service.verify(token)).toEqual({ valid: false, aaAddress: null }); + }); + + it("rejects garbage input", () => { + expect(service.verify("not-a-jwt")).toEqual({ valid: false, aaAddress: null }); + }); + }); +}); + +describe("JwtStrategy — SSO token exclusion on the cos72 session path", () => { + function makeStrategy(): JwtStrategy { + return new JwtStrategy(makeConfig({ JWT_SECRET: SESSION_SECRET })); + } + + it("rejects a payload carrying the SSO audience", async () => { + await expect( + makeStrategy().validate({ sub: USER_ID, aud: SSO_TOKEN_AUDIENCE }) + ).rejects.toThrow(UnauthorizedException); + }); + + it("rejects a payload whose audience array contains the SSO audience", async () => { + await expect( + makeStrategy().validate({ sub: USER_ID, aud: ["other", SSO_TOKEN_AUDIENCE] }) + ).rejects.toThrow(UnauthorizedException); + }); + + it("accepts a legacy session payload without an aud claim", async () => { + await expect( + makeStrategy().validate({ sub: USER_ID, email: "u@example.com", username: "u" }) + ).resolves.toEqual({ sub: USER_ID, email: "u@example.com", username: "u" }); + }); +}); + +describe("SsoRateLimit guard", () => { + beforeEach(() => { + __resetSsoRateLimit(); + }); + + function makeContext(ip: string) { + return { + switchToHttp: () => ({ + getRequest: () => ({ ip }), + getResponse: () => ({ header: jest.fn() }), + }), + } as any; + } + + it("allows up to `max` hits then throws 429 for the same IP", () => { + const Guard = SsoRateLimit("test-bucket", 3, 60_000); + const guard = new Guard(); + + expect(guard.canActivate(makeContext("1.2.3.4"))).toBe(true); + expect(guard.canActivate(makeContext("1.2.3.4"))).toBe(true); + expect(guard.canActivate(makeContext("1.2.3.4"))).toBe(true); + + let caught: unknown; + try { + guard.canActivate(makeContext("1.2.3.4")); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(HttpException); + expect((caught as HttpException).getStatus()).toBe(429); + }); + + it("tracks IPs independently", () => { + const Guard = SsoRateLimit("test-bucket-2", 1, 60_000); + const guard = new Guard(); + + expect(guard.canActivate(makeContext("1.1.1.1"))).toBe(true); + expect(guard.canActivate(makeContext("2.2.2.2"))).toBe(true); + expect(() => guard.canActivate(makeContext("1.1.1.1"))).toThrow(HttpException); + }); + + it("tracks buckets independently for the same IP", () => { + const ExchangeGuard = SsoRateLimit("bucket-a", 1, 60_000); + const VerifyGuard = SsoRateLimit("bucket-b", 1, 60_000); + + expect(new ExchangeGuard().canActivate(makeContext("3.3.3.3"))).toBe(true); + expect(new VerifyGuard().canActivate(makeContext("3.3.3.3"))).toBe(true); + expect(() => new ExchangeGuard().canActivate(makeContext("3.3.3.3"))).toThrow(HttpException); + }); + + it("frees the budget once the window slides past", () => { + jest.useFakeTimers(); + try { + const Guard = SsoRateLimit("test-bucket-3", 1, 60_000); + const guard = new Guard(); + + expect(guard.canActivate(makeContext("4.4.4.4"))).toBe(true); + expect(() => guard.canActivate(makeContext("4.4.4.4"))).toThrow(HttpException); + + jest.advanceTimersByTime(61_000); + expect(guard.canActivate(makeContext("4.4.4.4"))).toBe(true); + } finally { + jest.useRealTimers(); + } + }); +}); diff --git a/aastar/src/sso/sso.service.ts b/aastar/src/sso/sso.service.ts new file mode 100644 index 0000000..dec0458 --- /dev/null +++ b/aastar/src/sso/sso.service.ts @@ -0,0 +1,277 @@ +import { + BadRequestException, + Injectable, + OnModuleDestroy, + UnauthorizedException, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { JwtService } from "@nestjs/jwt"; +import { randomBytes } from "crypto"; +import { AccountService } from "../account/account.service"; +import { SSO_TOKEN_AUDIENCE, SSO_TOKEN_TTL_SECONDS } from "./sso.constants"; + +/** + * MyVote SSO channel (MV-1, cos72 half). + * + * Flow: the user is logged into cos72 (cos72 JWT) → POST /sso/authorize mints a one-time + * code bound to { userId, aaAddress } → the browser is redirected to MyVote with that code → + * MyVote calls POST /sso/exchange to swap it for a short-lived SSO session token (separate + * secret + audience "myvote", NOT interchangeable with the cos72 session JWT) → MyVote + * backend/edge validates it via GET /sso/verify. + * + * The KMS-side signTypedData API (the other half of MV-1) is delivered separately as E-5. + */ + +interface SsoCodeEntry { + userId: string; + aaAddress: string; + /** Normalized redirect_uri the code was authorized for — exchange must present the same one. */ + redirectUri: string; + issuedAt: number; + expiresAt: number; +} + +/** One-time authorization code TTL — just long enough for a browser redirect round-trip. */ +const CODE_TTL_MS = 60_000; +/** Periodic sweep of expired codes (codes are also validated on consumption). */ +const SWEEP_MS = 60_000; +/** + * Hard cap on outstanding codes. /sso/authorize is JWT-guarded so only authenticated users + * can mint codes, but a hostile logged-in client could still loop — the cap bounds memory + * (oldest-inserted entries are evicted first; worst case a pending login redirect is retried). + */ +const MAX_CODES = 10_000; + +/** + * Single unified error for every exchange failure (unknown / consumed / expired code, + * redirect_uri mismatch): one status + one message, so the endpoint cannot be used as an + * oracle to distinguish "code exists but expired" from "code never existed" etc. + */ +const INVALID_SSO_CODE = "Invalid SSO code"; + +/** + * Percent-encoded path separators / dot-segments: `%2f` (/), `%5c` (\, which many stacks + * fold to /), and `%2e` (.), any case. Their presence in a redirect_uri path means the value + * only *looks* whitelisted until something downstream decodes it — always reject. + */ +const ENCODED_PATH_SEPARATOR = /%2f|%5c|%2e/i; + +@Injectable() +export class SsoService implements OnModuleDestroy { + private readonly ssoJwtSecret: string; + private readonly allowedRedirects: string[]; + + // In-memory one-time-code store. Codes live for 60s and are consumed exactly once, so + // persistence is unnecessary. NOTE: per-process memory — correct for the current + // single-instance deployment; a multi-replica deployment must move this to a shared + // backend (e.g. Redis with GETDEL), or a code minted on one replica would be + // unredeemable on another. + private readonly codes = new Map(); + private readonly sweeper: ReturnType; + + constructor( + private readonly configService: ConfigService, + private readonly jwtService: JwtService, + private readonly accountService: AccountService + ) { + // Independent secret from the cos72 session JWT_SECRET, so an SSO token can never be + // replayed against cos72 APIs (and vice versa). Fail fast at boot when unset — a + // missing secret must never silently fall back to the session secret or a default. + const secret = this.configService.get("SSO_JWT_SECRET"); + if (!secret) { + throw new Error("SSO_JWT_SECRET environment variable is required"); + } + // Enforce the isolation, don't just document it: a shared secret would make SSO tokens + // and cos72 session tokens cryptographically interchangeable. + const sessionSecret = this.configService.get("JWT_SECRET"); + if (sessionSecret && sessionSecret === secret) { + throw new Error("SSO_JWT_SECRET must differ from JWT_SECRET (token-domain isolation)"); + } + this.ssoJwtSecret = secret; + + // Fail-closed whitelist: empty/unset env means every redirect_uri is rejected. + this.allowedRedirects = (this.configService.get("SSO_ALLOWED_REDIRECTS") ?? "") + .split(",") + .map(entry => entry.trim()) + .filter(entry => entry.length > 0); + + this.sweeper = setInterval(() => this.sweepExpired(), SWEEP_MS); + // Don't keep the event loop (or the test runner / graceful shutdown) alive for the sweep. + this.sweeper.unref?.(); + } + + onModuleDestroy(): void { + clearInterval(this.sweeper); + } + + /** + * Mints a one-time SSO code for the (JWT-authenticated) user. `redirectUri` must match the + * SSO_ALLOWED_REDIRECTS whitelist (see resolveRedirect for the matching rules). The code is + * bound to the normalized redirect_uri — exchange must present the exact same one (OAuth + * code↔redirect binding), so a stolen code can't be redeemed under a different redirect. + */ + async authorize( + userId: string, + redirectUri: string + ): Promise<{ code: string; redirectUri: string }> { + const normalizedRedirect = this.resolveRedirect(redirectUri); + + let aaAddress: string; + try { + aaAddress = await this.accountService.getAccountAddress(userId); + } catch (_error) { + throw new BadRequestException( + "User has no AirAccount yet — create the ERC-4337 account before using MyVote SSO" + ); + } + + const code = randomBytes(32).toString("hex"); + const now = Date.now(); + this.evictIfOverCap(); + this.codes.set(code, { + userId, + aaAddress, + redirectUri: normalizedRedirect, + issuedAt: now, + expiresAt: now + CODE_TTL_MS, + }); + + return { code, redirectUri: normalizedRedirect }; + } + + /** + * Swaps a one-time code for a short-lived SSO session token. The presented redirect_uri + * must exactly match (after URL normalization) the one the code was authorized for. + * + * The get+delete pair below is two synchronous operations with no await in between — + * atomic under Node's single-threaded event loop, so a code can never be redeemed twice + * even under concurrent requests. Any failed attempt (expired / redirect mismatch) also + * burns the code, per OAuth one-time-code semantics. + */ + exchange( + code: string, + redirectUri: string + ): { token: string; aaAddress: string; expiresIn: number } { + const entry = this.codes.get(code); + this.codes.delete(code); // consume unconditionally: single-use even when the attempt fails + + // Normalize the presented redirect_uri exactly like authorize stored it; a value that + // doesn't parse can never match (fail-closed). + let presentedRedirect: string | null = null; + try { + presentedRedirect = new URL(redirectUri).toString(); + } catch (_error) { + presentedRedirect = null; + } + + const valid = + entry !== undefined && + entry.expiresAt > Date.now() && + presentedRedirect !== null && + entry.redirectUri === presentedRedirect; + + if (!valid) { + throw new UnauthorizedException(INVALID_SSO_CODE); + } + + const token = this.jwtService.sign( + { sub: entry.userId, aa: entry.aaAddress }, + { + secret: this.ssoJwtSecret, + algorithm: "HS256", + audience: SSO_TOKEN_AUDIENCE, + expiresIn: SSO_TOKEN_TTL_SECONDS, + } + ); + + return { token, aaAddress: entry.aaAddress, expiresIn: SSO_TOKEN_TTL_SECONDS }; + } + + /** + * Validates an SSO session token (secret + audience + expiry). Non-throwing by design: + * MyVote's backend/edge polls this, so a bad token is a `{ valid: false }` answer, not a 401. + */ + verify(token: string): { valid: boolean; aaAddress: string | null } { + try { + const payload = this.jwtService.verify<{ sub: string; aa: string }>(token, { + secret: this.ssoJwtSecret, + algorithms: ["HS256"], // pin the algorithm — never trust the token header's alg + audience: SSO_TOKEN_AUDIENCE, + }); + return { valid: true, aaAddress: payload.aa ?? null }; + } catch (_error) { + return { valid: false, aaAddress: null }; + } + } + + /** + * Validates redirect_uri against SSO_ALLOWED_REDIRECTS and returns its normalized form. + * Matching rules (both sides URL-parsed, never raw-string prefixed): + * - the path must contain no ENCODED separators (see ENCODED_PATH_SEPARATOR below), AND + * - origin must be exactly equal (so `https://myvote.example.com.evil.com` can't pass a + * `https://myvote.example.com` entry), AND + * - pathname must match on a path-segment boundary: equal to the entry's path, or start + * with entryPath + "/" (so a `/sso/callback` entry admits `/sso/callback/sub` but NOT + * `/sso/callback-evil`). Both sides are canonicalized the same way (trailing slashes + * stripped) and compared case-sensitively — URL paths ARE case-sensitive. + * + * OPERATIONAL NOTE: a bare-origin entry (e.g. `https://myvote.example.com`) admits EVERY + * path on that origin — fine for local dev, but production SSO_ALLOWED_REDIRECTS should + * list the exact callback path (e.g. `https://myvote.example.com/sso/callback`), so an + * open-redirect or XSS sink elsewhere on the MyVote origin can't receive SSO codes. + */ + private resolveRedirect(redirectUri: string): string { + let target: URL; + try { + target = new URL(redirectUri); + } catch (_error) { + throw new BadRequestException("redirect_uri must be an absolute URL"); + } + + // WHATWG URL leaves %2f / %5c / %2e ENCODED in `pathname` (it does not decode or + // re-normalize them), so `/sso/callback/%2f..%2fevil` would sail through the + // segment-boundary check below while a downstream proxy or framework that DOES decode + // and re-normalize the path resolves it to `/sso/evil` — a path-traversal escape from + // the whitelisted callback. Reject any encoded separator/dot-segment outright + // (fail-closed); a legitimate callback URL never needs one in its path. + if (ENCODED_PATH_SEPARATOR.test(target.pathname)) { + throw new BadRequestException("redirect_uri is not in the SSO_ALLOWED_REDIRECTS whitelist"); + } + + const allowed = this.allowedRedirects.some(entry => { + let entryUrl: URL; + try { + entryUrl = new URL(entry); + } catch (_error) { + return false; // malformed whitelist entry never matches (fail-closed) + } + if (target.origin !== entryUrl.origin) return false; + const entryPath = entryUrl.pathname.replace(/\/+$/, ""); // "" = bare origin, admits all paths + return target.pathname === entryPath || target.pathname.startsWith(`${entryPath}/`); + }); + + if (!allowed) { + throw new BadRequestException("redirect_uri is not in the SSO_ALLOWED_REDIRECTS whitelist"); + } + + return target.toString(); + } + + private sweepExpired(): void { + const now = Date.now(); + for (const [code, entry] of this.codes) { + if (entry.expiresAt <= now) this.codes.delete(code); + } + } + + private evictIfOverCap(): void { + if (this.codes.size < MAX_CODES) return; + this.sweepExpired(); + let toDrop = this.codes.size - MAX_CODES + 1; + for (const code of this.codes.keys()) { + if (toDrop <= 0) break; + this.codes.delete(code); + toDrop--; + } + } +}