Implementation for oauth2 authentication using a redirect flow (without window popup) from frontend to backend API, followed by a redirect back to the fronted. A localstorage provider token is added before the redirect to the backend auth API.

During the subsequent front end refresh an asynchronous backstage session refresh is triggered when the provider token is put in localstorage. The session refresh will return a backstage authentication token if authentication succeeded during the previous backend auth API execution.

Addresses https://github.com/backstage/backstage/issues/9582

Signed-off-by: headphonejames <generalfuzz@gmail.com>
This commit is contained in:
headphonejames
2023-01-18 14:32:00 -08:00
parent 9d207058d3
commit 9a9170047b
31 changed files with 426 additions and 334 deletions
@@ -19,6 +19,7 @@ import {
safelyEncodeURIComponent,
ensuresXRequestedWith,
postMessageResponse,
redirectMessageResponse,
} from './authFlowHelpers';
import { WebMessageResponse } from './types';
@@ -178,6 +179,22 @@ describe('oauth helpers', () => {
});
});
describe('redirectMessageResponse', () => {
const redirectUrl = 'http://localhost:3000/catalog';
it('should perform redirect', () => {
const mockResponse = {
end: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
redirect: jest.fn().mockReturnThis(),
} as unknown as express.Response;
redirectMessageResponse(mockResponse, redirectUrl);
expect(mockResponse.redirect).toHaveBeenCalledTimes(1);
expect(mockResponse.end).not.toHaveBeenCalled();
expect(mockResponse.setHeader).not.toHaveBeenCalled();
});
});
describe('ensuresXRequestedWith', () => {
it('should return false if no header present', () => {
const mockRequest = {
@@ -69,6 +69,14 @@ export const postMessageResponse = (
res.end(`<html><body><script>${script}</script></body></html>`);
};
/** @public */
export const redirectMessageResponse = (
res: express.Response,
redirectUrl: string,
) => {
res.redirect(redirectUrl);
};
/** @public */
export const ensuresXRequestedWith = (req: express.Request) => {
const requiredHeader = req.header('X-Requested-With');
+5 -1
View File
@@ -14,6 +14,10 @@
* limitations under the License.
*/
export { ensuresXRequestedWith, postMessageResponse } from './authFlowHelpers';
export {
ensuresXRequestedWith,
postMessageResponse,
redirectMessageResponse,
} from './authFlowHelpers';
export type { WebMessageResponse } from './types';
@@ -74,6 +74,7 @@ describe('OAuthAdapter', () => {
providerId: 'test-provider',
appOrigin: 'http://localhost:3000',
baseUrl: 'http://domain.org/auth',
isPopupAuthenticationRequest: true,
cookieConfigurer: mockCookieConfigurer,
tokenIssuer: {
issueToken: async () => 'my-id-token',
@@ -83,56 +84,10 @@ describe('OAuthAdapter', () => {
callbackUrl: 'http://domain.org/auth/test-provider/handler/frame',
};
it('sets the correct headers in start', async () => {
const oauthProvider = new OAuthAdapter(
providerInstance,
oAuthProviderOptions,
);
const mockRequest = {
query: {
scope: 'user',
env: 'development',
},
} as unknown as express.Request;
const defaultState = { nonce: 'nonce', env: 'development' };
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);
// nonce cookie checks
expect(mockResponse.cookie).toHaveBeenCalledTimes(1);
expect(mockResponse.cookie).toHaveBeenCalledWith(
`${oAuthProviderOptions.providerId}-nonce`,
expect.any(String),
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);
expect(mockResponse.setHeader).toHaveBeenCalledWith('Location', '/url');
expect(mockResponse.setHeader).toHaveBeenCalledWith('Content-Length', '0');
expect(mockResponse.statusCode).toEqual(301);
expect(mockResponse.end).toHaveBeenCalledTimes(1);
});
it('sets the refresh cookie if refresh is enabled', async () => {
const oauthProvider = new OAuthAdapter(providerInstance, {
...oAuthProviderOptions,
isOriginAllowed: () => false,
});
const state = { nonce: 'nonce', env: 'development' };
const mockRequest = {
const createEncodedQueryMockRequest = (state: any) => {
return {
cookies: {
'test-provider-nonce': 'nonce',
},
@@ -140,12 +95,69 @@ describe('OAuthAdapter', () => {
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;
const mockResponse = {
cookie: jest.fn().mockReturnThis(),
end: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
statusCode: jest.fn().mockReturnThis(),
redirect: jest.fn().mockReturnThis(),
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
} as unknown as express.Response;
const mockStartRequest = {
query: {
scope: 'user',
env: 'development',
},
} as unknown as express.Request;
const expectedStartAuthCookieData = {
httpOnly: true,
path: '/auth/test-provider/handler',
maxAge: TEN_MINUTES_MS,
domain: 'domain.org',
sameSite: 'lax',
secure: false,
};
it('sets the correct headers in start', async () => {
const oauthProvider = new OAuthAdapter(
providerInstance,
oAuthProviderOptions,
);
await oauthProvider.start(mockStartRequest, mockResponse);
// nonce cookie checks
expect(mockResponse.cookie).toHaveBeenCalledTimes(1);
expect(mockResponse.cookie).toHaveBeenCalledWith(
`${oAuthProviderOptions.providerId}-nonce`,
expect.any(String),
expect.objectContaining(expectedStartAuthCookieData),
);
expect(mockResponse.setHeader).toHaveBeenCalledTimes(2);
expect(mockResponse.setHeader).toHaveBeenCalledWith('Location', '/url');
expect(mockResponse.setHeader).toHaveBeenCalledWith('Content-Length', '0');
expect(mockResponse.statusCode).toEqual(301);
expect(mockResponse.end).toHaveBeenCalledTimes(1);
expect(mockResponse.redirect).not.toHaveBeenCalled();
});
const refreshCookieData = {
...expectedStartAuthCookieData,
path: '/auth/test-provider',
maxAge: THOUSAND_DAYS_MS,
};
it('sets the refresh cookie if refresh is enabled', async () => {
const oauthProvider = new OAuthAdapter(providerInstance, {
...oAuthProviderOptions,
isOriginAllowed: () => false,
});
const mockRequest = createEncodedQueryMockRequest(defaultState);
await oauthProvider.frameHandler(mockRequest, mockResponse);
expect(mockCookieConfigurer).toHaveBeenCalledTimes(1);
@@ -153,15 +165,22 @@ describe('OAuthAdapter', () => {
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',
}),
expect.objectContaining(refreshCookieData),
);
expect(mockResponse.redirect).not.toHaveBeenCalled();
});
it('sets the refresh cookie if refresh is enabled with redirect', async () => {
const oauthProvider = new OAuthAdapter(providerInstance, {
...oAuthProviderOptions,
isOriginAllowed: () => false,
isPopupAuthenticationRequest: false,
});
const mockRequest = createEncodedQueryMockRequest(defaultState);
await oauthProvider.frameHandler(mockRequest, mockResponse);
expect(mockResponse.redirect).toHaveBeenCalledTimes(1);
});
it('persists scope through cookie if enabled', async () => {
@@ -179,32 +198,15 @@ describe('OAuthAdapter', () => {
});
// First we test the /start request, making sure state is set
const mockStartReq = {
query: {
scope: 'user',
env: 'development',
},
} as unknown as express.Request;
const mockStartRes = {
cookie: jest.fn().mockReturnThis(),
end: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
statusCode: jest.fn().mockReturnThis(),
} as unknown as express.Response;
await oauthProvider.start(mockStartReq, mockStartRes);
await oauthProvider.start(mockStartRequest, mockResponse);
expect(handlers.start).toHaveBeenCalledTimes(1);
expect(handlers.start).toHaveBeenCalledWith({
query: {
scope: 'user',
env: 'development',
},
...mockStartRequest,
scope: 'user',
state: {
nonce: expect.any(String),
env: 'development',
origin: undefined,
scope: 'user',
},
});
@@ -223,6 +225,7 @@ describe('OAuthAdapter', () => {
cookie: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
end: jest.fn().mockReturnThis(),
redirect: jest.fn().mockReturnThis(),
} as unknown as express.Response;
await oauthProvider.frameHandler(mockHandleReq, mockHandleRes);
@@ -230,17 +233,11 @@ describe('OAuthAdapter', () => {
expect(mockHandleRes.cookie).toHaveBeenCalledWith(
'test-provider-granted-scope',
'user',
expect.objectContaining({
httpOnly: true,
path: '/auth/test-provider',
maxAge: THOUSAND_DAYS_MS,
domain: 'domain.org',
secure: false,
sameSite: 'lax',
}),
expect.objectContaining(refreshCookieData),
);
expect(mockResponse.redirect).not.toHaveBeenCalled();
// Them make sure scopes are forwarded correctly during refresh
// Then make sure scopes are forwarded correctly during refresh
const mockRefreshReq = {
query: { scope: 'ignore-me' },
cookies: {
@@ -252,6 +249,7 @@ describe('OAuthAdapter', () => {
const mockRefreshRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
redirect: jest.fn().mockReturnThis(),
} as unknown as express.Response;
await oauthProvider.refresh(mockRefreshReq, mockRefreshRes);
expect(handlers.refresh).toHaveBeenCalledTimes(1);
@@ -261,8 +259,18 @@ describe('OAuthAdapter', () => {
refreshToken: 'refresh-token',
}),
);
expect(mockRefreshRes.redirect).not.toHaveBeenCalled();
});
const mockRequestWithHeader = {
header: () => 'XMLHttpRequest',
cookies: {
'test-provider-refresh-token': 'token',
},
query: {},
get: jest.fn(),
} as unknown as express.Request;
it('removes refresh cookie and calls logout handler when logging out', async () => {
const logoutSpy = jest.spyOn(providerInstance, 'logout');
const oauthProvider = new OAuthAdapter(providerInstance, {
@@ -270,22 +278,8 @@ describe('OAuthAdapter', () => {
isOriginAllowed: () => false,
});
const mockRequest = {
cookies: {
'test-provider-refresh-token': 'token',
},
header: () => 'XMLHttpRequest',
get: jest.fn(),
} as unknown as express.Request;
const mockResponse = {
cookie: jest.fn().mockReturnThis(),
end: jest.fn().mockReturnThis(),
status: jest.fn().mockReturnThis(),
} as unknown as express.Response;
await oauthProvider.logout(mockRequest, mockResponse);
expect(mockRequest.get).toHaveBeenCalledTimes(1);
await oauthProvider.logout(mockRequestWithHeader, mockResponse);
expect(mockRequestWithHeader.get).toHaveBeenCalledTimes(1);
expect(logoutSpy).toHaveBeenCalledTimes(1);
expect(mockResponse.cookie).toHaveBeenCalledTimes(1);
expect(mockResponse.cookie).toHaveBeenCalledWith(
@@ -294,6 +288,7 @@ describe('OAuthAdapter', () => {
expect.objectContaining({ path: '/auth/test-provider' }),
);
expect(mockResponse.end).toHaveBeenCalledTimes(1);
expect(mockResponse.redirect).not.toHaveBeenCalled();
});
it('gets new access-token when refreshing', async () => {
@@ -302,20 +297,7 @@ describe('OAuthAdapter', () => {
isOriginAllowed: () => false,
});
const mockRequest = {
header: () => 'XMLHttpRequest',
cookies: {
'test-provider-refresh-token': 'token',
},
query: {},
} as unknown as express.Request;
const mockResponse = {
json: jest.fn().mockReturnThis(),
status: jest.fn().mockReturnThis(),
} as unknown as express.Response;
await oauthProvider.refresh(mockRequest, mockResponse);
await oauthProvider.refresh(mockRequestWithHeader, mockResponse);
expect(mockResponse.json).toHaveBeenCalledTimes(1);
expect(mockResponse.json).toHaveBeenCalledWith({
...mockResponseData,
@@ -328,6 +310,7 @@ describe('OAuthAdapter', () => {
},
},
});
expect(mockResponse.redirect).not.toHaveBeenCalled();
});
it('sets new access-token when old cookie exists', async () => {
@@ -337,20 +320,12 @@ describe('OAuthAdapter', () => {
});
const mockRequest = {
header: () => 'XMLHttpRequest',
...mockRequestWithHeader,
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);
@@ -358,15 +333,9 @@ describe('OAuthAdapter', () => {
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',
}),
expect.objectContaining(refreshCookieData),
);
expect(mockResponse.redirect).not.toHaveBeenCalled();
});
it('sets the correct nonce cookie configuration', async () => {
@@ -374,168 +343,97 @@ describe('OAuthAdapter', () => {
baseUrl: 'http://domain.org/auth',
appUrl: 'http://domain.org',
isOriginAllowed: () => false,
isPopupAuthenticationRequest: true,
};
const oauthProvider = OAuthAdapter.fromConfig(config, providerInstance, {
...oAuthProviderOptions,
});
const mockRequest = {
query: {
scope: 'user',
env: 'development',
origin: 'http://domain.org',
},
} 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);
await oauthProvider.start(mockStartRequest, 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: false,
sameSite: 'lax',
}),
expect.objectContaining(expectedStartAuthCookieData),
);
expect(mockResponse.redirect).not.toHaveBeenCalled();
});
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 config = {
baseUrl: 'http://domain.org/auth',
appUrl: 'http://domain.org',
isOriginAllowed: () => false,
isPopupAuthenticationRequest: true,
};
const mockStartRequestWithOrigin = {
query: {
scope: 'user',
env: 'development',
origin: 'http://other.domain',
},
} as unknown as express.Request;
it('sets the correct nonce cookie configuration using origin from request', async () => {
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);
await oauthProvider.start(mockStartRequestWithOrigin, 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',
...expectedStartAuthCookieData,
secure: true,
sameSite: 'none',
}),
);
expect(mockResponse.redirect).not.toHaveBeenCalled();
});
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 secureCookieData = {
...refreshCookieData,
secure: true,
sameSite: 'lax',
maxAge: THOUSAND_DAYS_MS,
};
it('sets the correct cookie configuration using an secure callbackUrl', async () => {
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;
const mockRequest = createEncodedQueryMockRequest(defaultState);
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',
}),
expect.objectContaining(secureCookieData),
);
expect(mockResponse.redirect).not.toHaveBeenCalled();
});
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 secureSameSiteNoneCookieData = {
...secureCookieData,
sameSite: 'none',
};
it('sets the correct cookie configuration when on different domains and secure', async () => {
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;
const mockRequest = createEncodedQueryMockRequest(defaultState);
await oauthProvider.frameHandler(mockRequest, mockResponse);
expect(mockCookieConfigurer).not.toHaveBeenCalled();
expect(mockResponse.cookie).toHaveBeenCalledTimes(1);
@@ -543,106 +441,109 @@ describe('OAuthAdapter', () => {
expect.stringContaining('test-provider-refresh-token'),
expect.stringContaining('token'),
expect.objectContaining({
httpOnly: true,
...secureSameSiteNoneCookieData,
domain: 'authdomain.org',
path: '/auth/test-provider',
secure: true,
sameSite: 'none',
}),
);
expect(mockResponse.redirect).not.toHaveBeenCalled();
});
const configOriginAllowed = {
...config,
isOriginAllowed: () => true,
};
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(
configOriginAllowed,
providerInstance,
{
...oAuthProviderOptions,
callbackUrl: 'https://domain.org/auth/test-provider/handler/frame',
},
);
const oauthProvider = OAuthAdapter.fromConfig(config, providerInstance, {
...oAuthProviderOptions,
callbackUrl: 'https://domain.org/auth/test-provider/handler/frame',
});
const state = {
nonce: 'nonce',
env: 'development',
const mockRequest = createEncodedQueryMockRequest({
...defaultState,
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',
}),
expect.objectContaining(secureSameSiteNoneCookieData),
);
expect(mockResponse.redirect).not.toHaveBeenCalled();
});
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 mockRequestWithGetMockReturn = {
header: () => 'XMLHttpRequest',
cookies: {
'test-provider-refresh-token': 'old-token',
},
query: {},
get: jest.fn().mockReturnValue('http://other.domain'),
} as unknown as express.Request;
it('sets the correct cookie configuration using origin from header', async () => {
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);
await oauthProvider.refresh(mockRequestWithGetMockReturn, mockResponse);
expect(mockRequestWithGetMockReturn.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',
}),
expect.objectContaining(secureSameSiteNoneCookieData),
);
expect(mockResponse.redirect).not.toHaveBeenCalled();
});
it('executed a response redirect when isPopupAuthenticationRequest is false', async () => {
const handlers = {
start: jest.fn(async (_req: { state: OAuthState }) => ({
url: '/url',
status: 301,
})),
handler: jest.fn(async () => ({ response: mockResponseData })),
refresh: jest.fn(async () => ({ response: mockResponseData })),
};
const configWithNoPopupEnabled = {
...configOriginAllowed,
isPopupAuthenticationRequest: false,
};
const oauthProvider = OAuthAdapter.fromConfig(
configWithNoPopupEnabled,
handlers,
{
...oAuthProviderOptions,
callbackUrl: 'https://domain.org/auth/test-provider/handler/frame',
},
);
const state = {
...defaultState,
origin: 'http://other.domain',
redirectUrl: 'http://domain.org',
};
const mockRequest = {
...createEncodedQueryMockRequest(state),
get: jest.fn().mockReturnValue('http://other.domain'),
} as unknown as express.Request;
await oauthProvider.frameHandler(mockRequest, mockResponse);
expect(mockRequest.get).not.toHaveBeenCalled();
expect(mockResponse.end).not.toHaveBeenCalled();
expect(mockCookieConfigurer).not.toHaveBeenCalled();
expect(mockResponse.cookie).not.toHaveBeenCalled();
expect(mockResponse.redirect).toHaveBeenCalledTimes(1);
expect(mockResponse.redirect).toHaveBeenCalledWith('http://domain.org');
});
});
@@ -33,7 +33,12 @@ import {
NotAllowedError,
} from '@backstage/errors';
import { defaultCookieConfigurer, readState, verifyNonce } from './helpers';
import { postMessageResponse, ensuresXRequestedWith } from '../flow';
import {
postMessageResponse,
redirectMessageResponse,
ensuresXRequestedWith,
WebMessageResponse,
} from '../flow';
import {
OAuthHandlers,
OAuthStartRequest,
@@ -51,10 +56,12 @@ export type OAuthAdapterOptions = {
providerId: string;
persistScopes?: boolean;
appOrigin: string;
redirectUrl?: string;
baseUrl: string;
cookieConfigurer: CookieConfigurer;
isOriginAllowed: (origin: string) => boolean;
callbackUrl: string;
isPopupAuthenticationRequest: boolean;
};
/** @public */
@@ -64,10 +71,11 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
handlers: OAuthHandlers,
options: Pick<
OAuthAdapterOptions,
'providerId' | 'persistScopes' | 'callbackUrl'
'providerId' | 'persistScopes' | 'callbackUrl' | 'redirectUrl'
>,
): OAuthAdapter {
const { appUrl, baseUrl, isOriginAllowed } = config;
const { appUrl, baseUrl, isOriginAllowed, isPopupAuthenticationRequest } =
config;
const { origin: appOrigin } = new URL(appUrl);
const cookieConfigurer = config.cookieConfigurer ?? defaultCookieConfigurer;
@@ -78,6 +86,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
baseUrl,
cookieConfigurer,
isOriginAllowed,
isPopupAuthenticationRequest: isPopupAuthenticationRequest,
});
}
@@ -98,7 +107,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
const scope = req.query.scope?.toString() ?? '';
const env = req.query.env?.toString();
const origin = req.query.origin?.toString();
const redirectUrl = req.query.redirectUrl?.toString();
if (!env) {
throw new InputError('No env provided in request query parameters');
}
@@ -109,7 +118,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
// set a nonce cookie before redirecting to oauth provider
this.setNonceCookie(res, nonce, cookieConfig);
const state: OAuthState = { nonce, env, origin };
const state: OAuthState = { nonce, env, origin, redirectUrl };
// If scopes are persisted then we pass them through the state so that we
// can set the cookie on successful auth
@@ -136,6 +145,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
try {
const state: OAuthState = readState(req.query.state?.toString() ?? '');
const redirectUrl = state.redirectUrl ?? '';
if (state.origin) {
try {
@@ -169,11 +179,16 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
const identity = await this.populateIdentity(response.backstageIdentity);
// post message back to popup if successful
return postMessageResponse(res, appOrigin, {
const responseObj: WebMessageResponse = {
type: 'authorization_response',
response: { ...response, backstageIdentity: identity },
});
};
if (!this.options.isPopupAuthenticationRequest) {
return redirectMessageResponse(res, redirectUrl);
}
// post message back to popup if successful
return postMessageResponse(res, appOrigin, responseObj);
} catch (error) {
const { name, message } = isError(error)
? error
@@ -90,6 +90,7 @@ export type OAuthState = {
env: string;
origin?: string;
scope?: string;
redirectUrl?: string;
};
/** @public */
@@ -131,6 +131,8 @@ export type AuthProviderConfig = {
* The function used to resolve cookie configuration based on the auth provider options.
*/
cookieConfigurer?: CookieConfigurer;
isPopupAuthenticationRequest: boolean;
};
/** @public */
@@ -107,6 +107,9 @@ export async function createRouter(
...providerFactories,
};
const providersConfig = config.getConfig('auth.providers');
const isPopupAuthenticationRequest =
config.getOptionalBoolean('auth.usePopup') ?? true;
const configuredProviders = providersConfig.keys();
const isOriginAllowed = createOriginFilter(config);
@@ -123,6 +126,7 @@ export async function createRouter(
baseUrl: authUrl,
appUrl,
isOriginAllowed,
isPopupAuthenticationRequest: isPopupAuthenticationRequest,
},
config: providersConfig.getConfig(providerId),
logger,