Skip to content

Commit

Permalink
auth-backend: make it possible to tweak the cookie configuration logic
Browse files Browse the repository at this point in the history
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
  • Loading branch information
Rugvip committed Feb 4, 2022
1 parent d5b6653 commit 08fcda1
Show file tree
Hide file tree
Showing 10 changed files with 101 additions and 76 deletions.
5 changes: 5 additions & 0 deletions .changeset/breezy-windows-jump.md
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': minor
---

The `callbackUrl` option of `OAuthAdapter` is now required.
5 changes: 5 additions & 0 deletions .changeset/metal-lions-fix.md
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': patch
---

Added a new `cookieConfigurer` option to `createRouter` that makes it possible to override the default logic for configuring OAuth provider cookies.
15 changes: 14 additions & 1 deletion plugins/auth-backend/api-report.md
Expand Up @@ -214,6 +214,17 @@ export class CatalogIdentityClient {
resolveCatalogMembership(query: MemberClaimQuery): Promise<string[]>;
}

// @public
export type CookieConfigurer = (ctx: {
providerId: string;
baseUrl: string;
callbackUrl: string;
}) => {
domain: string;
path: string;
secure: boolean;
};

// Warning: (ae-missing-release-tag) "createAtlassianProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
Expand Down Expand Up @@ -665,6 +676,8 @@ export interface RouterOptions {
// (undocumented)
config: Config;
// (undocumented)
cookieConfigurer?: CookieConfigurer;
// (undocumented)
database: PluginDatabaseManager;
// (undocumented)
discovery: PluginEndpointDiscovery;
Expand Down Expand Up @@ -736,5 +749,5 @@ export type WebMessageResponse =
// src/identity/types.d.ts:31:9 - (ae-forgotten-export) The symbol "AnyJWK" needs to be exported by the entry point index.d.ts
// src/providers/aws-alb/provider.d.ts:77:5 - (ae-forgotten-export) The symbol "AwsAlbResult" needs to be exported by the entry point index.d.ts
// src/providers/github/provider.d.ts:97:5 - (ae-forgotten-export) The symbol "StateEncoder" needs to be exported by the entry point index.d.ts
// src/providers/types.d.ts:98:5 - (ae-forgotten-export) The symbol "AuthProviderConfig" needs to be exported by the entry point index.d.ts
// src/providers/types.d.ts:118:5 - (ae-forgotten-export) The symbol "AuthProviderConfig" needs to be exported by the entry point index.d.ts
```
44 changes: 2 additions & 42 deletions plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts
Expand Up @@ -69,13 +69,14 @@ describe('OAuthAdapter', () => {
secure: false,
disableRefresh: true,
appOrigin: 'http://localhost:3000',
cookieDomain: 'localhost',
cookieDomain: 'example.com',
cookiePath: '/auth/test-provider',
tokenIssuer: {
issueToken: async () => 'my-id-token',
listPublicKeys: async () => ({ keys: [] }),
},
isOriginAllowed: () => false,
callbackUrl: 'http://example.com:7007/auth/test-provider/frame/handler',
};

it('sets the correct headers in start', async () => {
Expand Down Expand Up @@ -444,47 +445,6 @@ describe('OAuthAdapter', () => {
});
});

it('sets the correct cookie configuration using the base url', async () => {
const config = {
baseUrl: 'http://domain.org/auth',
appUrl: 'http://domain.org',
isOriginAllowed: () => false,
};

const oauthProvider = OAuthAdapter.fromConfig(
config,
providerInstance,
oAuthProviderOptions,
);

const mockRequest = {
query: {
scope: 'user',
env: 'development',
},
} 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(mockResponse.cookie).toBeCalledTimes(1);
expect(mockResponse.cookie).toBeCalledWith(
`${oAuthProviderOptions.providerId}-nonce`,
expect.any(String),
expect.objectContaining({
domain: 'domain.org',
path: '/auth/test-provider/handler',
secure: false,
}),
);
});

it('sets the correct cookie configuration using a callbackUrl', async () => {
const config = {
baseUrl: 'http://domain.org/auth',
Expand Down
22 changes: 12 additions & 10 deletions plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts
Expand Up @@ -35,7 +35,7 @@ import {
NotAllowedError,
} from '@backstage/errors';
import { TokenIssuer } from '../../identity/types';
import { getCookieConfig, readState, verifyNonce } from './helpers';
import { defaultCookieConfigurer, readState, verifyNonce } from './helpers';
import { postMessageResponse, ensuresXRequestedWith } from '../flow';
import {
OAuthHandlers,
Expand All @@ -58,7 +58,7 @@ export type Options = {
appOrigin: string;
tokenIssuer: TokenIssuer;
isOriginAllowed: (origin: string) => boolean;
callbackUrl?: string;
callbackUrl: string;
};
export class OAuthAdapter implements AuthProviderRouteHandlers {
static fromConfig(
Expand All @@ -74,18 +74,20 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
>,
): OAuthAdapter {
const { origin: appOrigin } = new URL(config.appUrl);
const authUrl = new URL(options.callbackUrl ?? config.baseUrl);
const { cookieDomain, cookiePath, secure } = getCookieConfig(
authUrl,
options.providerId,
);

const cookieConfigurer = config.cookieConfigurer ?? defaultCookieConfigurer;
const cookieConfig = cookieConfigurer({
providerId: options.providerId,
baseUrl: config.baseUrl,
callbackUrl: options.callbackUrl,
});

return new OAuthAdapter(handlers, {
...options,
appOrigin,
cookieDomain,
cookiePath,
secure,
cookieDomain: cookieConfig.domain,
cookiePath: cookieConfig.path,
secure: cookieConfig.secure,
isOriginAllowed: config.isOriginAllowed,
});
}
Expand Down
41 changes: 27 additions & 14 deletions plugins/auth-backend/src/lib/oauth/helpers.test.ts
Expand Up @@ -19,7 +19,7 @@ import {
verifyNonce,
encodeState,
readState,
getCookieConfig,
defaultCookieConfigurer,
} from './helpers';

describe('OAuthProvider Utils', () => {
Expand Down Expand Up @@ -110,30 +110,43 @@ describe('OAuthProvider Utils', () => {
});
});

describe('getCookieConfig', () => {
describe('defaultCookieConfigurer', () => {
it('should set the correct domain and path for a base url', () => {
const mockAuthUrl = new URL('http://domain.org/auth');
expect(getCookieConfig(mockAuthUrl, 'test-provider')).toMatchObject({
cookieDomain: 'domain.org',
cookiePath: '/auth/test-provider',
expect(
defaultCookieConfigurer({
baseUrl: '',
providerId: 'test-provider',
callbackUrl: 'http://domain.org/auth',
}),
).toMatchObject({
domain: 'domain.org',
path: '/auth/test-provider',
secure: false,
});
});

it('should set the correct domain and path for a url containing a frame handler', () => {
const mockAuthUrl = new URL(
'http://domain.org/auth/test-provider/handler/frame',
);
expect(getCookieConfig(mockAuthUrl, 'test-provider')).toMatchObject({
cookieDomain: 'domain.org',
cookiePath: '/auth/test-provider',
expect(
defaultCookieConfigurer({
baseUrl: '',
providerId: 'test-provider',
callbackUrl: 'http://domain.org/auth/test-provider/handler/frame',
}),
).toMatchObject({
domain: 'domain.org',
path: '/auth/test-provider',
secure: false,
});
});

it('should set the secure flag if url is using https', () => {
const mockAuthUrl = new URL('https://domain.org/auth');
expect(getCookieConfig(mockAuthUrl, 'test-provider')).toMatchObject({
expect(
defaultCookieConfigurer({
baseUrl: '',
providerId: 'test-provider',
callbackUrl: 'https://domain.org/auth',
}),
).toMatchObject({
secure: true,
});
});
Expand Down
16 changes: 8 additions & 8 deletions plugins/auth-backend/src/lib/oauth/helpers.ts
Expand Up @@ -17,6 +17,7 @@
import express from 'express';
import { OAuthState } from './types';
import pickBy from 'lodash/pickBy';
import { CookieConfigurer } from '../../providers/types';

export const readState = (stateString: string): OAuthState => {
const state = Object.fromEntries(
Expand Down Expand Up @@ -58,20 +59,19 @@ export const verifyNonce = (req: express.Request, providerId: string) => {
}
};

export const getCookieConfig = (authUrl: URL, providerId: string) => {
const { hostname: cookieDomain, pathname, protocol } = authUrl;
export const defaultCookieConfigurer: CookieConfigurer = ({
callbackUrl,
providerId,
}) => {
const { hostname: domain, pathname, protocol } = new URL(callbackUrl);
const secure = protocol === 'https:';

// 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.
const cookiePath = pathname.endsWith(`${providerId}/handler/frame`)
const path = pathname.endsWith(`${providerId}/handler/frame`)
? pathname.slice(0, -'/handler/frame'.length)
: `${pathname}/${providerId}`;

return {
cookieDomain,
cookiePath,
secure,
};
return { domain, path, secure };
};
1 change: 1 addition & 0 deletions plugins/auth-backend/src/providers/index.ts
Expand Up @@ -43,6 +43,7 @@ export type {
AuthHandlerResult,
SignInResolver,
SignInInfo,
CookieConfigurer,
} from './types';

// These types are needed for a postMessage from the login pop-up
Expand Down
18 changes: 18 additions & 0 deletions plugins/auth-backend/src/providers/types.ts
Expand Up @@ -38,6 +38,19 @@ export type AuthResolverContext = {
logger: Logger;
};

/**
* The callback used to resolve the cookie configuration for auth providers that use cookies.
* @public
*/
export type CookieConfigurer = (ctx: {
/** ID of the auth provider that this configuration applies to */
providerId: string;
/** The externally reachable base URL of the auth-backend plugin */
baseUrl: string;
/** The configured callback URL of the auth provider */
callbackUrl: string;
}) => { domain: string; path: string; secure: boolean };

export type AuthProviderConfig = {
/**
* The protocol://domain[:port] where the app is hosted. This is used to construct the
Expand All @@ -54,6 +67,11 @@ export type AuthProviderConfig = {
* A function that is called to check whether an origin is allowed to receive the authentication result.
*/
isOriginAllowed: (origin: string) => boolean;

/**
* The function used to resolve cookie configuration based on the auth provider options.
*/
cookieConfigurer?: CookieConfigurer;
};

export type RedirectInfo = {
Expand Down
10 changes: 9 additions & 1 deletion plugins/auth-backend/src/service/router.ts
Expand Up @@ -34,6 +34,7 @@ import { createOidcRouter, TokenFactory, KeyStores } from '../identity';
import session from 'express-session';
import passport from 'passport';
import { Minimatch } from 'minimatch';
import { CookieConfigurer } from '../providers/types';

type ProviderFactories = { [s: string]: AuthProviderFactory };

Expand All @@ -44,6 +45,7 @@ export interface RouterOptions {
discovery: PluginEndpointDiscovery;
tokenManager: TokenManager;
providerFactories?: ProviderFactories;
cookieConfigurer?: CookieConfigurer;
}

export async function createRouter(
Expand All @@ -56,6 +58,7 @@ export async function createRouter(
database,
tokenManager,
providerFactories,
cookieConfigurer,
} = options;
const router = Router();

Expand Down Expand Up @@ -111,7 +114,12 @@ export async function createRouter(
try {
const provider = providerFactory({
providerId,
globalConfig: { baseUrl: authUrl, appUrl, isOriginAllowed },
globalConfig: {
baseUrl: authUrl,
appUrl,
isOriginAllowed,
cookieConfigurer,
},
config: providersConfig.getConfig(providerId),
logger,
tokenManager,
Expand Down

0 comments on commit 08fcda1

Please sign in to comment.