From 925a9d718bb2aba3ccce43c7c9f847acc173ac20 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Wed, 21 Oct 2026 00:50:53 +0300 Subject: [PATCH] fix(oauth): post tokens only to allowlisted web origins The Google callback page sent the access and refresh tokens with postMessage(..., '*'). Any site could open /oauth/google/login in a popup and receive a fresh session for a user who had already consented. Add httpApi.webOrigins, an allowlist of exact origins (the schema rejects '*', paths and trailing slashes). The callback now posts once to each allowlisted origin, and the browser delivers the message only when the opener's origin matches exactly. Dev config allows http://localhost:5173. Deploy: stage and production configs must set httpApi.webOrigins (e.g. https://notex.so), or Google login will deliver no tokens. Follow-up in notes.web: useAuth.ts should accept the message only if event.origin equals the API origin and event.source is the popup. Co-Authored-By: Claude Opus 5.5 --- app-config.yaml | 2 + src/infrastructure/config/index.ts | 14 +++ src/presentation/http/http-api.ts | 1 + src/presentation/http/router/oauth.test.ts | 125 +++++++++++++++++++++ src/presentation/http/router/oauth.ts | 25 ++++- 5 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 src/presentation/http/router/oauth.test.ts diff --git a/app-config.yaml b/app-config.yaml index 888ae411..ffecc9c3 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -3,6 +3,8 @@ httpApi: fileSizeLimit: 1000000 port: 1337 allowedOrigins: '*' + webOrigins: + - 'http://localhost:5173' cookieSecret: 'secret' cookieDomain: 'localhost' oauth2: diff --git a/src/infrastructure/config/index.ts b/src/infrastructure/config/index.ts index 7cc301d6..85ead773 100644 --- a/src/infrastructure/config/index.ts +++ b/src/infrastructure/config/index.ts @@ -98,6 +98,18 @@ export const LoggingConfig = z.object({ export type LoggingConfig = z.infer; +/** + * Exact origin of the web app, e.g. 'https://notex.so' + * Wildcards, paths and trailing slashes are not allowed, because tokens are posted to this origin + */ +const WebOrigin = z.string().refine((value) => { + try { + return new URL(value).origin === value; + } catch { + return false; + } +}, { message: 'Must be an exact origin like https://notex.so' }); + /** * Http API configuration */ @@ -109,6 +121,7 @@ const HttpApiConfig = z.object({ cookieDomain: z.string(), accessTokenSecret: z.string(), allowedOrigins: z.union([z.array(z.string()), z.literal('*')]), + webOrigins: z.array(WebOrigin), // origins of the web app allowed to receive tokens after OAuth login oauth2: OAuth2Config, }); @@ -138,6 +151,7 @@ const defaultConfig: AppConfig = { cookieDomain: 'localhost', accessTokenSecret: 'accessTokenSecret', allowedOrigins: [], + webOrigins: [], oauth2: { google: { clientId: '', diff --git a/src/presentation/http/http-api.ts b/src/presentation/http/http-api.ts index 0505fe6a..a5d3ee20 100644 --- a/src/presentation/http/http-api.ts +++ b/src/presentation/http/http-api.ts @@ -232,6 +232,7 @@ export default class HttpApi implements Api { userService: domainServices.userService, authService: domainServices.authService, cookieDomain: this.config.cookieDomain, + webOrigins: this.config.webOrigins, }); await this.server?.register(AuthRouter, { diff --git a/src/presentation/http/router/oauth.test.ts b/src/presentation/http/router/oauth.test.ts new file mode 100644 index 00000000..236a61c5 --- /dev/null +++ b/src/presentation/http/router/oauth.test.ts @@ -0,0 +1,125 @@ +import { describe, test, expect, vi } from 'vitest'; +import { runInNewContext } from 'node:vm'; +import fastify from 'fastify'; +import type { FastifyInstance } from 'fastify'; +import type { OAuth2Namespace } from '@fastify/oauth2'; +import type UserService from '@domain/service/user.js'; +import type AuthService from '@domain/service/auth.js'; +import { OauthSchema } from '@presentation/http/schema/OauthSchema.js'; +import { notFound } from '@presentation/http/decorators/index.js'; +import OauthRouter from '@presentation/http/router/oauth.js'; + +const accessToken = 'access-token'; +const refreshToken = 'refresh-token'; + +/** + * Creates server with oauth router only. Google token exchange and user lookup are stubbed + * @param webOrigins - origins allowed to receive tokens + * @param user - user returned by provider lookup + */ +async function createServer(webOrigins: string[], user: { id: number } | null = { id: 1 }): Promise { + const server = fastify(); + + server.decorate('googleOAuth2', { + /* eslint-disable-next-line @typescript-eslint/naming-convention */ + getAccessTokenFromAuthorizationCodeFlow: vi.fn().mockResolvedValue({ token: { access_token: 'google-access-token' } }), + } as unknown as OAuth2Namespace); + server.decorateReply('notFound', notFound); + server.addSchema(OauthSchema); + + await server.register(OauthRouter, { + prefix: '/oauth', + userService: { + getUserByProvider: vi.fn().mockResolvedValue(user), + } as unknown as UserService, + authService: { + signAccessToken: vi.fn().mockReturnValue(accessToken), + signRefreshToken: vi.fn().mockResolvedValue(refreshToken), + } as unknown as AuthService, + cookieDomain: 'localhost', + webOrigins, + }); + + return server; +} + +/** + * Runs script of the callback page against fake opener window + * @param html - page returned by the callback endpoint + * @returns arguments of every postMessage call + */ +function runCallbackPage(html: string): unknown[][] { + const script = /