Merge pull request #13556 from backstage/eide/allow-third-party-cookies

[auth-backend]: Calculate `SameSite` cookie attribute in defaultCookieConfigurer and default to `none`
This commit is contained in:
Patrik Oldsberg
2022-09-20 17:20:22 +02:00
committed by GitHub
7 changed files with 456 additions and 39 deletions
+10
View File
@@ -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.
+4 -3
View File
@@ -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;
};
@@ -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<CookieConfigurer> = {
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',
}),
);
});
@@ -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<CookieConfigurer>,
) => {
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<CookieConfigurer>,
) => {
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<CookieConfigurer>,
) => {
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<CookieConfigurer>,
) => {
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,
});
};
}
@@ -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,
});
});
});
});
+11 -1
View File
@@ -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<CookieConfigurer>['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 };
};
+8 -1
View File
@@ -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 = {