Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ httpApi:
fileSizeLimit: 1000000
port: 1337
allowedOrigins: '*'
webOrigins:
- 'http://localhost:5173'
cookieSecret: 'secret'
cookieDomain: 'localhost'
oauth2:
Expand Down
14 changes: 14 additions & 0 deletions src/infrastructure/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,18 @@ export const LoggingConfig = z.object({

export type LoggingConfig = z.infer<typeof LoggingConfig>;

/**
* 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
*/
Expand All @@ -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,
});
Expand Down Expand Up @@ -148,6 +161,7 @@ const defaultConfig: AppConfig = {
cookieDomain: 'localhost',
accessTokenSecret: 'accessTokenSecret',
allowedOrigins: [],
webOrigins: [],
oauth2: {
google: {
clientId: '',
Expand Down
1 change: 1 addition & 0 deletions src/presentation/http/http-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
125 changes: 125 additions & 0 deletions src/presentation/http/router/oauth.test.ts
Original file line number Diff line number Diff line change
@@ -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<FastifyInstance> {
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 = /<script>([\s\S]*?)<\/script>/.exec(html)?.[1] ?? '';
const postMessage = vi.fn();

runInNewContext(script, {
window: {
opener: { postMessage },
close: vi.fn(),
},
});

return postMessage.mock.calls;
}

describe('OAuth API', () => {
describe('GET /oauth/google/callback', () => {
test('Posts tokens only to the configured web origin', async () => {
const server = await createServer(['https://notex.so']);

const response = await server.inject({
method: 'GET',
url: '/oauth/google/callback',
});

expect(response.statusCode).toBe(200);
expect(runCallbackPage(response.body)).toEqual([
[
{
accessToken,
refreshToken,
},
'https://notex.so',
],
]);
});

test('Posts tokens to each configured web origin exactly, never to "*"', async () => {
const server = await createServer(['https://notex.so', 'http://localhost:5173']);

const response = await server.inject({
method: 'GET',
url: '/oauth/google/callback',
});

const targetOrigins = runCallbackPage(response.body).map(([, origin]) => origin);

expect(targetOrigins).toEqual(['https://notex.so', 'http://localhost:5173']);
});

test('Does not post tokens when no web origins are configured', async () => {
const server = await createServer([]);

const response = await server.inject({
method: 'GET',
url: '/oauth/google/callback',
});

expect(response.statusCode).toBe(200);
expect(runCallbackPage(response.body)).toEqual([]);
});

test('Returns 404 without tokens when user is not found', async () => {
const server = await createServer(['https://notex.so'], null);

const response = await server.inject({
method: 'GET',
url: '/oauth/google/callback',
});

expect(response.statusCode).toBe(404);
expect(response.body).not.toContain(accessToken);
expect(response.body).not.toContain(refreshToken);
});
});
});
25 changes: 24 additions & 1 deletion src/presentation/http/router/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ interface OauthRouterOptions {
* Cookie domain for refresh and access tokens
*/
cookieDomain: string;

/**
* Exact origins of the web app allowed to receive tokens via postMessage
*/
webOrigins: string[];
}

/**
Expand All @@ -30,6 +35,10 @@ interface OauthRouterOptions {
* @param done - callback
*/
const OauthRouter: FastifyPluginCallback<OauthRouterOptions> = (fastify, opts, done) => {
if (opts.webOrigins.length === 0) {
fastify.log.warn('httpApi.webOrigins is empty, so Google login will not pass tokens to the web app');
}

/**
* Callback for Google oauth2. Google redirects to this endpoint after user authentication.
*/
Expand Down Expand Up @@ -65,14 +74,28 @@ const OauthRouter: FastifyPluginCallback<OauthRouterOptions> = (fastify, opts, d
const accessToken = opts.authService.signAccessToken({ id: user.id });
const refreshToken = await opts.authService.signRefreshToken(user.id);

/**
* Never use '*' as target origin: any site could open the login popup and receive the tokens.
* Browser delivers the message only when the opener origin exactly matches one of the allowed origins
*/
const message = JSON.stringify({
accessToken,
refreshToken,
});
const targetOrigins = JSON.stringify(opts.webOrigins);

/**
* Show page with script passing parent window postMessage with tokens
*/
return reply.type('text/html').send(`
<html>
<head>
<script>
window.opener.postMessage({ accessToken: '${accessToken}', refreshToken: '${refreshToken}' }, '*');
if (window.opener) {
${targetOrigins}.forEach(function (origin) {
window.opener.postMessage(${message}, origin);
});
}
window.close();
</script>
</head>
Expand Down
Loading