From 44ee811c983a5bcbe8f777b0d39d75cc4589037e Mon Sep 17 00:00:00 2001 From: jhfnetboy Date: Tue, 14 Jul 2026 15:30:34 +0700 Subject: [PATCH 1/3] =?UTF-8?q?feat(mv1):=20MyVote=20SSO=20=E9=80=9A?= =?UTF-8?q?=E9=81=93=20=E2=80=94=20=E4=B8=80=E6=AC=A1=E6=80=A7=20code=20?= =?UTF-8?q?=E7=AD=BE=E5=8F=91/=E5=85=91=E6=8D=A2/=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E7=AB=AF=E7=82=B9(cos72=20=E5=8D=8A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 aastar/src/sso 模块(MV-1 的 cos72 半,KMS signTypedData 由 E-5 另行交付): - POST /sso/authorize(JWT 保护):为当前登录用户签发一次性 code(32B hex, TTL 60s,单次消费),绑定 { userId, aaAddress };redirect_uri 走 SSO_ALLOWED_REDIRECTS 白名单(origin 解析比对 + 前缀匹配,空=拒绝一切, fail-closed) - POST /sso/exchange(公开):code 换短时 SSO session token — 独立 SSO_JWT_SECRET(缺失则启动即 fail fast)+ audience "myvote" + TTL 10min, payload { sub, aa };get+delete 同步连续操作,单线程 Node 下天然原子 - GET /sso/verify(公开):Bearer 校验 secret/audience/过期,返回 { valid, aaAddress },坏 token 不抛 401(供 MyVote 后端/edge 轮询) - exchange/verify 挂 per-IP 滑窗限流 20 req/min(仿 otp-rate-limit.guard, IP 键;单实例内存实现,多副本需换 Redis——code 存储同) - CORS 不动:main.ts 已是 origin:true 宽松配置,无需并入 SSO 白名单 - 单测 21 例:单次消费/过期/白名单 fail-closed(含 lookalike origin)/ audience/secret/过期 token/限流(IP、bucket 隔离与滑窗释放) Claude-Session: https://claude.ai/code/session_01MCxVCnKkpzi5sCGf1FZ4CH --- aastar/.env.example | 7 + aastar/src/app.module.ts | 2 + aastar/src/sso/dto/sso.dto.ts | 25 ++ aastar/src/sso/guards/sso-rate-limit.guard.ts | 93 +++++++ aastar/src/sso/sso.controller.ts | 59 ++++ aastar/src/sso/sso.module.ts | 18 ++ aastar/src/sso/sso.service.spec.ts | 257 ++++++++++++++++++ aastar/src/sso/sso.service.ts | 199 ++++++++++++++ 8 files changed, 660 insertions(+) create mode 100644 aastar/src/sso/dto/sso.dto.ts create mode 100644 aastar/src/sso/guards/sso-rate-limit.guard.ts create mode 100644 aastar/src/sso/sso.controller.ts create mode 100644 aastar/src/sso/sso.module.ts create mode 100644 aastar/src/sso/sso.service.spec.ts create mode 100644 aastar/src/sso/sso.service.ts 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/sso/dto/sso.dto.ts b/aastar/src/sso/dto/sso.dto.ts new file mode 100644 index 0000000..b9f35cc --- /dev/null +++ b/aastar/src/sso/dto/sso.dto.ts @@ -0,0 +1,25 @@ +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; +} 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.controller.ts b/aastar/src/sso/sso.controller.ts new file mode 100644 index 0000000..e63abaf --- /dev/null +++ b/aastar/src/sso/sso.controller.ts @@ -0,0 +1,59 @@ +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, or already consumed" }) + async exchange(@Body() dto: SsoExchangeDto) { + return this.ssoService.exchange(dto.code); + } + + @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..6b15955 --- /dev/null +++ b/aastar/src/sso/sso.service.spec.ts @@ -0,0 +1,257 @@ +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 { SsoRateLimit, __resetSsoRateLimit } from "./guards/sso-rate-limit.guard"; +import { SSO_TOKEN_AUDIENCE, SSO_TOKEN_TTL_SECONDS, SsoService } from "./sso.service"; + +const TEST_SECRET = "test-sso-secret"; +const ALLOWED = "https://myvote.example.com,http://localhost:5175/sso/callback"; +const AA_ADDRESS = "0x1111111111111111111111111111111111111111"; +const USER_ID = "user-1"; + +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, 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"); + }); + + 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 }), + jwtService, + accountServiceMock as unknown as AccountService + ); + await expect( + closed.authorize(USER_ID, "https://myvote.example.com/sso/callback") + ).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, "https://myvote.example.com/sso/callback") + ).rejects.toThrow(BadRequestException); + }); + }); + + describe("exchange — one-time code consumption", () => { + it("exchanges a fresh code for a token bound to the user's AA address", async () => { + const { code } = await service.authorize(USER_ID, "https://myvote.example.com/cb"); + const result = service.exchange(code); + + 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, "https://myvote.example.com/cb"); + service.exchange(code); + expect(() => service.exchange(code)).toThrow(UnauthorizedException); + }); + + it("rejects an unknown code", () => { + expect(() => service.exchange("f".repeat(64))).toThrow(UnauthorizedException); + }); + + it("rejects an expired code (TTL 60s) and keeps it consumed", async () => { + jest.useFakeTimers(); + const { code } = await service.authorize(USER_ID, "https://myvote.example.com/cb"); + jest.advanceTimersByTime(61_000); + expect(() => service.exchange(code)).toThrow(UnauthorizedException); + // expired attempt consumed it too — still single-use even if the clock is rolled back + expect(() => service.exchange(code)).toThrow(UnauthorizedException); + }); + }); + + describe("verify — SSO token validation", () => { + it("accepts a token minted by exchange", async () => { + const { code } = await service.authorize(USER_ID, "https://myvote.example.com/cb"); + const { token } = service.exchange(code); + 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 an expired token", async () => { + jest.useFakeTimers(); + const { code } = await service.authorize(USER_ID, "https://myvote.example.com/cb"); + const { token } = service.exchange(code); + 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("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..ff1d217 --- /dev/null +++ b/aastar/src/sso/sso.service.ts @@ -0,0 +1,199 @@ +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"; + +/** + * 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; + 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; + +export const SSO_TOKEN_AUDIENCE = "myvote"; +export const SSO_TOKEN_TTL_SECONDS = 600; // 10 min + +@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"); + } + 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: same origin as an entry AND prefixed by it — origins are + * compared on parsed URLs so `https://myvote.example.evil.com` can't pass an + * `https://myvote.example` entry. + */ + async authorize( + userId: string, + redirectUri: string + ): Promise<{ code: string; redirectUri: string }> { + this.assertRedirectAllowed(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, issuedAt: now, expiresAt: now + CODE_TTL_MS }); + + return { code, redirectUri }; + } + + /** + * Swaps a one-time code for a short-lived SSO session token. 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. + */ + exchange(code: string): { token: string; aaAddress: string; expiresIn: number } { + const entry = this.codes.get(code); + this.codes.delete(code); // consume unconditionally: even an expired code is single-use + + if (!entry) { + throw new UnauthorizedException("Invalid or already used SSO code"); + } + if (entry.expiresAt <= Date.now()) { + throw new UnauthorizedException("SSO code expired"); + } + + const token = this.jwtService.sign( + { sub: entry.userId, aa: entry.aaAddress }, + { + secret: this.ssoJwtSecret, + 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, + audience: SSO_TOKEN_AUDIENCE, + }); + return { valid: true, aaAddress: payload.aa ?? null }; + } catch (_error) { + return { valid: false, aaAddress: null }; + } + } + + private assertRedirectAllowed(redirectUri: string): void { + let target: URL; + try { + target = new URL(redirectUri); + } catch (_error) { + throw new BadRequestException("redirect_uri must be an absolute URL"); + } + + const allowed = this.allowedRedirects.some(entry => { + try { + const entryUrl = new URL(entry); + return target.origin === entryUrl.origin && redirectUri.startsWith(entry); + } catch (_error) { + return false; // malformed whitelist entry never matches (fail-closed) + } + }); + + if (!allowed) { + throw new BadRequestException("redirect_uri is not in the SSO_ALLOWED_REDIRECTS whitelist"); + } + } + + 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--; + } + } +} From 071c94997819f76accfabf6125b1f07bef2ff244 Mon Sep 17 00:00:00 2001 From: jhfnetboy Date: Tue, 14 Jul 2026 15:43:03 +0700 Subject: [PATCH 2/3] =?UTF-8?q?fix(mv1):=20Codex=20=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E5=AE=A1=E4=BF=AE=E5=A4=8D=20=E2=80=94=20code=E2=86=94redirect?= =?UTF-8?q?=20=E7=BB=91=E5=AE=9A/path=20=E8=BE=B9=E7=95=8C/secret=20?= =?UTF-8?q?=E9=9A=94=E7=A6=BB/=E7=AE=97=E6=B3=95=E5=9B=BA=E5=AE=9A/?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M1 code 绑 redirect_uri:authorize 把规范化(URL.toString)redirect_uri 存进 SsoCodeEntry;exchange DTO 新增 redirect_uri,兑换时与存值精确匹配,不匹配 401 且照样烧掉 code(OAuth code↔redirect 绑定) - M2 path 前缀绕过:白名单匹配改为 URL 解析后 origin 精确相等 + pathname 边界 匹配(等于白名单 path 或以 path+'/' 开头;白名单 path 尾斜杠规范化)—— /sso/callback 不再放过 /sso/callback-evil - M3 secret 隔离强制:SsoService 构造时校验 SSO_JWT_SECRET != JWT_SECRET,相等 即 fail fast;JwtStrategy 采用 audience 排斥方案(拒绝 aud 含 "myvote" 的 token 走 session 路径,存量无 aud 的 session token 不受影响),常量抽到 sso.constants.ts 避免 auth→sso 重依赖链循环 - L1 算法固定:SSO 签发 algorithm HS256、verify algorithms ['HS256']; JwtStrategy 同样 pin HS256 - L2 错误 oracle:exchange 的不存在/已用/过期/redirect 不匹配统一为 401 'Invalid SSO code' - 单测 21→33:redirect 不匹配拒绝+烧码、URL 规范化等价匹配、callback-evil 拒/ callback/sub 放/精确放/尾斜杠规范化、secret 相等 fail fast、JwtStrategy aud 排斥×2+存量放行、HS512 拒绝、三态错误一致性 Claude-Session: https://claude.ai/code/session_01MCxVCnKkpzi5sCGf1FZ4CH --- aastar/src/auth/strategies/jwt.strategy.ts | 17 +- aastar/src/sso/dto/sso.dto.ts | 9 + aastar/src/sso/sso.constants.ts | 8 + aastar/src/sso/sso.controller.ts | 7 +- aastar/src/sso/sso.service.spec.ts | 181 ++++++++++++++++++--- aastar/src/sso/sso.service.ts | 95 ++++++++--- 6 files changed, 271 insertions(+), 46 deletions(-) create mode 100644 aastar/src/sso/sso.constants.ts 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 index b9f35cc..4bfa7e7 100644 --- a/aastar/src/sso/dto/sso.dto.ts +++ b/aastar/src/sso/dto/sso.dto.ts @@ -22,4 +22,13 @@ export class SsoExchangeDto { @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/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 index e63abaf..b32017c 100644 --- a/aastar/src/sso/sso.controller.ts +++ b/aastar/src/sso/sso.controller.ts @@ -40,9 +40,12 @@ export class SsoController { status: 200, description: "{ token, aaAddress, expiresIn } — JWT audience 'myvote', TTL 10min", }) - @ApiResponse({ status: 401, description: "Code invalid, expired, or already consumed" }) + @ApiResponse({ + status: 401, + description: "Code invalid / expired / already consumed / redirect_uri mismatch (unified)", + }) async exchange(@Body() dto: SsoExchangeDto) { - return this.ssoService.exchange(dto.code); + return this.ssoService.exchange(dto.code, dto.redirect_uri); } @Get("verify") diff --git a/aastar/src/sso/sso.service.spec.ts b/aastar/src/sso/sso.service.spec.ts index 6b15955..252e9d6 100644 --- a/aastar/src/sso/sso.service.spec.ts +++ b/aastar/src/sso/sso.service.spec.ts @@ -11,13 +11,17 @@ jest.mock("../account/account.service", () => ({ })); 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, SsoService } from "./sso.service"; +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 { @@ -43,7 +47,11 @@ describe("SsoService", () => { JwtService, { provide: ConfigService, - useValue: makeConfig({ SSO_JWT_SECRET: TEST_SECRET, SSO_ALLOWED_REDIRECTS: ALLOWED }), + useValue: makeConfig({ + SSO_JWT_SECRET: TEST_SECRET, + JWT_SECRET: SESSION_SECRET, + SSO_ALLOWED_REDIRECTS: ALLOWED, + }), }, { provide: AccountService, useValue: accountServiceMock }, ], @@ -73,16 +81,29 @@ describe("SsoService", () => { ).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 }), + makeConfig({ SSO_JWT_SECRET: TEST_SECRET, JWT_SECRET: SESSION_SECRET }), jwtService, accountServiceMock as unknown as AccountService ); - await expect( - closed.authorize(USER_ID, "https://myvote.example.com/sso/callback") - ).rejects.toThrow(BadRequestException); + await expect(closed.authorize(USER_ID, REDIRECT)).rejects.toThrow(BadRequestException); closed.onModuleDestroy(); }); @@ -110,16 +131,47 @@ describe("SsoService", () => { it("rejects when the user has no AirAccount", async () => { accountServiceMock.getAccountAddress.mockRejectedValue(new Error("no account")); - await expect( - service.authorize(USER_ID, "https://myvote.example.com/sso/callback") - ).rejects.toThrow(BadRequestException); + 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("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", () => { + 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, "https://myvote.example.com/cb"); - const result = service.exchange(code); + 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); @@ -133,29 +185,75 @@ describe("SsoService", () => { }); it("consumes the code — a second exchange with the same code fails", async () => { - const { code } = await service.authorize(USER_ID, "https://myvote.example.com/cb"); - service.exchange(code); - expect(() => service.exchange(code)).toThrow(UnauthorizedException); + 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))).toThrow(UnauthorizedException); + 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, "https://myvote.example.com/cb"); + const { code } = await service.authorize(USER_ID, REDIRECT); jest.advanceTimersByTime(61_000); - expect(() => service.exchange(code)).toThrow(UnauthorizedException); + 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)).toThrow(UnauthorizedException); + 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, "https://myvote.example.com/cb"); - const { token } = service.exchange(code); + const { code } = await service.authorize(USER_ID, REDIRECT); + const { token } = service.exchange(code, REDIRECT); expect(service.verify(token)).toEqual({ valid: true, aaAddress: AA_ADDRESS }); }); @@ -175,10 +273,23 @@ describe("SsoService", () => { 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, "https://myvote.example.com/cb"); - const { token } = service.exchange(code); + 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 }); }); @@ -189,6 +300,30 @@ describe("SsoService", () => { }); }); +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(); diff --git a/aastar/src/sso/sso.service.ts b/aastar/src/sso/sso.service.ts index ff1d217..54aef2b 100644 --- a/aastar/src/sso/sso.service.ts +++ b/aastar/src/sso/sso.service.ts @@ -8,6 +8,7 @@ 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). @@ -24,6 +25,8 @@ import { AccountService } from "../account/account.service"; 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; } @@ -39,8 +42,12 @@ const SWEEP_MS = 60_000; */ const MAX_CODES = 10_000; -export const SSO_TOKEN_AUDIENCE = "myvote"; -export const SSO_TOKEN_TTL_SECONDS = 600; // 10 min +/** + * 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"; @Injectable() export class SsoService implements OnModuleDestroy { @@ -67,6 +74,12 @@ export class SsoService implements OnModuleDestroy { 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. @@ -86,15 +99,15 @@ export class SsoService implements OnModuleDestroy { /** * Mints a one-time SSO code for the (JWT-authenticated) user. `redirectUri` must match the - * SSO_ALLOWED_REDIRECTS whitelist: same origin as an entry AND prefixed by it — origins are - * compared on parsed URLs so `https://myvote.example.evil.com` can't pass an - * `https://myvote.example` entry. + * 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 }> { - this.assertRedirectAllowed(redirectUri); + const normalizedRedirect = this.resolveRedirect(redirectUri); let aaAddress: string; try { @@ -108,31 +121,57 @@ export class SsoService implements OnModuleDestroy { const code = randomBytes(32).toString("hex"); const now = Date.now(); this.evictIfOverCap(); - this.codes.set(code, { userId, aaAddress, issuedAt: now, expiresAt: now + CODE_TTL_MS }); + this.codes.set(code, { + userId, + aaAddress, + redirectUri: normalizedRedirect, + issuedAt: now, + expiresAt: now + CODE_TTL_MS, + }); - return { code, redirectUri }; + return { code, redirectUri: normalizedRedirect }; } /** - * Swaps a one-time code for a short-lived SSO session token. 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. + * 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): { token: string; aaAddress: string; expiresIn: number } { + exchange( + code: string, + redirectUri: string + ): { token: string; aaAddress: string; expiresIn: number } { const entry = this.codes.get(code); - this.codes.delete(code); // consume unconditionally: even an expired code is single-use + this.codes.delete(code); // consume unconditionally: single-use even when the attempt fails - if (!entry) { - throw new UnauthorizedException("Invalid or already used SSO code"); + // 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; } - if (entry.expiresAt <= Date.now()) { - throw new UnauthorizedException("SSO code expired"); + + 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, } @@ -149,6 +188,7 @@ export class SsoService implements OnModuleDestroy { 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 }; @@ -157,7 +197,17 @@ export class SsoService implements OnModuleDestroy { } } - private assertRedirectAllowed(redirectUri: string): void { + /** + * Validates redirect_uri against SSO_ALLOWED_REDIRECTS and returns its normalized form. + * Matching rules (both sides URL-parsed, never raw-string prefixed): + * - 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`). Entry paths are normalized by stripping trailing slashes; a + * bare-origin entry admits every path on that origin. + */ + private resolveRedirect(redirectUri: string): string { let target: URL; try { target = new URL(redirectUri); @@ -166,17 +216,22 @@ export class SsoService implements OnModuleDestroy { } const allowed = this.allowedRedirects.some(entry => { + let entryUrl: URL; try { - const entryUrl = new URL(entry); - return target.origin === entryUrl.origin && redirectUri.startsWith(entry); + 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 { From 386058bfec2a0ea4b6894628bf9bfee743d49aef Mon Sep 17 00:00:00 2001 From: jhfnetboy Date: Tue, 14 Jul 2026 15:51:15 +0700 Subject: [PATCH 3/3] =?UTF-8?q?fix(mv1):=20=E6=8B=92=E7=BB=9D=20redirect?= =?UTF-8?q?=5Furi=20path=20=E4=B8=AD=E7=9A=84=E7=BC=96=E7=A0=81=E5=88=86?= =?UTF-8?q?=E9=9A=94=E7=AC=A6(%2f/%5c/%2e),=E5=A0=B5=E4=BD=8F=E7=BC=96?= =?UTF-8?q?=E7=A0=81=E7=A9=BF=E8=B6=8A=E7=BB=95=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex 终审剩余 Medium:WHATWG URL 的 pathname 不解码 %2f/%5c/%2e,于是 /sso/callback/%2f..%2fevil 能满足 startsWith(entryPath + "/") 通过白名单; 一旦 MyVote 或其前置代理解码并归一化路径,就逃逸到 /sso/evil。 - resolveRedirect 在 origin/path 边界匹配之前,先以 /%2f|%5c|%2e/i 大小写不敏感 拒绝 path 含编码分隔符/点段的 redirect_uri(fail-closed,合法 callback 不需要) - path 比较保持大小写敏感(URL path 本就区分大小写)+ 双方同一 canonical 化 (去尾斜杠)后做 segment 边界匹配 —— 现有实现即如此,保持不变 - 注释补运营建议:生产 SSO_ALLOWED_REDIRECTS 应配精确 callback path,不要配裸 origin(裸 origin 放行该 origin 任意 path) - 单测 33→38:%2f 拒、%2F 大写拒、%5c 拒、%2e%2e 拒、正常 /sso/callback/sub 仍放行 Claude-Session: https://claude.ai/code/session_01MCxVCnKkpzi5sCGf1FZ4CH --- aastar/src/sso/sso.service.spec.ts | 29 +++++++++++++++++++++++++++++ aastar/src/sso/sso.service.ts | 27 +++++++++++++++++++++++++-- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/aastar/src/sso/sso.service.spec.ts b/aastar/src/sso/sso.service.spec.ts index 252e9d6..c712135 100644 --- a/aastar/src/sso/sso.service.spec.ts +++ b/aastar/src/sso/sso.service.spec.ts @@ -151,6 +151,35 @@ describe("SsoService", () => { 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({ diff --git a/aastar/src/sso/sso.service.ts b/aastar/src/sso/sso.service.ts index 54aef2b..dec0458 100644 --- a/aastar/src/sso/sso.service.ts +++ b/aastar/src/sso/sso.service.ts @@ -49,6 +49,13 @@ const MAX_CODES = 10_000; */ 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; @@ -200,12 +207,18 @@ export class SsoService implements OnModuleDestroy { /** * 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`). Entry paths are normalized by stripping trailing slashes; a - * bare-origin entry admits every path on that origin. + * `/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; @@ -215,6 +228,16 @@ export class SsoService implements OnModuleDestroy { 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 {