diff --git a/.changeset/rare-otters-judge.md b/.changeset/rare-otters-judge.md new file mode 100644 index 0000000000..3c3f91e094 --- /dev/null +++ b/.changeset/rare-otters-judge.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-node': patch +--- + +Browsers silently drop cookies that exceed 4KB, which can be problematic for refresh tokens and other large cookies.This update ensures that large cookies, like refresh tokens, are not dropped by browsers, maintaining the integrity of the authentication process. The changes include both the implementation of the cookie splitting logic and corresponding tests to validate the new functionality. diff --git a/plugins/auth-node/src/oauth/OAuthCookieManager.ts b/plugins/auth-node/src/oauth/OAuthCookieManager.ts index 1d06242a2e..ac9531a8aa 100644 --- a/plugins/auth-node/src/oauth/OAuthCookieManager.ts +++ b/plugins/auth-node/src/oauth/OAuthCookieManager.ts @@ -14,12 +14,14 @@ * limitations under the License. */ -import { Request, Response } from 'express'; +import { CookieOptions, Request, Response } from 'express'; import { CookieConfigurer } from '../types'; const THOUSAND_DAYS_MS = 1000 * 24 * 60 * 60 * 1000; const TEN_MINUTES_MS = 600 * 1000; +const MAX_COOKIE_SIZE_CHARACTERS = 4000; + const defaultCookieConfigurer: CookieConfigurer = ({ callbackUrl, providerId, @@ -85,50 +87,126 @@ export class OAuthCookieManager { }; } - setNonce(res: Response, nonce: string, origin?: string) { - res.cookie(this.nonceCookie, nonce, { + setNonce(res: Response, nonce: string, origin?: string): void { + this.setCookie(res, this.nonceCookie, nonce, { maxAge: TEN_MINUTES_MS, ...this.getConfig(origin, '/handler'), }); } - setRefreshToken(res: Response, refreshToken: string, origin?: string) { - res.cookie(this.refreshTokenCookie, refreshToken, { + setRefreshToken(res: Response, refreshToken: string, origin?: string): void { + this.setCookie(res, this.refreshTokenCookie, refreshToken, { maxAge: THOUSAND_DAYS_MS, ...this.getConfig(origin), }); } - removeRefreshToken(res: Response, origin?: string) { - res.cookie(this.refreshTokenCookie, '', { - maxAge: 0, - ...this.getConfig(origin), - }); + removeRefreshToken(res: Response, origin?: string): void { + this.removeCookie(res, this.refreshTokenCookie, origin); } - removeGrantedScopes(res: Response, origin?: string) { - res.cookie(this.grantedScopeCookie, '', { - maxAge: 0, - ...this.getConfig(origin), - }); + removeGrantedScopes(res: Response, origin?: string): void { + this.removeCookie(res, this.grantedScopeCookie, origin); } - setGrantedScopes(res: Response, scope: string, origin?: string) { - res.cookie(this.grantedScopeCookie, scope, { + setGrantedScopes(res: Response, scope: string, origin?: string): void { + this.setCookie(res, this.grantedScopeCookie, scope, { maxAge: THOUSAND_DAYS_MS, ...this.getConfig(origin), }); } getNonce(req: Request): string | undefined { - return req.cookies[this.nonceCookie]; + return this.getCookie(req, this.nonceCookie); } getRefreshToken(req: Request): string | undefined { - return req.cookies[this.refreshTokenCookie]; + return this.getCookie(req, this.refreshTokenCookie); } getGrantedScopes(req: Request): string | undefined { - return req.cookies[this.grantedScopeCookie]; + return this.getCookie(req, this.grantedScopeCookie); + } + + private setCookie( + res: Response, + name: string, + val: string, + options: CookieOptions, + ): Response { + if (val.length > MAX_COOKIE_SIZE_CHARACTERS) { + const chunked = this.splitCookieToChunks(val, MAX_COOKIE_SIZE_CHARACTERS); + let output = res; + chunked.forEach((value, chunkNumber) => { + output = output.cookie( + OAuthCookieManager.getCookieChunkName(name, chunkNumber), + value, + options, + ); + }); + return output; + } + return res.cookie(name, val, options); + } + + private getCookie(req: Request, name: string): string | undefined { + const isChunked = + !!req.cookies[OAuthCookieManager.getCookieChunkName(name, 0)]; + if (isChunked) { + const chunks: string[] = []; + let chunkNumber = 0; + let chunk = + req.cookies[OAuthCookieManager.getCookieChunkName(name, chunkNumber)]; + while (chunk) { + chunks.push(chunk); + chunkNumber++; + chunk = + req.cookies[OAuthCookieManager.getCookieChunkName(name, chunkNumber)]; + } + return chunks.join(''); + } + return req.cookies[name]; + } + + private removeCookie(res: Response, name: string, origin?: string): Response { + const req = res.req; + const options = { + maxAge: 0, + ...this.getConfig(origin), + }; + const isChunked = + !!req.cookies[OAuthCookieManager.getCookieChunkName(name, 0)]; + if (isChunked) { + const oldFormatExists = !!req.cookies[name]; + let output: Response = oldFormatExists + ? res.cookie(name, '', options) + : res; + for (let chunkNumber = 0; ; chunkNumber++) { + const key = OAuthCookieManager.getCookieChunkName(name, chunkNumber); + const exists = !!req.cookies[key]; + if (!exists) { + break; + } + output = output.cookie(key, '', options); + } + return output; + } + return res.cookie(name, '', options); + } + + private splitCookieToChunks(val: string, chunkSize: number): string[] { + const numChunks = Math.ceil(val.length / chunkSize); + const chunks: string[] = Array(numChunks); + + let offset: number = 0; + for (let i = 0; i < numChunks; i++) { + chunks[i] = val.substring(offset, offset + chunkSize); + offset += chunkSize; + } + return chunks; + } + + private static getCookieChunkName(name: string, chunkIndex: number): string { + return `${name}-${chunkIndex}`; } } diff --git a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts index 7da5519655..0c94171881 100644 --- a/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts +++ b/plugins/auth-node/src/oauth/createOAuthRouteHandlers.test.ts @@ -49,6 +49,9 @@ const mockSession = { refreshToken: 'refresh-token', }; +const fiveKilobyteRefreshToken = + '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'; + const baseConfig = { authenticator: mockAuthenticator, appUrl: 'http://127.0.0.1', @@ -91,7 +94,15 @@ function getNonceCookie(test: SuperAgentTest) { }); } -function getRefreshTokenCookie(test: SuperAgentTest) { +function getRefreshTokenCookie(test: SuperAgentTest, chunkNumber?: number) { + if (chunkNumber !== undefined) { + return test.jar.getCookie(`my-provider-refresh-token-${chunkNumber}`, { + domain: '127.0.0.1', + path: '/my-provider', + script: false, + secure: false, + }); + } return test.jar.getCookie('my-provider-refresh-token', { domain: '127.0.0.1', path: '/my-provider', @@ -267,6 +278,60 @@ describe('createOAuthRouteHandlers', () => { expect(getGrantedScopesCookie(agent)).toBeUndefined(); }); + it('should authenticate when refresh token is bigger than 4KB', async () => { + const agent = request.agent( + wrapInApp(createOAuthRouteHandlers(baseConfig)), + ); + + agent.jar.setCookie( + 'my-provider-nonce=123', + '127.0.0.1', + '/my-provider/handler', + ); + + mockAuthenticator.authenticate.mockResolvedValue({ + fullProfile: { id: 'id' } as PassportProfile, + session: { + ...mockSession, + refreshToken: fiveKilobyteRefreshToken, + }, + }); + + const res = await agent.get('/my-provider/handler/frame').query({ + state: encodeOAuthState({ + env: 'development', + nonce: '123', + } as OAuthState), + }); + + expect(mockAuthenticator.authenticate).toHaveBeenCalledWith( + { req: expect.anything() }, + { ctx: 'authenticator' }, + ); + + expect(res.status).toBe(200); + expect(parseWebMessageResponse(res.text).response).toEqual({ + type: 'authorization_response', + response: { + profile: {}, + providerInfo: { + accessToken: 'access-token', + expiresInSeconds: 3, + idToken: 'id-token', + scope: 'my-scope', + }, + }, + }); + + expect(getRefreshTokenCookie(agent, 0).value).toBe( + fiveKilobyteRefreshToken.slice(0, 4000), + ); + expect(getRefreshTokenCookie(agent, 1).value).toBe( + fiveKilobyteRefreshToken.slice(4000), + ); + expect(getGrantedScopesCookie(agent)).toBeUndefined(); + }); + it('should authenticate with sign-in, profile transform, and persisted scopes', async () => { const agent = request.agent( wrapInApp( @@ -556,6 +621,60 @@ describe('createOAuthRouteHandlers', () => { }); }); + it('should refresh for chunked token', async () => { + const agent = request.agent( + wrapInApp(createOAuthRouteHandlers(baseConfig)), + ); + + agent.jar.setCookie( + `my-provider-refresh-token-0=${fiveKilobyteRefreshToken.slice( + 0, + 4000, + )}`, + '127.0.0.1', + '/my-provider', + ); + agent.jar.setCookie( + `my-provider-refresh-token-1=${fiveKilobyteRefreshToken.slice(4000)}`, + '127.0.0.1', + '/my-provider', + ); + + mockAuthenticator.refresh.mockImplementation(async ({ scope }) => ({ + fullProfile: { id: 'id' } as PassportProfile, + session: { + ...mockSession, + scope, + refreshToken: fiveKilobyteRefreshToken, + }, + })); + + const res = await agent + .post('/my-provider/refresh') + .set('X-Requested-With', 'XMLHttpRequest') + .query({ scope: 'my-scope' }); + + expect(mockAuthenticator.refresh).toHaveBeenCalledWith( + { + req: expect.anything(), + refreshToken: fiveKilobyteRefreshToken, + scope: 'my-scope', + }, + { ctx: 'authenticator' }, + ); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + profile: {}, + providerInfo: { + accessToken: 'access-token', + expiresInSeconds: 3, + idToken: 'id-token', + scope: 'my-scope', + }, + }); + }); + it('should refresh with sign-in, profile transform, and persisted scopes', async () => { const agent = request.agent( wrapInApp(