diff --git a/app-config.yaml b/app-config.yaml index b8d355e4..2ff2cbed 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 0a36f5e5..32ea0cb9 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' }); + /** * MCP endpoint configuration */ @@ -118,6 +130,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, mcp: McpConfig, }); @@ -148,6 +161,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 ac57e80f..ab430129 100644 --- a/src/presentation/http/http-api.ts +++ b/src/presentation/http/http-api.ts @@ -235,6 +235,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 = /