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:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user