diff --git a/.changeset/kind-penguins-report.md b/.changeset/kind-penguins-report.md new file mode 100644 index 0000000000..4023ea5976 --- /dev/null +++ b/.changeset/kind-penguins-report.md @@ -0,0 +1,10 @@ +--- +'@backstage/plugin-auth-backend': minor +--- + +CookieConfigurer can optionally return the `SameSite` cookie attribute. +CookieConfigurer now requires an additional argument `appOrigin` - the origin URL of the app - which is used to calculate the `SameSite` attribute. +defaultCookieConfigurer returns the `SameSite` attribute which defaults to `Lax`. In cases where an auth-backend is running on a different domain than the App, `SameSite=None` is used - but only for secure contexts. This is so that cookies can be included in third-party requests. + +OAuthAdapterOptions has been modified to require additional arguments, `baseUrl`, and `cookieConfigurer`. +OAuthAdapter now resolves cookie configuration using its supplied CookieConfigurer for each request to make sure that the proper attributes always are set. diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index 6e9de12cdb..ea5b923baa 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -183,10 +183,12 @@ export type CookieConfigurer = (ctx: { providerId: string; baseUrl: string; callbackUrl: string; + appOrigin: string; }) => { domain: string; path: string; secure: boolean; + sameSite?: 'none' | 'lax' | 'strict'; }; // @public @@ -280,11 +282,10 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { // @public (undocumented) export type OAuthAdapterOptions = { providerId: string; - secure: boolean; persistScopes?: boolean; - cookieDomain: string; - cookiePath: string; appOrigin: string; + baseUrl: string; + cookieConfigurer: CookieConfigurer; isOriginAllowed: (origin: string) => boolean; callbackUrl: string; }; diff --git a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts index 9dcd76e92e..526b05d0ab 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts @@ -18,6 +18,7 @@ import express from 'express'; import { THOUSAND_DAYS_MS, TEN_MINUTES_MS, OAuthAdapter } from './OAuthAdapter'; import { encodeState } from './helpers'; import { OAuthHandlers, OAuthState } from './types'; +import { CookieConfigurer } from '../../providers/types'; const mockResponseData = { providerInfo: { @@ -36,6 +37,10 @@ const mockResponseData = { }; describe('OAuthAdapter', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + class MyAuthProvider implements OAuthHandlers { async start() { return { @@ -57,18 +62,24 @@ describe('OAuthAdapter', () => { } } const providerInstance = new MyAuthProvider(); + const mockCookieConfig: ReturnType = { + domain: 'domain.org', + path: '/auth/test-provider', + secure: false, + }; + const mockCookieConfigurer = jest.fn().mockReturnValue(mockCookieConfig); + const oAuthProviderOptions = { providerId: 'test-provider', - secure: false, appOrigin: 'http://localhost:3000', - cookieDomain: 'example.com', - cookiePath: '/auth/test-provider', + baseUrl: 'http://domain.org/auth', + cookieConfigurer: mockCookieConfigurer, tokenIssuer: { issueToken: async () => 'my-id-token', listPublicKeys: async () => ({ keys: [] }), }, isOriginAllowed: () => false, - callbackUrl: 'http://example.com:7007/auth/test-provider/frame/handler', + callbackUrl: 'http://domain.org/auth/test-provider/handler/frame', }; it('sets the correct headers in start', async () => { @@ -96,7 +107,14 @@ describe('OAuthAdapter', () => { expect(mockResponse.cookie).toHaveBeenCalledWith( `${oAuthProviderOptions.providerId}-nonce`, expect.any(String), - expect.objectContaining({ maxAge: TEN_MINUTES_MS }), + expect.objectContaining({ + httpOnly: true, + path: '/auth/test-provider/handler', + maxAge: TEN_MINUTES_MS, + domain: 'domain.org', + sameSite: 'lax', + secure: false, + }), ); // redirect checks expect(mockResponse.setHeader).toHaveBeenCalledTimes(2); @@ -129,13 +147,18 @@ describe('OAuthAdapter', () => { } as unknown as express.Response; await oauthProvider.frameHandler(mockRequest, mockResponse); + expect(mockCookieConfigurer).toHaveBeenCalledTimes(1); expect(mockResponse.cookie).toHaveBeenCalledTimes(1); expect(mockResponse.cookie).toHaveBeenCalledWith( expect.stringContaining('test-provider-refresh-token'), expect.stringContaining('token'), expect.objectContaining({ + httpOnly: true, path: '/auth/test-provider', maxAge: THOUSAND_DAYS_MS, + domain: 'domain.org', + secure: false, + sameSite: 'lax', }), ); }); @@ -207,8 +230,12 @@ describe('OAuthAdapter', () => { 'test-provider-granted-scope', 'user', expect.objectContaining({ + httpOnly: true, path: '/auth/test-provider', maxAge: THOUSAND_DAYS_MS, + domain: 'domain.org', + secure: false, + sameSite: 'lax', }), ); @@ -243,6 +270,7 @@ describe('OAuthAdapter', () => { const mockRequest = { header: () => 'XMLHttpRequest', + get: jest.fn(), } as unknown as express.Request; const mockResponse = { @@ -252,6 +280,7 @@ describe('OAuthAdapter', () => { } as unknown as express.Response; await oauthProvider.logout(mockRequest, mockResponse); + expect(mockRequest.get).toHaveBeenCalledTimes(1); expect(mockResponse.cookie).toHaveBeenCalledTimes(1); expect(mockResponse.cookie).toHaveBeenCalledWith( expect.stringContaining('test-provider-refresh-token'), @@ -295,7 +324,46 @@ describe('OAuthAdapter', () => { }); }); - it('sets the correct cookie configuration using a callbackUrl', async () => { + it('sets new access-token when old cookie exists', async () => { + const oauthProvider = new OAuthAdapter(providerInstance, { + ...oAuthProviderOptions, + isOriginAllowed: () => false, + }); + + const mockRequest = { + header: () => 'XMLHttpRequest', + cookies: { + 'test-provider-refresh-token': 'old-token', + }, + query: {}, + get: jest.fn(), + } as unknown as express.Request; + + const mockResponse = { + json: jest.fn().mockReturnThis(), + status: jest.fn().mockReturnThis(), + cookie: jest.fn().mockReturnThis(), + } as unknown as express.Response; + + await oauthProvider.refresh(mockRequest, mockResponse); + expect(mockRequest.get).toHaveBeenCalledTimes(1); + expect(mockCookieConfigurer).toHaveBeenCalledTimes(1); + expect(mockResponse.cookie).toHaveBeenCalledTimes(1); + expect(mockResponse.cookie).toHaveBeenCalledWith( + 'test-provider-refresh-token', + 'token', + expect.objectContaining({ + httpOnly: true, + path: '/auth/test-provider', + maxAge: THOUSAND_DAYS_MS, + domain: 'domain.org', + secure: false, + sameSite: 'lax', + }), + ); + }); + + it('sets the correct nonce cookie configuration', async () => { const config = { baseUrl: 'http://domain.org/auth', appUrl: 'http://domain.org', @@ -304,13 +372,13 @@ describe('OAuthAdapter', () => { const oauthProvider = OAuthAdapter.fromConfig(config, providerInstance, { ...oAuthProviderOptions, - callbackUrl: 'https://authdomain.org/auth/test-provider/handler/frame', }); const mockRequest = { query: { scope: 'user', env: 'development', + origin: 'http://domain.org', }, } as unknown as express.Request; @@ -322,15 +390,252 @@ describe('OAuthAdapter', () => { } as unknown as express.Response; await oauthProvider.start(mockRequest, mockResponse); - + expect(mockCookieConfigurer).not.toHaveBeenCalled(); expect(mockResponse.cookie).toHaveBeenCalledTimes(1); expect(mockResponse.cookie).toHaveBeenCalledWith( `${oAuthProviderOptions.providerId}-nonce`, expect.any(String), expect.objectContaining({ - domain: 'authdomain.org', + httpOnly: true, + domain: 'domain.org', + maxAge: TEN_MINUTES_MS, + path: '/auth/test-provider/handler', + secure: false, + sameSite: 'lax', + }), + ); + }); + + it('sets the correct nonce cookie configuration using origin from request', async () => { + const config = { + baseUrl: 'http://domain.org/auth', + appUrl: 'http://domain.org', + isOriginAllowed: () => false, + }; + + const oauthProvider = OAuthAdapter.fromConfig(config, providerInstance, { + ...oAuthProviderOptions, + callbackUrl: 'https://domain.org/auth/test-provider/handler/frame', + }); + + const mockRequest = { + query: { + scope: 'user', + env: 'development', + origin: 'http://other.domain', + }, + } as unknown as express.Request; + + const mockResponse = { + cookie: jest.fn().mockReturnThis(), + end: jest.fn().mockReturnThis(), + setHeader: jest.fn().mockReturnThis(), + statusCode: jest.fn().mockReturnThis(), + } as unknown as express.Response; + + await oauthProvider.start(mockRequest, mockResponse); + expect(mockCookieConfigurer).not.toHaveBeenCalled(); + expect(mockResponse.cookie).toHaveBeenCalledTimes(1); + expect(mockResponse.cookie).toHaveBeenCalledWith( + `${oAuthProviderOptions.providerId}-nonce`, + expect.any(String), + expect.objectContaining({ + httpOnly: true, + domain: 'domain.org', + maxAge: TEN_MINUTES_MS, path: '/auth/test-provider/handler', secure: true, + sameSite: 'none', + }), + ); + }); + + it('sets the correct cookie configuration using an secure callbackUrl', async () => { + const config = { + baseUrl: 'https://domain.org/auth', + appUrl: 'http://domain.org', + isOriginAllowed: () => false, + }; + + const oauthProvider = OAuthAdapter.fromConfig(config, providerInstance, { + ...oAuthProviderOptions, + callbackUrl: 'https://domain.org/auth/test-provider/handler/frame', + }); + + const state = { + nonce: 'nonce', + env: 'development', + }; + + const mockRequest = { + cookies: { + 'test-provider-nonce': 'nonce', + }, + query: { + state: encodeState(state), + }, + } as unknown as express.Request; + + const mockResponse = { + cookie: jest.fn().mockReturnThis(), + setHeader: jest.fn().mockReturnThis(), + end: jest.fn().mockReturnThis(), + } as unknown as express.Response; + + await oauthProvider.frameHandler(mockRequest, mockResponse); + expect(mockCookieConfigurer).not.toHaveBeenCalled(); + expect(mockResponse.cookie).toHaveBeenCalledTimes(1); + expect(mockResponse.cookie).toHaveBeenCalledWith( + expect.stringContaining('test-provider-refresh-token'), + expect.stringContaining('token'), + expect.objectContaining({ + httpOnly: true, + domain: 'domain.org', + path: '/auth/test-provider', + secure: true, + sameSite: 'lax', + }), + ); + }); + + it('sets the correct cookie configuration when on different domains and secure', async () => { + const config = { + baseUrl: 'https://domain.org/auth', + appUrl: 'http://domain.org', + isOriginAllowed: () => false, + }; + + const oauthProvider = OAuthAdapter.fromConfig(config, providerInstance, { + ...oAuthProviderOptions, + callbackUrl: 'https://authdomain.org/auth/test-provider/handler/frame', + }); + + const state = { + nonce: 'nonce', + env: 'development', + }; + + const mockRequest = { + cookies: { + 'test-provider-nonce': 'nonce', + }, + query: { + state: encodeState(state), + }, + } as unknown as express.Request; + + const mockResponse = { + cookie: jest.fn().mockReturnThis(), + setHeader: jest.fn().mockReturnThis(), + end: jest.fn().mockReturnThis(), + } as unknown as express.Response; + + await oauthProvider.frameHandler(mockRequest, mockResponse); + expect(mockCookieConfigurer).not.toHaveBeenCalled(); + expect(mockResponse.cookie).toHaveBeenCalledTimes(1); + expect(mockResponse.cookie).toHaveBeenCalledWith( + expect.stringContaining('test-provider-refresh-token'), + expect.stringContaining('token'), + expect.objectContaining({ + httpOnly: true, + domain: 'authdomain.org', + path: '/auth/test-provider', + secure: true, + sameSite: 'none', + }), + ); + }); + + it('sets the correct cookie configuration using origin from state', async () => { + const config = { + baseUrl: 'https://domain.org/auth', + appUrl: 'http://domain.org', + isOriginAllowed: () => true, + }; + + const oauthProvider = OAuthAdapter.fromConfig(config, providerInstance, { + ...oAuthProviderOptions, + callbackUrl: 'https://domain.org/auth/test-provider/handler/frame', + }); + + const state = { + nonce: 'nonce', + env: 'development', + origin: 'http://other.domain', + }; + + const mockRequest = { + cookies: { + 'test-provider-nonce': 'nonce', + }, + query: { + state: encodeState(state), + }, + } as unknown as express.Request; + + const mockResponse = { + cookie: jest.fn().mockReturnThis(), + setHeader: jest.fn().mockReturnThis(), + end: jest.fn().mockReturnThis(), + } as unknown as express.Response; + + await oauthProvider.frameHandler(mockRequest, mockResponse); + expect(mockCookieConfigurer).not.toHaveBeenCalled(); + expect(mockResponse.cookie).toHaveBeenCalledTimes(1); + expect(mockResponse.cookie).toHaveBeenCalledWith( + expect.stringContaining('test-provider-refresh-token'), + expect.stringContaining('token'), + expect.objectContaining({ + httpOnly: true, + domain: 'domain.org', + path: '/auth/test-provider', + secure: true, + sameSite: 'none', + }), + ); + }); + + it('sets the correct cookie configuration using origin from header', async () => { + const config = { + baseUrl: 'https://domain.org/auth', + appUrl: 'http://domain.org', + isOriginAllowed: () => false, + }; + + const oauthProvider = OAuthAdapter.fromConfig(config, providerInstance, { + ...oAuthProviderOptions, + callbackUrl: 'https://domain.org/auth/test-provider/handler/frame', + }); + + const mockRequest = { + header: () => 'XMLHttpRequest', + cookies: { + 'test-provider-refresh-token': 'old-token', + }, + query: {}, + get: jest.fn().mockReturnValue('http://other.domain'), + } as unknown as express.Request; + + const mockResponse = { + json: jest.fn().mockReturnThis(), + status: jest.fn().mockReturnThis(), + cookie: jest.fn().mockReturnThis(), + } as unknown as express.Response; + + await oauthProvider.refresh(mockRequest, mockResponse); + expect(mockRequest.get).toHaveBeenCalledTimes(1); + expect(mockCookieConfigurer).not.toHaveBeenCalled(); + expect(mockResponse.cookie).toHaveBeenCalledTimes(1); + expect(mockResponse.cookie).toHaveBeenCalledWith( + 'test-provider-refresh-token', + 'token', + expect.objectContaining({ + httpOnly: true, + path: '/auth/test-provider', + maxAge: THOUSAND_DAYS_MS, + domain: 'domain.org', + secure: true, + sameSite: 'none', }), ); }); diff --git a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts index 6caa9fed12..8f08cfbbd0 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts @@ -24,6 +24,7 @@ import { import { AuthProviderRouteHandlers, AuthProviderConfig, + CookieConfigurer, } from '../../providers/types'; import { AuthenticationError, @@ -47,11 +48,10 @@ export const TEN_MINUTES_MS = 600 * 1000; /** @public */ export type OAuthAdapterOptions = { providerId: string; - secure: boolean; persistScopes?: boolean; - cookieDomain: string; - cookiePath: string; appOrigin: string; + baseUrl: string; + cookieConfigurer: CookieConfigurer; isOriginAllowed: (origin: string) => boolean; callbackUrl: string; }; @@ -66,22 +66,17 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { 'providerId' | 'persistScopes' | 'callbackUrl' >, ): OAuthAdapter { - const { origin: appOrigin } = new URL(config.appUrl); + const { appUrl, baseUrl, isOriginAllowed } = config; + const { origin: appOrigin } = new URL(appUrl); const cookieConfigurer = config.cookieConfigurer ?? defaultCookieConfigurer; - const cookieConfig = cookieConfigurer({ - providerId: options.providerId, - baseUrl: config.baseUrl, - callbackUrl: options.callbackUrl, - }); return new OAuthAdapter(handlers, { ...options, appOrigin, - cookieDomain: cookieConfig.domain, - cookiePath: cookieConfig.path, - secure: cookieConfig.secure, - isOriginAllowed: config.isOriginAllowed, + baseUrl, + cookieConfigurer, + isOriginAllowed, }); } @@ -94,9 +89,6 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { this.baseCookieOptions = { httpOnly: true, sameSite: 'lax', - secure: this.options.secure, - path: this.options.cookiePath, - domain: this.options.cookieDomain, }; } @@ -110,9 +102,11 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { throw new InputError('No env provided in request query parameters'); } + const cookieConfig = this.getCookieConfig(origin); + const nonce = crypto.randomBytes(16).toString('base64'); // set a nonce cookie before redirecting to oauth provider - this.setNonceCookie(res, nonce); + this.setNonceCookie(res, nonce, cookieConfig); const state: OAuthState = { nonce, env, origin }; @@ -158,16 +152,18 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { const { response, refreshToken } = await this.handlers.handler(req); + const cookieConfig = this.getCookieConfig(appOrigin); + // Store the scope that we have been granted for this session. This is useful if // the provider does not return granted scopes on refresh or if they are normalized. if (this.options.persistScopes && state.scope) { - this.setGrantedScopeCookie(res, state.scope); + this.setGrantedScopeCookie(res, state.scope, cookieConfig); response.providerInfo.scope = state.scope; } if (refreshToken) { // set new refresh token - this.setRefreshTokenCookie(res, refreshToken); + this.setRefreshTokenCookie(res, refreshToken, cookieConfig); } const identity = await this.populateIdentity(response.backstageIdentity); @@ -195,7 +191,9 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { } // remove refresh token cookie if it is set - this.removeRefreshTokenCookie(res); + const origin = req.get('origin'); + const cookieConfig = this.getCookieConfig(origin); + this.removeRefreshTokenCookie(res, cookieConfig); res.status(200).end(); } @@ -235,7 +233,9 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { ); if (newRefreshToken && newRefreshToken !== refreshToken) { - this.setRefreshTokenCookie(res, newRefreshToken); + const origin = req.get('origin'); + const cookieConfig = this.getCookieConfig(origin); + this.setRefreshTokenCookie(res, newRefreshToken, cookieConfig); } res.status(200).json({ ...response, backstageIdentity }); @@ -261,18 +261,28 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { return prepareBackstageIdentityResponse(identity); } - private setNonceCookie = (res: express.Response, nonce: string) => { + private setNonceCookie = ( + res: express.Response, + nonce: string, + cookieConfig: ReturnType, + ) => { res.cookie(`${this.options.providerId}-nonce`, nonce, { maxAge: TEN_MINUTES_MS, ...this.baseCookieOptions, - path: `${this.options.cookiePath}/handler`, + ...cookieConfig, + path: `${cookieConfig.path}/handler`, }); }; - private setGrantedScopeCookie = (res: express.Response, scope: string) => { + private setGrantedScopeCookie = ( + res: express.Response, + scope: string, + cookieConfig: ReturnType, + ) => { res.cookie(`${this.options.providerId}-granted-scope`, scope, { maxAge: THOUSAND_DAYS_MS, ...this.baseCookieOptions, + ...cookieConfig, }); }; @@ -283,17 +293,32 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { private setRefreshTokenCookie = ( res: express.Response, refreshToken: string, + cookieConfig: ReturnType, ) => { res.cookie(`${this.options.providerId}-refresh-token`, refreshToken, { maxAge: THOUSAND_DAYS_MS, ...this.baseCookieOptions, + ...cookieConfig, }); }; - private removeRefreshTokenCookie = (res: express.Response) => { + private removeRefreshTokenCookie = ( + res: express.Response, + cookieConfig: ReturnType, + ) => { res.cookie(`${this.options.providerId}-refresh-token`, '', { maxAge: 0, ...this.baseCookieOptions, + ...cookieConfig, + }); + }; + + private getCookieConfig = (origin?: string) => { + return this.options.cookieConfigurer({ + providerId: this.options.providerId, + baseUrl: this.options.baseUrl, + callbackUrl: this.options.callbackUrl, + appOrigin: origin ?? this.options.appOrigin, }); }; } diff --git a/plugins/auth-backend/src/lib/oauth/helpers.test.ts b/plugins/auth-backend/src/lib/oauth/helpers.test.ts index b3052c93e1..58a3dcab05 100644 --- a/plugins/auth-backend/src/lib/oauth/helpers.test.ts +++ b/plugins/auth-backend/src/lib/oauth/helpers.test.ts @@ -117,6 +117,7 @@ describe('OAuthProvider Utils', () => { baseUrl: '', providerId: 'test-provider', callbackUrl: 'http://domain.org/auth', + appOrigin: 'http://domain.org', }), ).toMatchObject({ domain: 'domain.org', @@ -131,6 +132,7 @@ describe('OAuthProvider Utils', () => { baseUrl: '', providerId: 'test-provider', callbackUrl: 'http://domain.org/auth/test-provider/handler/frame', + appOrigin: 'http://domain.org', }), ).toMatchObject({ domain: 'domain.org', @@ -145,10 +147,67 @@ describe('OAuthProvider Utils', () => { baseUrl: '', providerId: 'test-provider', callbackUrl: 'https://domain.org/auth', + appOrigin: 'http://domain.org', }), ).toMatchObject({ secure: true, }); }); + + it('should set sameSite to lax for https on the same domain', () => { + expect( + defaultCookieConfigurer({ + baseUrl: '', + providerId: 'test-provider', + callbackUrl: 'https://domain.org/auth', + appOrigin: 'http://domain.org', + }), + ).toMatchObject({ + sameSite: 'lax', + secure: true, + }); + }); + + it('should set sameSite to lax for http on the same domain', () => { + expect( + defaultCookieConfigurer({ + baseUrl: '', + providerId: 'test-provider', + callbackUrl: 'http://domain.org/auth', + appOrigin: 'http://domain.org', + }), + ).toMatchObject({ + sameSite: 'lax', + secure: false, + }); + }); + + it('should set sameSite to lax if not secure and on different domains', () => { + expect( + defaultCookieConfigurer({ + baseUrl: '', + providerId: 'test-provider', + callbackUrl: 'http://authdomain.org/auth', + appOrigin: 'http://domain.org', + }), + ).toMatchObject({ + sameSite: 'lax', + secure: false, + }); + }); + + it('should set sameSite to none if secure and on different domains', () => { + expect( + defaultCookieConfigurer({ + baseUrl: '', + providerId: 'test-provider', + callbackUrl: 'https://authdomain.org/auth', + appOrigin: 'http://domain.org', + }), + ).toMatchObject({ + sameSite: 'none', + secure: true, + }); + }); }); }); diff --git a/plugins/auth-backend/src/lib/oauth/helpers.ts b/plugins/auth-backend/src/lib/oauth/helpers.ts index 63d5d62ef9..2b4e50b477 100644 --- a/plugins/auth-backend/src/lib/oauth/helpers.ts +++ b/plugins/auth-backend/src/lib/oauth/helpers.ts @@ -65,10 +65,20 @@ export const verifyNonce = (req: express.Request, providerId: string) => { export const defaultCookieConfigurer: CookieConfigurer = ({ callbackUrl, providerId, + appOrigin, }) => { const { hostname: domain, pathname, protocol } = new URL(callbackUrl); const secure = protocol === 'https:'; + // For situations where the auth-backend is running on a + // different domain than the app, we set the SameSite attribute + // to 'none' to allow third-party access to the cookie, but + // only if it's in a secure context (https). + let sameSite: ReturnType['sameSite'] = 'lax'; + if (new URL(appOrigin).hostname !== domain && secure) { + sameSite = 'none'; + } + // If the provider supports callbackUrls, the pathname will // contain the complete path to the frame handler so we need // to slice off the trailing part of the path. @@ -76,5 +86,5 @@ export const defaultCookieConfigurer: CookieConfigurer = ({ ? pathname.slice(0, -'/handler/frame'.length) : `${pathname}/${providerId}`; - return { domain, path, secure }; + return { domain, path, secure, sameSite }; }; diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index 574afade79..1459cff484 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -100,7 +100,14 @@ export type CookieConfigurer = (ctx: { baseUrl: string; /** The configured callback URL of the auth provider */ callbackUrl: string; -}) => { domain: string; path: string; secure: boolean }; + /** The origin URL of the app */ + appOrigin: string; +}) => { + domain: string; + path: string; + secure: boolean; + sameSite?: 'none' | 'lax' | 'strict'; +}; /** @public */ export type AuthProviderConfig = {