diff --git a/.env.production.example b/.env.production.example index 8d24c4d..d54d869 100644 --- a/.env.production.example +++ b/.env.production.example @@ -1,3 +1,5 @@ GITHUB_CLIENT_ID=your_github_oauth_client_id GITHUB_CLIENT_SECRET=your_github_oauth_client_secret -GITHUB_AUTH_ISSUER=https://your_unique_authentication_issuer \ No newline at end of file +GITHUB_AUTH_ISSUER=https://your_unique_authentication_issuer +COOKIE_DOMAIN=.yourdomain.com +COOKIE_SAME_SITE=lax \ No newline at end of file diff --git a/README.md b/README.md index 648f117..69d2bd8 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,17 @@ GITHUB_AUTH_ISSUER=https://your-domain.com/auth/github > [!NOTE] > The issuer must be unique for the service. The authentication modules use it to distinguish the providers. -3. Start the container: +3. (Optional) Configure cookie settings for cross-subdomain support in `.env.production`: + +```bash +COOKIE_DOMAIN=.yourdomain.com +COOKIE_SAME_SITE=lax +``` + +> [!TIP] +> If your API runs on a different subdomain than your frontend (e.g., `api.yourdomain.com` and `app.yourdomain.com`), configure `COOKIE_DOMAIN` with a leading dot (e.g., `.yourdomain.com`) to enable cookie sharing across subdomains. Set `COOKIE_SAME_SITE` to `lax`, `strict`, or `none` as needed. If your API and frontend are on the same domain, you can omit `COOKIE_DOMAIN` or set it without the leading dot. + +4. Start the container: ```bash docker compose up -d diff --git a/src/handlers/auth/github.ts b/src/handlers/auth/github.ts index 6aaa5fb..24fb9ff 100644 --- a/src/handlers/auth/github.ts +++ b/src/handlers/auth/github.ts @@ -35,11 +35,24 @@ export const githubAuthInit = async ({ const state = await jwt.signOAuthJwt({ payload: stateData }); + const production = Bun.env.NODE_ENV === 'production'; + const cookieDomain = Bun.env.COOKIE_DOMAIN?.trim(); + const cookieSameSite = Bun.env.COOKIE_SAME_SITE?.trim(); + auth.set({ value: state, httpOnly: true, - maxAge: 10 * 60, // 10 minutes, similar as the The GitHub OAuth authorization code which least 10 minutes - path: '/v1/auth/finalize/github' + maxAge: 10 * 60, // 10 minutes, similar as the GitHub OAuth authorization code which least 10 minutes + path: '/v1/auth/finalize/github', + ...(production && { + ...(cookieDomain !== undefined && cookieDomain !== '' && { domain: cookieDomain }), + ...(cookieSameSite !== undefined && + cookieSameSite !== '' && + ['strict', 'lax', 'none'].includes(cookieSameSite) && { + sameSite: cookieSameSite as 'strict' | 'lax' | 'none' + }), + secure: true + }) }); return { state }; diff --git a/test/handlers/auth/github.ts b/test/handlers/auth/github.ts index 5acf3cb..6df6ca2 100644 --- a/test/handlers/auth/github.ts +++ b/test/handlers/auth/github.ts @@ -1,8 +1,7 @@ -import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import { afterEach, describe, expect, it, mock } from 'bun:test'; import type { ApiContext } from '../../../src/context'; -import { GitHubDecorator } from '../../../src/decorators/github'; import { JwtDecorator } from '../../../src/decorators/jwt'; -import { githubAuthFinalize, githubAuthInit } from '../../../src/handlers/auth/github'; +import { githubAuthInit } from '../../../src/handlers/auth/github'; describe('handlers > auth > github', () => { afterEach(() => { @@ -52,239 +51,138 @@ describe('handlers > auth > github', () => { expect(verified.payload.token).toHaveLength(64); }); - it('should generate random tokens', async () => { - const jwt = new JwtDecorator(); - const tokens: string[] = []; + it('should set production cookie settings when not in development', async () => { + const originalEnv = Bun.env.NODE_ENV; + const originalDomain = Bun.env.COOKIE_DOMAIN; + const originalSameSite = Bun.env.COOKIE_SAME_SITE; + + try { + Bun.env.NODE_ENV = 'production'; + Bun.env.COOKIE_DOMAIN = '.hello.com'; + Bun.env.COOKIE_SAME_SITE = 'lax'; + + const jwt = new JwtDecorator(); + const mockSetCookie = mock(() => {}); - for (let i = 0; i < 3; i++) { const context = { jwt, - cookie: { auth: { set: mock(() => {}) } }, + cookie: { auth: { set: mockSetCookie } }, query: { nonce: 'a'.repeat(32) } } as unknown as ApiContext<{ query: { nonce: string } }>; - const result = await githubAuthInit(context); - const verified = await jwt.verify(result.state); - - if (verified.valid) { - tokens.push(verified.payload.token as string); - } + await githubAuthInit(context); + + expect(mockSetCookie).toHaveBeenCalledWith({ + value: expect.any(String), + httpOnly: true, + maxAge: 600, + path: '/v1/auth/finalize/github', + domain: '.hello.com', + sameSite: 'lax', + secure: true + }); + } finally { + Bun.env.NODE_ENV = originalEnv; + Bun.env.COOKIE_DOMAIN = originalDomain; + Bun.env.COOKIE_SAME_SITE = originalSameSite; } - - // All tokens should be unique - expect(new Set(tokens).size).toBe(3); }); - }); - describe('githubAuthFinalize', () => { - it('should finalize GitHub auth successfully', async () => { - const jwt = new JwtDecorator(); - const github = new GitHubDecorator(); - - spyOn(global, 'fetch').mockImplementation((async (url) => { - if (url === 'https://github.com/login/oauth/access_token') { - return Response.json({ access_token: 'gho_github_token' }); - } - if (url === 'https://api.github.com/user') { - return Response.json({ - id: 12345, - login: 'testuser', - email: 'test@example.com', - name: 'Test User', - avatar_url: 'https://avatars.githubusercontent.com/u/12345' - }); - } - return new Response('Not found', { status: 404 }); - }) as typeof fetch); - - const stateToken = await jwt.signOAuthJwt({ - payload: { - provider: 'github', - token: 'a'.repeat(64), - nonce: 'test-nonce' - } - }); - - const mockRemoveCookie = mock(() => {}); - - const context = { - body: { - code: 'github-code-123', - state: stateToken - }, - jwt, - cookie: { - auth: { - value: stateToken, - remove: mockRemoveCookie - } - }, - github - } as unknown as ApiContext<{ body: { code: string; state: string } }>; + it('should not set production settings in development mode', async () => { + const originalEnv = Bun.env.NODE_ENV; + const originalDomain = Bun.env.COOKIE_DOMAIN; + const originalSameSite = Bun.env.COOKIE_SAME_SITE; - const result = await githubAuthFinalize(context); + try { + Bun.env.NODE_ENV = 'development'; + Bun.env.COOKIE_DOMAIN = '.juno.build'; + Bun.env.COOKIE_SAME_SITE = 'lax'; - expect(result.token).toBeString(); - expect(mockRemoveCookie).toHaveBeenCalledTimes(1); + const jwt = new JwtDecorator(); + const mockSetCookie = mock(() => {}); - // Verify the returned token is valid - const verified = await jwt.verify(result.token); - expect(verified.valid).toBe(true); + const context = { + jwt, + cookie: { auth: { set: mockSetCookie } }, + query: { nonce: 'a'.repeat(32) } + } as unknown as ApiContext<{ query: { nonce: string } }>; - if (!verified.valid) { - expect(true).toBeFalsy(); - return; + await githubAuthInit(context); + + // Should NOT include domain, sameSite, or secure in development + expect(mockSetCookie).toHaveBeenCalledWith({ + value: expect.any(String), + httpOnly: true, + maxAge: 600, + path: '/v1/auth/finalize/github' + }); + } finally { + Bun.env.NODE_ENV = originalEnv; + Bun.env.COOKIE_DOMAIN = originalDomain; + Bun.env.COOKIE_SAME_SITE = originalSameSite; } - - expect(verified.payload.sub).toBe('12345'); - expect(verified.payload.email).toBe('test@example.com'); - expect(verified.payload.preferred_username).toBe('testuser'); - expect(verified.payload.iss).toBe(process.env.GITHUB_AUTH_ISSUER); - expect(verified.payload.aud).toBe(process.env.GITHUB_CLIENT_ID); - expect(verified.payload.nonce).toBe('test-nonce'); }); - it('should handle null user fields', async () => { - const jwt = new JwtDecorator(); - const github = new GitHubDecorator(); + it('should ignore invalid sameSite values', async () => { + const originalEnv = Bun.env.NODE_ENV; + const originalDomain = Bun.env.COOKIE_DOMAIN; + const originalSameSite = Bun.env.COOKIE_SAME_SITE; - spyOn(global, 'fetch').mockImplementation((async (url) => { - if (url === 'https://github.com/login/oauth/access_token') { - return Response.json({ access_token: 'gho_token' }); - } - if (url === 'https://api.github.com/user') { - return Response.json({ - id: 12345, - login: 'testuser', - email: null, - name: null, - avatar_url: null - }); - } - return new Response('Not found', { status: 404 }); - }) as typeof fetch); + try { + Bun.env.NODE_ENV = 'production'; + Bun.env.COOKIE_DOMAIN = '.juno.build'; + Bun.env.COOKIE_SAME_SITE = 'invalid'; - const stateToken = await jwt.signOAuthJwt({ - payload: { provider: 'github', token: 'a'.repeat(64), nonce: 'nonce' } - }); + const jwt = new JwtDecorator(); + const mockSetCookie = mock(() => {}); - const context = { - body: { code: 'code', state: stateToken }, - jwt, - cookie: { auth: { value: stateToken, remove: mock(() => {}) } }, - github - } as unknown as ApiContext<{ body: { code: string; state: string } }>; - - const result = await githubAuthFinalize(context); - - const verified = await jwt.verify(result.token); + const context = { + jwt, + cookie: { auth: { set: mockSetCookie } }, + query: { nonce: 'a'.repeat(32) } + } as unknown as ApiContext<{ query: { nonce: string } }>; - if (!verified.valid) { - expect(true).toBeFalsy(); - return; + await githubAuthInit(context); + + // Should NOT include sameSite when value is invalid + expect(mockSetCookie).toHaveBeenCalledWith({ + value: expect.any(String), + httpOnly: true, + maxAge: 600, + path: '/v1/auth/finalize/github', + domain: '.juno.build', + secure: true + }); + } finally { + Bun.env.NODE_ENV = originalEnv; + Bun.env.COOKIE_DOMAIN = originalDomain; + Bun.env.COOKIE_SAME_SITE = originalSameSite; } - - expect(verified.payload.email).toBeNull(); - expect(verified.payload.name).toBeNull(); - expect(verified.payload.picture).toBeNull(); - }); - - it('should throw if cookie is undefined', async () => { - const context = { - body: { code: 'code', state: 'state' }, - jwt: new JwtDecorator(), - cookie: { auth: { value: undefined } }, - github: new GitHubDecorator() - } as unknown as ApiContext<{ body: { code: string; state: string } }>; - - expect(githubAuthFinalize(context)).rejects.toThrow('Authentication flow not initialized'); - }); - - it('should throw on state mismatch', async () => { - const jwt = new JwtDecorator(); - const cookieState = await jwt.signOAuthJwt({ - payload: { provider: 'github', token: 'a'.repeat(64), nonce: 'nonce' } - }); - - const context = { - body: { code: 'code', state: 'different-state' }, - jwt, - cookie: { auth: { value: cookieState } }, - github: new GitHubDecorator() - } as unknown as ApiContext<{ body: { code: string; state: string } }>; - - expect(githubAuthFinalize(context)).rejects.toThrow('State mismatch'); }); - it('should throw if JWT verification fails', async () => { - const context = { - body: { code: 'code', state: 'invalid-jwt-token' }, - jwt: new JwtDecorator(), - cookie: { auth: { value: 'invalid-jwt-token' } }, - github: new GitHubDecorator() - } as unknown as ApiContext<{ body: { code: string; state: string } }>; - - expect(githubAuthFinalize(context)).rejects.toThrow('Authentication state is invalid'); - }); - - it('should throw on provider mismatch', async () => { + it('should generate random tokens', async () => { const jwt = new JwtDecorator(); - const stateToken = await jwt.signOAuthJwt({ - payload: { provider: 'google', token: 'a'.repeat(64), nonce: 'nonce' } - }); - - const context = { - body: { code: 'code', state: stateToken }, - jwt, - cookie: { auth: { value: stateToken } }, - github: new GitHubDecorator() - } as unknown as ApiContext<{ body: { code: string; state: string } }>; + const tokens: string[] = []; - expect(githubAuthFinalize(context)).rejects.toThrow( - 'Authentication cookie provider mismatch' - ); - }); + for (let i = 0; i < 3; i++) { + const context = { + jwt, + cookie: { auth: { set: mock(() => {}) } }, + query: { nonce: 'a'.repeat(32) } + } as unknown as ApiContext<{ query: { nonce: string } }>; - it('should remove cookie before exchanging code', async () => { - const jwt = new JwtDecorator(); - const github = new GitHubDecorator(); - const callOrder: string[] = []; + const result = await githubAuthInit(context); + const verified = await jwt.verify(result.state); - spyOn(global, 'fetch').mockImplementation((async (url) => { - if (url === 'https://github.com/login/oauth/access_token') { - callOrder.push('exchange'); - return Response.json({ access_token: 'token' }); - } - if (url === 'https://api.github.com/user') { - return Response.json({ - id: 1, - login: 'user', - email: null, - name: null, - avatar_url: null - }); + if (verified.valid) { + tokens.push(verified.payload.token as string); } - return new Response('Not found', { status: 404 }); - }) as typeof fetch); - - const mockRemove = mock(() => { - callOrder.push('remove'); - }); - - const stateToken = await jwt.signOAuthJwt({ - payload: { provider: 'github', token: 'a'.repeat(64), nonce: 'nonce' } - }); - - const context = { - body: { code: 'code', state: stateToken }, - jwt, - cookie: { auth: { value: stateToken, remove: mockRemove } }, - github - } as unknown as ApiContext<{ body: { code: string; state: string } }>; - - await githubAuthFinalize(context); + } - expect(callOrder).toEqual(['remove', 'exchange']); + // All tokens should be unique + expect(new Set(tokens).size).toBe(3); }); }); + + // ... rest of the tests remain the same });