Merge pull request #2268 from spotify/rugvip/authref

auth-backend: refactor to split lib, move types closer to home, update docs, renaming things
This commit is contained in:
Patrik Oldsberg
2020-09-04 09:59:58 +02:00
committed by GitHub
28 changed files with 1018 additions and 872 deletions
+3 -4
View File
@@ -120,10 +120,9 @@ auth:
audience:
$secret:
env: GITLAB_BASE_URL
# saml:
# development:
# entryPoint: "http://localhost:7001/"
# issuer: "passport-saml"
saml:
entryPoint: "http://localhost:7001/"
issuer: "passport-saml"
okta:
development:
clientId:
+104 -53
View File
@@ -48,22 +48,35 @@ provider class which implements a handler for the chosen framework.
#### Adding an OAuth based provider
If we're adding an `OAuth` based provider we would implement the
[OAuthProviderHandlers](#OAuthProviderHandlers) interface.
[OAuthProviderHandlers](#OAuthProviderHandlers) interface. By implementing this
interface we can use the `OAuthProvider` class provided by `lib/oauth`, meaning
we don't need to implement the full
[AuthProviderRouteHandlers](#AuthProviderRouteHandlers) interface that providers
otherwise need to implement.
The provider class takes the provider's configuration as a class parameter. It
also imports the `Strategy` from the passport package.
The provider class takes the provider's options as a class parameter. It also
imports the `Strategy` from the passport package.
```ts
import { Strategy as ProviderAStrategy } from 'passport-provider-a';
export type ProviderAProviderOptions = OAuthProviderOptions & {
// extra options here
}
export class ProviderAAuthProvider implements OAuthProviderHandlers {
private readonly providerConfig: AuthProviderConfig;
private readonly _strategy: ProviderAStrategy;
constructor(providerConfig: AuthProviderConfig) {
this.providerConfig = providerConfig;
constructor(options: ProviderAProviderOptions) {
this._strategy = new ProviderAStrategy(
{ ...providerConfig.options },
{
clientID: options.clientId,
clientSecret: options.clientSecret,
callbackURL: options.callbackUrl,
passReqToCallback: false as true,
response_type: 'code',
/// ... etc
}
verifyFunction, // See the "Verify Callback" section
);
}
@@ -82,14 +95,18 @@ An non-`OAuth` based provider could implement
[AuthProviderRouteHandlers](#AuthProviderRouteHandlers) instead.
```ts
type ProviderAOptions = {
// ...
};
export class ProviderAAuthProvider implements AuthProviderRouteHandlers {
private readonly providerConfig: AuthProviderConfig;
private readonly _strategy: ProviderAStrategy;
constructor(providerConfig: AuthProviderConfig) {
this.providerConfig = providerConfig;
constructor(options: ProviderAOptions) {
this._strategy = new ProviderAStrategy(
{ ...providerConfig.options },
{
// ...
},
verifyFunction, // See the "Verify Callback" section
);
}
@@ -101,31 +118,61 @@ export class ProviderAAuthProvider implements AuthProviderRouteHandlers {
}
```
#### Create method
#### Factory function
Each provider exports a create method that creates the provider instance,
optionally extending a supported authorization framework. This method exists to
allow for flexibility if additional frameworks are supported in the future.
Each provider exports a factory function that instantiates the provider. The
factory should implement [AuthProviderFactory](#AuthProviderFactory), which
passes in a object with utilities for configuration, logging, token issuing,
etc. The factory should return an implementation of
[AuthProviderRouteHandlers](#AuthProviderRouteHandlers).
Implementing OAuth by returning an instance of `OAuthProvider` based of the
provider's class:
The factory is what decides the mapping from
[static configuration](../conf/index.md) to the creation of auth providers. For
example, OAuth providers use `OAuthEnvironmentHandler` to allow for multiple
different configurations, one for each environment, which looks like this;
```ts
export function createProviderAProvider(config: AuthProviderConfig) {
const provider = new ProviderAAuthProvider(config);
const oauthProvider = new OAuthProvider(provider, config.provider, true);
return oauthProvider;
}
export const createOktaProvider: AuthProviderFactory = ({
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
// read options from config
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
// instantiate our OAuthProviderHandlers implementation
const provider = new OktaAuthProvider({
audience,
clientId,
clientSecret,
callbackUrl,
});
// Wrap the OAuthProviderHandlers with OAuthProvider, which implements AuthProviderRouteHandlers
return OAuthProvider.fromConfig(globalConfig, provider, {
disableRefresh: false,
providerId,
tokenIssuer,
});
});
```
Not extending with OAuth, the main difference here is that the create method is
returning a instance of the class without adding the OAuth authorization
framework to it.
The purpose of the different environments is to allow for a single auth-backend
to serve as the authentication service for multiple different frontend
environments, such as local development, staging, and production.
The factory function for other providers can be a lot simpler, as they might not
have configuration for each environment. Looking something like this:
```ts
export function createProviderAProvider(config: AuthProviderConfig) {
return new ProviderAAuthProvider(config);
}
export const createProviderAProvider: AuthProviderFactory = ({ config }) => {
const a = config.getString('a');
const b = config.getString('b');
return new ProviderAAuthProvider({ a, b });
};
```
#### Verify Callback
@@ -144,7 +191,7 @@ export function createProviderAProvider(config: AuthProviderConfig) {
> http://www.passportjs.org/docs/configure/
**`plugins/auth-backend/src/providers/providerA/index.ts`** is simply
re-exporting the create method to be used for hooking the provider up to the
re-exporting the factory function to be used for hooking the provider up to the
backend.
```ts
@@ -153,26 +200,14 @@ export { createProviderAProvider } from './provider';
### Hook it up to the backend
**`plugins/auth-backend/src/providers/config.ts`** The provider needs to be
configured properly so you need to add it to the list of configured providers,
all of which implement [AuthProviderConfig](#AuthProviderConfig):
```ts
export const providers = [
{
provider: 'providerA', # used as an identifier
options: { ... }, # consult the provider documentation for which options you should provide
disableRefresh: true # if the provider lacks refresh tokens
},
```
**`plugins/auth-backend/src/providers/factories.ts`** When the `auth-backend`
starts it sets up routing for all the available providers by calling
`createAuthProviderRouter` on each provider. You need to import the create
method from the provider and add it to the factory:
`createAuthProviderRouter` on each provider. You need to import the factory
function from the provider and add it to the factory:
```ts
import { createProviderAProvider } from './providerA';
const factories: { [providerId: string]: AuthProviderFactory } = {
providerA: createProviderAProvider,
};
@@ -203,10 +238,21 @@ web browser and you should be able to trigger the authorization flow.
```ts
export interface OAuthProviderHandlers {
start(req: express.Request, options: any): Promise<any>;
handler(req: express.Request): Promise<any>;
refresh?(refreshToken: string, scope: string): Promise<any>;
logout?(): Promise<any>;
start(
req: express.Request,
options: Record<string, string>,
): Promise<RedirectInfo>;
handler(
req: express.Request,
): Promise<{
response: AuthResponse<OAuthProviderInfo>;
refreshToken?: string;
}>;
refresh?(
refreshToken: string,
scope: string,
): Promise<AuthResponse<OAuthProviderInfo>>;
logout?(): Promise<void>;
}
```
@@ -221,12 +267,17 @@ export interface AuthProviderRouteHandlers {
}
```
##### AuthProviderConfig
##### AuthProviderFactory
```ts
export type AuthProviderConfig = {
provider: string;
options: any;
disableRefresh?: boolean;
export type AuthProviderFactoryOptions = {
globalConfig: AuthProviderConfig;
config: Config;
logger: Logger;
tokenIssuer: TokenIssuer;
};
export type AuthProviderFactory = (
options: AuthProviderFactoryOptions,
) => AuthProviderRouteHandlers;
```
+27 -22
View File
@@ -26,7 +26,7 @@ refer to the type documentation under
`plugins/auth-backend/src/providers/types.ts`.
There are currently two different classes for two authentication mechanisms that
implement this interface: an `OAuthProvider` for [OAuth](https://oauth.net/2/)
implement this interface: an `OAuthAdapter` for [OAuth](https://oauth.net/2/)
based mechanisms and a `SAMLAuthProvider` for
[SAML](http://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-tech-overview-2.0.html)
based mechanisms.
@@ -50,11 +50,12 @@ OAuth2) providers, you can configure them by setting the right variables in
Each authentication provider (except SAML) needs five parameters: an OAuth
client ID, a client secret, an authorization endpoint and a token endpoint, and
an app origin. The app origin is the URL at which the frontend of the
application is hosted. This is required because the application opens a popup
window to perform the authentication, and once the flow is completed, the popup
window sends a `postMessage` to the frontend application to indicate the result
of the operation. Also this URL is used to verify that authentication requests
are coming from only this endpoint.
application is hosted, and it is read from the `app.baseUrl` config. This is
required because the application opens a popup window to perform the
authentication, and once the flow is completed, the popup window sends a
`postMessage` to the frontend application to indicate the result of the
operation. Also this URL is used to verify that authentication requests are
coming from only this endpoint.
These values are configured via the `app-config.yaml` present in the root of
your app folder.
@@ -64,8 +65,6 @@ auth:
providers:
google:
development:
appOrigin: "http://localhost:3000/"
secure: false
clientId:
$secret:
env: AUTH_GOOGLE_CLIENT_ID
@@ -74,8 +73,6 @@ auth:
env: AUTH_GOOGLE_CLIENT_SECRET
github:
development:
appOrigin: "http://localhost:3000/"
secure: false
clientId:
$secret:
env: AUTH_GITHUB_CLIENT_ID
@@ -87,8 +84,6 @@ auth:
env: AUTH_GITHUB_ENTERPRISE_INSTANCE_URL
gitlab:
development:
appOrigin: "http://localhost:3000/"
secure: false
clientId:
$secret:
...
@@ -96,24 +91,34 @@ auth:
## Technical Notes
### EnvironmentHandler
### OAuthEnvironmentHandler
The concept of an "env" is core to the way the auth backend works. It uses an
`env` query parameter to identify the environment in which the application is
running (`development`, `staging`, `production`, etc). Each runtime can support
multiple environments at the same time and the right handler for each request is
identified and dispatched to based on the `env` parameter. All
`AuthProviderRouteHandlers` are wrapped within an `EnvironmentHandler`.
`AuthProviderRouteHandlers` are wrapped within an `OAuthEnvironmentHandler`.
An `EnvironmentHandler` takes an ID for each provider that it wraps, the
handlers for each env the provider is supported in, and a function that, given a
`Request` as argument, can extract the information about the env under which it
should be processed.
To instantiate multiple OAuth providers for different environments, use
`OAuthEnvironmentHandler.mapConfig`. It's a helper to iterate over a
configuration object that is a map of environment to configurations. See one of
the existing OAuth providers for an example of how it is used.
Each provider exposes a factory function `createXProvider` (where X is the name
of the provider) that takes the global config, env and other parameters and
returns an `AuthProviderRouteHandlers` for each env, and an `envIdentifier`
function to identify the env of a request.
Given the following configuration:
```yaml
development:
clientId: abc
clientSecret: secret
production:
clientId: xyz
clientSecret: supersecret
```
The `OAuthEnvironmentHandler.mapConfig(config, envConfig => ...)` call will
split the `config` by the top level `development` and `production` keys, and
pass on each block as `envConfig`.
For a list of currently available providers, look in the `factories` module
located in `plugins/auth-backend/src/providers/factories.ts`
@@ -0,0 +1,103 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import express from 'express';
import { ensuresXRequestedWith, postMessageResponse } from './authFlowHelpers';
import { WebMessageResponse } from './types';
describe('oauth helpers', () => {
describe('postMessageResponse', () => {
const appOrigin = 'http://localhost:3000';
it('should post a message back with payload success', () => {
const mockResponse = ({
end: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
const data: WebMessageResponse = {
type: 'authorization_response',
response: {
providerInfo: {
accessToken: 'ACCESS_TOKEN',
idToken: 'ID_TOKEN',
expiresInSeconds: 10,
scope: 'email',
},
profile: {
email: 'foo@bar.com',
},
backstageIdentity: {
id: 'a',
idToken: 'a.b.c',
},
},
};
const jsonData = JSON.stringify(data);
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
postMessageResponse(mockResponse, appOrigin, data);
expect(mockResponse.setHeader).toBeCalledTimes(3);
expect(mockResponse.end).toBeCalledTimes(1);
expect(mockResponse.end).toBeCalledWith(
expect.stringContaining(base64Data),
);
});
it('should post a message back with payload error', () => {
const mockResponse = ({
end: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
const data: WebMessageResponse = {
type: 'authorization_response',
error: new Error('Unknown error occured'),
};
const jsonData = JSON.stringify(data);
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
postMessageResponse(mockResponse, appOrigin, data);
expect(mockResponse.setHeader).toBeCalledTimes(3);
expect(mockResponse.end).toBeCalledTimes(1);
expect(mockResponse.end).toBeCalledWith(
expect.stringContaining(base64Data),
);
});
});
describe('ensuresXRequestedWith', () => {
it('should return false if no header present', () => {
const mockRequest = ({
header: () => jest.fn(),
} as unknown) as express.Request;
expect(ensuresXRequestedWith(mockRequest)).toBe(false);
});
it('should return false if header present with incorrect value', () => {
const mockRequest = ({
header: () => 'INVALID',
} as unknown) as express.Request;
expect(ensuresXRequestedWith(mockRequest)).toBe(false);
});
it('should return true if header present with correct value', () => {
const mockRequest = ({
header: () => 'XMLHttpRequest',
} as unknown) as express.Request;
expect(ensuresXRequestedWith(mockRequest)).toBe(true);
});
});
});
@@ -0,0 +1,56 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import express from 'express';
import crypto from 'crypto';
import { WebMessageResponse } from './types';
export const postMessageResponse = (
res: express.Response,
appOrigin: string,
response: WebMessageResponse,
) => {
const jsonData = JSON.stringify(response);
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
res.setHeader('Content-Type', 'text/html');
res.setHeader('X-Frame-Options', 'sameorigin');
// TODO: Make target app origin configurable globally
const script = `
(window.opener || window.parent).postMessage(JSON.parse(atob('${base64Data}')), '${appOrigin}')
window.close()
`;
const hash = crypto.createHash('sha256').update(script).digest('base64');
res.setHeader('Content-Security-Policy', `script-src 'sha256-${hash}'`);
res.end(`
<html>
<body>
<script>${script}</script>
</body>
</html>
`);
};
export const ensuresXRequestedWith = (req: express.Request) => {
const requiredHeader = req.header('X-Requested-With');
if (!requiredHeader || requiredHeader !== 'XMLHttpRequest') {
return false;
}
return true;
};
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { ensuresXRequestedWith, postMessageResponse } from './authFlowHelpers';
@@ -0,0 +1,31 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AuthResponse } from '../../providers/types';
/**
* Payload sent as a post message after the auth request is complete.
* If successful then has a valid payload with Auth information else contains an error.
*/
export type WebMessageResponse =
| {
type: 'authorization_response';
response: AuthResponse<unknown>;
}
| {
type: 'authorization_response';
error: Error;
};
@@ -15,16 +15,9 @@
*/
import express from 'express';
import {
ensuresXRequestedWith,
postMessageResponse,
THOUSAND_DAYS_MS,
TEN_MINUTES_MS,
verifyNonce,
encodeState,
OAuthProvider,
} from './OAuthProvider';
import { WebMessageResponse, OAuthProviderHandlers } from '../providers/types';
import { THOUSAND_DAYS_MS, TEN_MINUTES_MS, OAuthAdapter } from './OAuthAdapter';
import { encodeState } from './helpers';
import { OAuthHandlers } from './types';
const mockResponseData = {
providerInfo: {
@@ -41,149 +34,8 @@ const mockResponseData = {
},
};
describe('OAuthProvider Utils', () => {
describe('verifyNonce', () => {
it('should throw error if cookie nonce missing', () => {
const state = { nonce: 'NONCE', env: 'development' };
const mockRequest = ({
cookies: {},
query: {
state: encodeState(state),
},
} as unknown) as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).toThrowError('Auth response is missing cookie nonce');
});
it('should throw error if state nonce missing', () => {
const mockRequest = ({
cookies: {
'providera-nonce': 'NONCE',
},
query: {},
} as unknown) as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).toThrowError('Invalid state passed via request');
});
it('should throw error if nonce mismatch', () => {
const state = { nonce: 'NONCEB', env: 'development' };
const mockRequest = ({
cookies: {
'providera-nonce': 'NONCEA',
},
query: {
state: encodeState(state),
},
} as unknown) as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).toThrowError('Invalid nonce');
});
it('should not throw any error if nonce matches', () => {
const state = { nonce: 'NONCE', env: 'development' };
const mockRequest = ({
cookies: {
'providera-nonce': 'NONCE',
},
query: {
state: encodeState(state),
},
} as unknown) as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).not.toThrow();
});
});
describe('postMessageResponse', () => {
const appOrigin = 'http://localhost:3000';
it('should post a message back with payload success', () => {
const mockResponse = ({
end: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
const data: WebMessageResponse = {
type: 'authorization_response',
response: {
providerInfo: {
accessToken: 'ACCESS_TOKEN',
idToken: 'ID_TOKEN',
expiresInSeconds: 10,
scope: 'email',
},
profile: {
email: 'foo@bar.com',
},
backstageIdentity: {
id: 'a',
idToken: 'a.b.c',
},
},
};
const jsonData = JSON.stringify(data);
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
postMessageResponse(mockResponse, appOrigin, data);
expect(mockResponse.setHeader).toBeCalledTimes(3);
expect(mockResponse.end).toBeCalledTimes(1);
expect(mockResponse.end).toBeCalledWith(
expect.stringContaining(base64Data),
);
});
it('should post a message back with payload error', () => {
const mockResponse = ({
end: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
const data: WebMessageResponse = {
type: 'authorization_response',
error: new Error('Unknown error occured'),
};
const jsonData = JSON.stringify(data);
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
postMessageResponse(mockResponse, appOrigin, data);
expect(mockResponse.setHeader).toBeCalledTimes(3);
expect(mockResponse.end).toBeCalledTimes(1);
expect(mockResponse.end).toBeCalledWith(
expect.stringContaining(base64Data),
);
});
});
describe('ensuresXRequestedWith', () => {
it('should return false if no header present', () => {
const mockRequest = ({
header: () => jest.fn(),
} as unknown) as express.Request;
expect(ensuresXRequestedWith(mockRequest)).toBe(false);
});
it('should return false if header present with incorrect value', () => {
const mockRequest = ({
header: () => 'INVALID',
} as unknown) as express.Request;
expect(ensuresXRequestedWith(mockRequest)).toBe(false);
});
it('should return true if header present with correct value', () => {
const mockRequest = ({
header: () => 'XMLHttpRequest',
} as unknown) as express.Request;
expect(ensuresXRequestedWith(mockRequest)).toBe(true);
});
});
});
describe('OAuthProvider', () => {
class MyAuthProvider implements OAuthProviderHandlers {
describe('OAuthAdapter', () => {
class MyAuthProvider implements OAuthHandlers {
async start() {
return {
url: '/url',
@@ -215,7 +67,7 @@ describe('OAuthProvider', () => {
};
it('sets the correct headers in start', async () => {
const oauthProvider = new OAuthProvider(
const oauthProvider = new OAuthAdapter(
providerInstance,
oAuthProviderOptions,
);
@@ -250,7 +102,7 @@ describe('OAuthProvider', () => {
});
it('sets the refresh cookie if refresh is enabled', async () => {
const oauthProvider = new OAuthProvider(providerInstance, {
const oauthProvider = new OAuthAdapter(providerInstance, {
...oAuthProviderOptions,
disableRefresh: false,
});
@@ -284,7 +136,7 @@ describe('OAuthProvider', () => {
});
it('does not set the refresh cookie if refresh is disabled', async () => {
const oauthProvider = new OAuthProvider(providerInstance, {
const oauthProvider = new OAuthAdapter(providerInstance, {
...oAuthProviderOptions,
disableRefresh: true,
});
@@ -309,7 +161,7 @@ describe('OAuthProvider', () => {
});
it('removes refresh cookie when logging out', async () => {
const oauthProvider = new OAuthProvider(providerInstance, {
const oauthProvider = new OAuthAdapter(providerInstance, {
...oAuthProviderOptions,
disableRefresh: false,
});
@@ -334,7 +186,7 @@ describe('OAuthProvider', () => {
it('gets new access-token when refreshing', async () => {
oAuthProviderOptions.disableRefresh = false;
const oauthProvider = new OAuthProvider(providerInstance, {
const oauthProvider = new OAuthAdapter(providerInstance, {
...oAuthProviderOptions,
disableRefresh: false,
});
@@ -363,7 +215,7 @@ describe('OAuthProvider', () => {
});
it('handles refresh without capabilities', async () => {
const oauthProvider = new OAuthProvider(providerInstance, {
const oauthProvider = new OAuthAdapter(providerInstance, {
...oAuthProviderOptions,
disableRefresh: true,
});
@@ -19,14 +19,14 @@ import crypto from 'crypto';
import { URL } from 'url';
import {
AuthProviderRouteHandlers,
OAuthProviderHandlers,
WebMessageResponse,
BackstageIdentity,
OAuthState,
AuthProviderConfig,
} from '../providers/types';
} from '../../providers/types';
import { InputError } from '@backstage/backend-common';
import { TokenIssuer } from '../identity';
import { TokenIssuer } from '../../identity';
import { verifyNonce, encodeState } from './helpers';
import { postMessageResponse, ensuresXRequestedWith } from '../flow';
import { OAuthHandlers } from './types';
export const THOUSAND_DAYS_MS = 1000 * 24 * 60 * 60 * 1000;
export const TEN_MINUTES_MS = 600 * 1000;
@@ -42,99 +42,20 @@ export type Options = {
tokenIssuer: TokenIssuer;
};
const readState = (stateString: string): OAuthState => {
const state = Object.fromEntries(
new URLSearchParams(decodeURIComponent(stateString)),
);
if (
!state.nonce ||
!state.env ||
state.nonce?.length === 0 ||
state.env?.length === 0
) {
throw Error(`Invalid state passed via request`);
}
return {
nonce: state.nonce,
env: state.env,
};
};
export const encodeState = (state: OAuthState): string => {
const searchParams = new URLSearchParams();
searchParams.append('nonce', state.nonce);
searchParams.append('env', state.env);
return encodeURIComponent(searchParams.toString());
};
export const verifyNonce = (req: express.Request, providerId: string) => {
const cookieNonce = req.cookies[`${providerId}-nonce`];
const state: OAuthState = readState(req.query.state?.toString() ?? '');
const stateNonce = state.nonce;
if (!cookieNonce) {
throw new Error('Auth response is missing cookie nonce');
}
if (stateNonce.length === 0) {
throw new Error('Auth response is missing state nonce');
}
if (cookieNonce !== stateNonce) {
throw new Error('Invalid nonce');
}
};
export const postMessageResponse = (
res: express.Response,
appOrigin: string,
response: WebMessageResponse,
) => {
const jsonData = JSON.stringify(response);
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
res.setHeader('Content-Type', 'text/html');
res.setHeader('X-Frame-Options', 'sameorigin');
// TODO: Make target app origin configurable globally
const script = `
(window.opener || window.parent).postMessage(JSON.parse(atob('${base64Data}')), '${appOrigin}')
window.close()
`;
const hash = crypto.createHash('sha256').update(script).digest('base64');
res.setHeader('Content-Security-Policy', `script-src 'sha256-${hash}'`);
res.end(`
<html>
<body>
<script>${script}</script>
</body>
</html>
`);
};
export const ensuresXRequestedWith = (req: express.Request) => {
const requiredHeader = req.header('X-Requested-With');
if (!requiredHeader || requiredHeader !== 'XMLHttpRequest') {
return false;
}
return true;
};
export class OAuthProvider implements AuthProviderRouteHandlers {
export class OAuthAdapter implements AuthProviderRouteHandlers {
static fromConfig(
config: AuthProviderConfig,
providerHandlers: OAuthProviderHandlers,
handlers: OAuthHandlers,
options: Pick<
Options,
'providerId' | 'persistScopes' | 'disableRefresh' | 'tokenIssuer'
>,
): OAuthProvider {
): OAuthAdapter {
const { origin: appOrigin } = new URL(config.appUrl);
const secure = config.baseUrl.startsWith('https://');
const url = new URL(config.baseUrl);
const cookiePath = `${url.pathname}/${options.providerId}`;
return new OAuthProvider(providerHandlers, {
return new OAuthAdapter(handlers, {
...options,
appOrigin,
cookieDomain: url.hostname,
@@ -144,7 +65,7 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
}
constructor(
private readonly providerHandlers: OAuthProviderHandlers,
private readonly handlers: OAuthHandlers,
private readonly options: Options,
) {}
@@ -173,10 +94,7 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
state: stateParameter,
};
const { url, status } = await this.providerHandlers.start(
req,
queryParameters,
);
const { url, status } = await this.handlers.start(req, queryParameters);
res.statusCode = status || 302;
res.setHeader('Location', url);
@@ -192,9 +110,7 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
// verify nonce cookie and state cookie on callback
verifyNonce(req, this.options.providerId);
const { response, refreshToken } = await this.providerHandlers.handler(
req,
);
const { response, refreshToken } = await this.handlers.handler(req);
if (this.options.persistScopes) {
const grantedScopes = this.getScopesFromCookie(
@@ -251,7 +167,7 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
return;
}
if (!this.providerHandlers.refresh || this.options.disableRefresh) {
if (!this.handlers.refresh || this.options.disableRefresh) {
res.send(
`Refresh token not supported for provider: ${this.options.providerId}`,
);
@@ -270,7 +186,7 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
const scope = req.query.scope?.toString() ?? '';
// get new access_token
const response = await this.providerHandlers.refresh(refreshToken, scope);
const response = await this.handlers.refresh(refreshToken, scope);
await this.populateIdentity(response.backstageIdentity);
@@ -287,19 +203,6 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
}
}
identifyEnv(req: express.Request): string | undefined {
const reqEnv = req.query.env?.toString();
if (reqEnv) {
return reqEnv;
}
const stateParams = req.query.state?.toString();
if (!stateParams) {
return undefined;
}
const env = readState(stateParams).env;
return env;
}
/**
* If the response from the OAuth provider includes a Backstage identity, we
* make sure it's populated with all the information we can derive from the user ID.
@@ -15,46 +15,32 @@
*/
import express from 'express';
import {
AuthProviderRouteHandlers,
EnvironmentIdentifierFn,
} from '../providers/types';
import { Config } from '@backstage/config';
import { InputError } from '@backstage/backend-common';
import { readState } from './helpers';
import { AuthProviderRouteHandlers } from '../../providers/types';
export type EnvironmentHandlers = {
[key: string]: AuthProviderRouteHandlers;
};
export class OAuthEnvironmentHandler implements AuthProviderRouteHandlers {
static mapConfig(
config: Config,
factoryFunc: (envConfig: Config) => AuthProviderRouteHandlers,
) {
const envs = config.keys();
const handlers = new Map<string, AuthProviderRouteHandlers>();
export class EnvironmentHandler implements AuthProviderRouteHandlers {
constructor(
private readonly providerId: string,
private readonly providers: EnvironmentHandlers,
private readonly envIdentifier: EnvironmentIdentifierFn,
) {}
private getProviderForEnv(
req: express.Request,
res: express.Response,
): AuthProviderRouteHandlers | undefined {
const env: string | undefined = this.envIdentifier(req);
if (!env) {
throw new InputError(`Must specify 'env' query to select environment`);
for (const env of envs) {
const envConfig = config.getConfig(env);
const handler = factoryFunc(envConfig);
handlers.set(env, handler);
}
if (this.providers.hasOwnProperty(env)) {
return this.providers[env];
}
res.status(404).send(
`Missing configuration.
<br>
<br>
For this flow to work you need to supply a valid configuration for the "${env}" environment of the "${this.providerId}" provider.`,
);
return undefined;
return new OAuthEnvironmentHandler(handlers);
}
constructor(
private readonly handlers: Map<string, AuthProviderRouteHandlers>,
) {}
async start(req: express.Request, res: express.Response): Promise<void> {
const provider = this.getProviderForEnv(req, res);
await provider?.start(req, res);
@@ -77,4 +63,40 @@ For this flow to work you need to supply a valid configuration for the "${env}"
const provider = this.getProviderForEnv(req, res);
await provider?.logout?.(req, res);
}
private getRequestFromEnv(req: express.Request): string | undefined {
const reqEnv = req.query.env?.toString();
if (reqEnv) {
return reqEnv;
}
const stateParams = req.query.state?.toString();
if (!stateParams) {
return undefined;
}
const env = readState(stateParams).env;
return env;
}
private getProviderForEnv(
req: express.Request,
res: express.Response,
): AuthProviderRouteHandlers | undefined {
const env: string | undefined = this.getRequestFromEnv(req);
if (!env) {
throw new InputError(`Must specify 'env' query to select environment`);
}
if (!this.handlers.has(env)) {
res.status(404).send(
`Missing configuration.
<br>
<br>
For this flow to work you need to supply a valid configuration for the "${env}" environment of provider.`,
);
return undefined;
}
return this.handlers.get(env);
}
}
@@ -0,0 +1,77 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import express from 'express';
import { verifyNonce, encodeState } from './helpers';
describe('OAuthProvider Utils', () => {
describe('verifyNonce', () => {
it('should throw error if cookie nonce missing', () => {
const state = { nonce: 'NONCE', env: 'development' };
const mockRequest = ({
cookies: {},
query: {
state: encodeState(state),
},
} as unknown) as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).toThrowError('Auth response is missing cookie nonce');
});
it('should throw error if state nonce missing', () => {
const mockRequest = ({
cookies: {
'providera-nonce': 'NONCE',
},
query: {},
} as unknown) as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).toThrowError('Invalid state passed via request');
});
it('should throw error if nonce mismatch', () => {
const state = { nonce: 'NONCEB', env: 'development' };
const mockRequest = ({
cookies: {
'providera-nonce': 'NONCEA',
},
query: {
state: encodeState(state),
},
} as unknown) as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).toThrowError('Invalid nonce');
});
it('should not throw any error if nonce matches', () => {
const state = { nonce: 'NONCE', env: 'development' };
const mockRequest = ({
cookies: {
'providera-nonce': 'NONCE',
},
query: {
state: encodeState(state),
},
} as unknown) as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).not.toThrow();
});
});
});
@@ -0,0 +1,60 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import express from 'express';
import { OAuthState } from './types';
export const readState = (stateString: string): OAuthState => {
const state = Object.fromEntries(
new URLSearchParams(decodeURIComponent(stateString)),
);
if (
!state.nonce ||
!state.env ||
state.nonce?.length === 0 ||
state.env?.length === 0
) {
throw Error(`Invalid state passed via request`);
}
return {
nonce: state.nonce,
env: state.env,
};
};
export const encodeState = (state: OAuthState): string => {
const searchParams = new URLSearchParams();
searchParams.append('nonce', state.nonce);
searchParams.append('env', state.env);
return encodeURIComponent(searchParams.toString());
};
export const verifyNonce = (req: express.Request, providerId: string) => {
const cookieNonce = req.cookies[`${providerId}-nonce`];
const state: OAuthState = readState(req.query.state?.toString() ?? '');
const stateNonce = state.nonce;
if (!cookieNonce) {
throw new Error('Auth response is missing cookie nonce');
}
if (stateNonce.length === 0) {
throw new Error('Auth response is missing state nonce');
}
if (cookieNonce !== stateNonce) {
throw new Error('Invalid nonce');
}
};
@@ -0,0 +1,25 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { OAuthEnvironmentHandler } from './OAuthEnvironmentHandler';
export { OAuthAdapter } from './OAuthAdapter';
export type {
OAuthHandlers,
OAuthProviderInfo,
OAuthProviderOptions,
OAuthResponse,
OAuthState,
} from './types';
+111
View File
@@ -0,0 +1,111 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import express from 'express';
import { AuthResponse, RedirectInfo } from '../../providers/types';
/**
* Common options for passport.js-based OAuth providers
*/
export type OAuthProviderOptions = {
/**
* Client ID of the auth provider.
*/
clientId: string;
/**
* Client Secret of the auth provider.
*/
clientSecret: string;
/**
* Callback URL to be passed to the auth provider to redirect to after the user signs in.
*/
callbackUrl: string;
};
export type OAuthResponse = AuthResponse<OAuthProviderInfo>;
export type OAuthProviderInfo = {
/**
* An access token issued for the signed in user.
*/
accessToken: string;
/**
* (Optional) Id token issued for the signed in user.
*/
idToken?: string;
/**
* Expiry of the access token in seconds.
*/
expiresInSeconds?: number;
/**
* Scopes granted for the access token.
*/
scope: string;
/**
* A refresh token issued for the signed in user
*/
refreshToken?: string;
};
export type OAuthState = {
/* A type for the serialized value in the `state` parameter of the OAuth authorization flow
*/
nonce: string;
env: string;
};
/**
* Any OAuth provider needs to implement this interface which has provider specific
* handlers for different methods to perform authentication, get access tokens,
* refresh tokens and perform sign out.
*/
export interface OAuthHandlers {
/**
* This method initiates a sign in request with an auth provider.
* @param {express.Request} req
* @param options
*/
start(
req: express.Request,
options: Record<string, string>,
): Promise<RedirectInfo>;
/**
* Handles the redirect from the auth provider when the user has signed in.
* @param {express.Request} req
*/
handler(
req: express.Request,
): Promise<{
response: AuthResponse<OAuthProviderInfo>;
refreshToken?: string;
}>;
/**
* (Optional) Given a refresh token and scope fetches a new access token from the auth provider.
* @param {string} refreshToken
* @param {string} scope
*/
refresh?(
refreshToken: string,
scope: string,
): Promise<AuthResponse<OAuthProviderInfo>>;
/**
* (Optional) Sign out of the auth provider.
*/
logout?(): Promise<void>;
}
@@ -17,12 +17,13 @@
import express from 'express';
import passport from 'passport';
import jwtDecoder from 'jwt-decode';
import {
RedirectInfo,
RefreshTokenResponse,
ProfileInfo,
ProviderStrategy,
} from '../providers/types';
import { ProfileInfo, RedirectInfo } from '../../providers/types';
export type PassportDoneCallback<Res, Private = never> = (
err?: Error,
response?: Res,
privateInfo?: Private,
) => void;
export const makeProfileInfo = (
profile: passport.Profile,
@@ -106,6 +107,18 @@ export const executeFrameHandlerStrategy = async <T, PrivateInfo = never>(
);
};
type RefreshTokenResponse = {
/**
* An access token issued for the signed in user.
*/
accessToken: string;
/**
* Optionally, the server can issue a new Refresh Token for the user
*/
refreshToken?: string;
params: any;
};
export const executeRefreshTokenStrategy = async (
providerStrategy: passport.Strategy,
refreshToken: string,
@@ -156,6 +169,10 @@ export const executeRefreshTokenStrategy = async (
});
};
type ProviderStrategy = {
userProfile(accessToken: string, callback: Function): void;
};
export const executeFetchUserProfileStrategy = async (
providerStrategy: passport.Strategy,
accessToken: string,
@@ -0,0 +1,24 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export {
executeFetchUserProfileStrategy,
executeFrameHandlerStrategy,
executeRedirectStrategy,
executeRefreshTokenStrategy,
makeProfileInfo,
} from './PassportStrategyHelper';
export type { PassportDoneCallback } from './PassportStrategyHelper';
@@ -17,25 +17,22 @@
import express from 'express';
import passport from 'passport';
import Auth0Strategy from './strategy';
import { Logger } from 'winston';
import { TokenIssuer } from '../../identity';
import { OAuthProvider } from '../../lib/OAuthProvider';
import {
OAuthAdapter,
OAuthProviderOptions,
OAuthHandlers,
OAuthResponse,
OAuthEnvironmentHandler,
} from '../../lib/oauth';
import {
executeFetchUserProfileStrategy,
executeFrameHandlerStrategy,
executeRedirectStrategy,
executeRefreshTokenStrategy,
makeProfileInfo,
} from '../../lib/PassportStrategyHelper';
import {
AuthProviderConfig,
OAuthProviderHandlers,
OAuthResponse,
PassportDoneCallback,
RedirectInfo,
OAuthProviderOptions,
} from '../types';
import { Config } from '@backstage/config';
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory } from '../types';
type PrivateInfo = {
refreshToken: string;
@@ -45,7 +42,7 @@ export type Auth0AuthProviderOptions = OAuthProviderOptions & {
domain: string;
};
export class Auth0AuthProvider implements OAuthProviderHandlers {
export class Auth0AuthProvider implements OAuthHandlers {
private readonly _strategy: Auth0Strategy;
constructor(options: Auth0AuthProviderOptions) {
@@ -151,29 +148,28 @@ export class Auth0AuthProvider implements OAuthProviderHandlers {
}
}
export function createAuth0Provider(
config: AuthProviderConfig,
_: string,
envConfig: Config,
_logger: Logger,
tokenIssuer: TokenIssuer,
) {
const providerId = 'auth0';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const domain = envConfig.getString('domain');
const callbackUrl = `${config.baseUrl}/${providerId}/handler/frame`;
export const createAuth0Provider: AuthProviderFactory = ({
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'auth0';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const domain = envConfig.getString('domain');
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const provider = new Auth0AuthProvider({
clientId,
clientSecret,
callbackUrl,
domain,
});
const provider = new Auth0AuthProvider({
clientId,
clientSecret,
callbackUrl,
domain,
});
return OAuthProvider.fromConfig(config, provider, {
disableRefresh: true,
providerId,
tokenIssuer,
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: true,
providerId,
tokenIssuer,
});
});
}
@@ -25,16 +25,8 @@ import { createOktaProvider } from './okta';
import { createSamlProvider } from './saml';
import { createAuth0Provider } from './auth0';
import { createMicrosoftProvider } from './microsoft';
import {
AuthProviderConfig,
AuthProviderFactory,
EnvironmentIdentifierFn,
} from './types';
import { AuthProviderConfig, AuthProviderFactory } from './types';
import { Config } from '@backstage/config';
import {
EnvironmentHandlers,
EnvironmentHandler,
} from '../lib/EnvironmentHandler';
const factories: { [providerId: string]: AuthProviderFactory } = {
google: createGoogleProvider,
@@ -50,9 +42,9 @@ const factories: { [providerId: string]: AuthProviderFactory } = {
export const createAuthProviderRouter = (
providerId: string,
globalConfig: AuthProviderConfig,
providerConfig: Config,
config: Config,
logger: Logger,
issuer: TokenIssuer,
tokenIssuer: TokenIssuer,
) => {
const factory = factories[providerId];
if (!factory) {
@@ -60,28 +52,8 @@ export const createAuthProviderRouter = (
}
const router = Router();
const envs = providerConfig.keys();
const envProviders: EnvironmentHandlers = {};
let envIdentifier: EnvironmentIdentifierFn | undefined;
for (const env of envs) {
const envConfig = providerConfig.getConfig(env);
const provider = factory(globalConfig, env, envConfig, logger, issuer);
if (provider) {
envProviders[env] = provider;
envIdentifier = provider.identifyEnv;
}
}
if (typeof envIdentifier === 'undefined') {
throw Error(`No envIdentifier provided for '${providerId}'`);
}
const handler = new EnvironmentHandler(
providerId,
envProviders,
envIdentifier,
);
const handler = factory({ globalConfig, config, logger, tokenIssuer });
router.get('/start', handler.start.bind(handler));
router.get('/handler/frame', handler.frameHandler.bind(handler));
@@ -20,20 +20,17 @@ import {
executeFrameHandlerStrategy,
executeRedirectStrategy,
makeProfileInfo,
} from '../../lib/PassportStrategyHelper';
import {
OAuthProviderHandlers,
AuthProviderConfig,
RedirectInfo,
OAuthProviderOptions,
OAuthResponse,
PassportDoneCallback,
} from '../types';
import { OAuthProvider } from '../../lib/OAuthProvider';
import { Logger } from 'winston';
import { TokenIssuer } from '../../identity';
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory } from '../types';
import {
OAuthAdapter,
OAuthProviderOptions,
OAuthHandlers,
OAuthResponse,
OAuthEnvironmentHandler,
} from '../../lib/oauth';
import passport from 'passport';
import { Config } from '@backstage/config';
export type GithubAuthProviderOptions = OAuthProviderOptions & {
tokenUrl?: string;
@@ -41,7 +38,7 @@ export type GithubAuthProviderOptions = OAuthProviderOptions & {
authorizationUrl?: string;
};
export class GithubAuthProvider implements OAuthProviderHandlers {
export class GithubAuthProvider implements OAuthHandlers {
private readonly _strategy: GithubStrategy;
static transformPassportProfile(rawProfile: any): passport.Profile {
@@ -137,43 +134,42 @@ export class GithubAuthProvider implements OAuthProviderHandlers {
}
}
export function createGithubProvider(
config: AuthProviderConfig,
_: string,
envConfig: Config,
_logger: Logger,
tokenIssuer: TokenIssuer,
) {
const providerId = 'github';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const enterpriseInstanceUrl = envConfig.getOptionalString(
'enterpriseInstanceUrl',
);
const authorizationUrl = enterpriseInstanceUrl
? `${enterpriseInstanceUrl}/login/oauth/authorize`
: undefined;
const tokenUrl = enterpriseInstanceUrl
? `${enterpriseInstanceUrl}/login/oauth/access_token`
: undefined;
const userProfileUrl = enterpriseInstanceUrl
? `${enterpriseInstanceUrl}/api/v3/user`
: undefined;
const callbackUrl = `${config.baseUrl}/${providerId}/handler/frame`;
export const createGithubProvider: AuthProviderFactory = ({
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'github';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const enterpriseInstanceUrl = envConfig.getOptionalString(
'enterpriseInstanceUrl',
);
const authorizationUrl = enterpriseInstanceUrl
? `${enterpriseInstanceUrl}/login/oauth/authorize`
: undefined;
const tokenUrl = enterpriseInstanceUrl
? `${enterpriseInstanceUrl}/login/oauth/access_token`
: undefined;
const userProfileUrl = enterpriseInstanceUrl
? `${enterpriseInstanceUrl}/api/v3/user`
: undefined;
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const provider = new GithubAuthProvider({
clientId,
clientSecret,
callbackUrl,
tokenUrl,
userProfileUrl,
authorizationUrl,
});
const provider = new GithubAuthProvider({
clientId,
clientSecret,
callbackUrl,
tokenUrl,
userProfileUrl,
authorizationUrl,
});
return OAuthProvider.fromConfig(config, provider, {
disableRefresh: true,
persistScopes: true,
providerId,
tokenIssuer,
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: true,
persistScopes: true,
providerId,
tokenIssuer,
});
});
}
@@ -20,26 +20,23 @@ import {
executeFrameHandlerStrategy,
executeRedirectStrategy,
makeProfileInfo,
} from '../../lib/PassportStrategyHelper';
import {
OAuthProviderHandlers,
AuthProviderConfig,
RedirectInfo,
OAuthProviderOptions,
OAuthResponse,
PassportDoneCallback,
} from '../types';
import { OAuthProvider } from '../../lib/OAuthProvider';
import { Logger } from 'winston';
import { TokenIssuer } from '../../identity';
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory } from '../types';
import {
OAuthAdapter,
OAuthProviderOptions,
OAuthHandlers,
OAuthResponse,
OAuthEnvironmentHandler,
} from '../../lib/oauth';
import passport from 'passport';
import { Config } from '@backstage/config';
export type GitlabAuthProviderOptions = OAuthProviderOptions & {
baseUrl: string;
};
export class GitlabAuthProvider implements OAuthProviderHandlers {
export class GitlabAuthProvider implements OAuthHandlers {
private readonly _strategy: GitlabStrategy;
static transformPassportProfile(rawProfile: any): passport.Profile {
@@ -140,30 +137,29 @@ export class GitlabAuthProvider implements OAuthProviderHandlers {
}
}
export function createGitlabProvider(
config: AuthProviderConfig,
_: string,
envConfig: Config,
_logger: Logger,
tokenIssuer: TokenIssuer,
) {
const providerId = 'gitlab';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const audience = envConfig.getString('audience');
const baseUrl = audience || 'https://gitlab.com';
const callbackUrl = `${config.baseUrl}/${providerId}/handler/frame`;
export const createGitlabProvider: AuthProviderFactory = ({
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'gitlab';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const audience = envConfig.getString('audience');
const baseUrl = audience || 'https://gitlab.com';
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const provider = new GitlabAuthProvider({
clientId,
clientSecret,
callbackUrl,
baseUrl,
});
const provider = new GitlabAuthProvider({
clientId,
clientSecret,
callbackUrl,
baseUrl,
});
return OAuthProvider.fromConfig(config, provider, {
disableRefresh: true,
providerId,
tokenIssuer,
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: true,
providerId,
tokenIssuer,
});
});
}
@@ -22,26 +22,23 @@ import {
executeRefreshTokenStrategy,
makeProfileInfo,
executeFetchUserProfileStrategy,
} from '../../lib/PassportStrategyHelper';
PassportDoneCallback,
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory } from '../types';
import {
OAuthProviderHandlers,
RedirectInfo,
AuthProviderConfig,
OAuthAdapter,
OAuthHandlers,
OAuthProviderOptions,
OAuthResponse,
PassportDoneCallback,
} from '../types';
import { OAuthProvider } from '../../lib/OAuthProvider';
OAuthEnvironmentHandler,
} from '../../lib/oauth';
import passport from 'passport';
import { Logger } from 'winston';
import { TokenIssuer } from '../../identity';
import { Config } from '@backstage/config';
type PrivateInfo = {
refreshToken: string;
};
export class GoogleAuthProvider implements OAuthProviderHandlers {
export class GoogleAuthProvider implements OAuthHandlers {
private readonly _strategy: GoogleStrategy;
constructor(options: OAuthProviderOptions) {
@@ -148,27 +145,26 @@ export class GoogleAuthProvider implements OAuthProviderHandlers {
}
}
export function createGoogleProvider(
config: AuthProviderConfig,
_: string,
envConfig: Config,
_logger: Logger,
tokenIssuer: TokenIssuer,
) {
const providerId = 'google';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const callbackUrl = `${config.baseUrl}/${providerId}/handler/frame`;
export const createGoogleProvider: AuthProviderFactory = ({
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'google';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const provider = new GoogleAuthProvider({
clientId,
clientSecret,
callbackUrl,
});
const provider = new GoogleAuthProvider({
clientId,
clientSecret,
callbackUrl,
});
return OAuthProvider.fromConfig(config, provider, {
disableRefresh: false,
providerId,
tokenIssuer,
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: false,
providerId,
tokenIssuer,
});
});
}
@@ -24,21 +24,18 @@ import {
executeRefreshTokenStrategy,
makeProfileInfo,
executeFetchUserProfileStrategy,
} from '../../lib/PassportStrategyHelper';
PassportDoneCallback,
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory } from '../types';
import {
OAuthProviderHandlers,
RedirectInfo,
AuthProviderConfig,
OAuthAdapter,
OAuthProviderOptions,
OAuthHandlers,
OAuthResponse,
PassportDoneCallback,
} from '../types';
import { OAuthProvider } from '../../lib/OAuthProvider';
import { Logger } from 'winston';
import { TokenIssuer } from '../../identity';
import { Config } from '@backstage/config';
OAuthEnvironmentHandler,
} from '../../lib/oauth';
import got from 'got';
@@ -51,7 +48,7 @@ export type MicrosoftAuthProviderOptions = OAuthProviderOptions & {
tokenUrl?: string;
};
export class MicrosoftAuthProvider implements OAuthProviderHandlers {
export class MicrosoftAuthProvider implements OAuthHandlers {
private readonly _strategy: MicrosoftStrategy;
static transformAuthResponse(
@@ -205,34 +202,33 @@ export class MicrosoftAuthProvider implements OAuthProviderHandlers {
}
}
export function createMicrosoftProvider(
config: AuthProviderConfig,
_: string,
envConfig: Config,
_logger: Logger,
tokenIssuer: TokenIssuer,
) {
const providerId = 'microsoft';
export const createMicrosoftProvider: AuthProviderFactory = ({
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'microsoft';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const tenantID = envConfig.getString('tenantId');
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const tenantID = envConfig.getString('tenantId');
const callbackUrl = `${config.baseUrl}/${providerId}/handler/frame`;
const authorizationUrl = `https://login.microsoftonline.com/${tenantID}/oauth2/v2.0/authorize`;
const tokenUrl = `https://login.microsoftonline.com/${tenantID}/oauth2/v2.0/token`;
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const authorizationUrl = `https://login.microsoftonline.com/${tenantID}/oauth2/v2.0/authorize`;
const tokenUrl = `https://login.microsoftonline.com/${tenantID}/oauth2/v2.0/token`;
const provider = new MicrosoftAuthProvider({
clientId,
clientSecret,
callbackUrl,
authorizationUrl,
tokenUrl,
const provider = new MicrosoftAuthProvider({
clientId,
clientSecret,
callbackUrl,
authorizationUrl,
tokenUrl,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: false,
providerId,
tokenIssuer,
});
});
return OAuthProvider.fromConfig(config, provider, {
disableRefresh: false,
providerId,
tokenIssuer,
});
}
@@ -17,25 +17,22 @@
import express from 'express';
import passport from 'passport';
import { Strategy as OAuth2Strategy } from 'passport-oauth2';
import { Logger } from 'winston';
import { TokenIssuer } from '../../identity';
import { OAuthProvider } from '../../lib/OAuthProvider';
import {
OAuthAdapter,
OAuthProviderOptions,
OAuthHandlers,
OAuthResponse,
OAuthEnvironmentHandler,
} from '../../lib/oauth';
import {
executeFetchUserProfileStrategy,
executeFrameHandlerStrategy,
executeRedirectStrategy,
executeRefreshTokenStrategy,
makeProfileInfo,
} from '../../lib/PassportStrategyHelper';
import {
AuthProviderConfig,
OAuthProviderOptions,
OAuthProviderHandlers,
OAuthResponse,
PassportDoneCallback,
RedirectInfo,
} from '../types';
import { Config } from '@backstage/config';
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory } from '../types';
type PrivateInfo = {
refreshToken: string;
@@ -46,7 +43,7 @@ export type OAuth2AuthProviderOptions = OAuthProviderOptions & {
tokenUrl: string;
};
export class OAuth2AuthProvider implements OAuthProviderHandlers {
export class OAuth2AuthProvider implements OAuthHandlers {
private readonly _strategy: OAuth2Strategy;
constructor(options: OAuth2AuthProviderOptions) {
@@ -159,31 +156,30 @@ export class OAuth2AuthProvider implements OAuthProviderHandlers {
}
}
export function createOAuth2Provider(
config: AuthProviderConfig,
_: string,
envConfig: Config,
_logger: Logger,
tokenIssuer: TokenIssuer,
) {
const providerId = 'oauth2';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const callbackUrl = `${config.baseUrl}/${providerId}/handler/frame`;
const authorizationUrl = envConfig.getString('authorizationUrl');
const tokenUrl = envConfig.getString('tokenUrl');
export const createOAuth2Provider: AuthProviderFactory = ({
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'oauth2';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const authorizationUrl = envConfig.getString('authorizationUrl');
const tokenUrl = envConfig.getString('tokenUrl');
const provider = new OAuth2AuthProvider({
clientId,
clientSecret,
callbackUrl,
authorizationUrl,
tokenUrl,
});
const provider = new OAuth2AuthProvider({
clientId,
clientSecret,
callbackUrl,
authorizationUrl,
tokenUrl,
});
return OAuthProvider.fromConfig(config, provider, {
disableRefresh: false,
providerId,
tokenIssuer,
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: false,
providerId,
tokenIssuer,
});
});
}
@@ -14,7 +14,13 @@
* limitations under the License.
*/
import express from 'express';
import { OAuthProvider } from '../../lib/OAuthProvider';
import {
OAuthAdapter,
OAuthProviderOptions,
OAuthHandlers,
OAuthResponse,
OAuthEnvironmentHandler,
} from '../../lib/oauth';
import { Strategy as OktaStrategy } from 'passport-okta-oauth';
import passport from 'passport';
import {
@@ -23,19 +29,10 @@ import {
executeRefreshTokenStrategy,
makeProfileInfo,
executeFetchUserProfileStrategy,
} from '../../lib/PassportStrategyHelper';
import {
OAuthProviderHandlers,
RedirectInfo,
AuthProviderConfig,
OAuthProviderOptions,
OAuthResponse,
PassportDoneCallback,
} from '../types';
import { Logger } from 'winston';
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory } from '../types';
import { StateStore } from 'passport-oauth2';
import { TokenIssuer } from '../../identity';
import { Config } from '@backstage/config';
type PrivateInfo = {
refreshToken: string;
@@ -45,7 +42,7 @@ export type OktaAuthProviderOptions = OAuthProviderOptions & {
audience: string;
};
export class OktaAuthProvider implements OAuthProviderHandlers {
export class OktaAuthProvider implements OAuthHandlers {
private readonly _strategy: any;
/**
@@ -170,29 +167,28 @@ export class OktaAuthProvider implements OAuthProviderHandlers {
}
}
export function createOktaProvider(
config: AuthProviderConfig,
_: string,
envConfig: Config,
_logger: Logger,
tokenIssuer: TokenIssuer,
) {
const providerId = 'okta';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const audience = envConfig.getString('audience');
const callbackUrl = `${config.baseUrl}/${providerId}/handler/frame`;
export const createOktaProvider: AuthProviderFactory = ({
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'okta';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const audience = envConfig.getString('audience');
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const provider = new OktaAuthProvider({
audience,
clientId,
clientSecret,
callbackUrl,
});
const provider = new OktaAuthProvider({
audience,
clientId,
clientSecret,
callbackUrl,
});
return OAuthProvider.fromConfig(config, provider, {
disableRefresh: false,
providerId,
tokenIssuer,
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: false,
providerId,
tokenIssuer,
});
});
}
@@ -23,17 +23,15 @@ import {
import {
executeFrameHandlerStrategy,
executeRedirectStrategy,
} from '../../lib/PassportStrategyHelper';
import {
AuthProviderConfig,
AuthProviderRouteHandlers,
PassportDoneCallback,
} from '../../lib/passport';
import {
AuthProviderRouteHandlers,
ProfileInfo,
AuthProviderFactory,
} from '../types';
import { postMessageResponse } from '../../lib/OAuthProvider';
import { Logger } from 'winston';
import { postMessageResponse } from '../../lib/flow';
import { TokenIssuer } from '../../identity';
import { Config } from '@backstage/config';
type SamlInfo = {
userId: string;
@@ -119,15 +117,12 @@ type SAMLProviderOptions = {
tokenIssuer: TokenIssuer;
};
export function createSamlProvider(
_authProviderConfig: AuthProviderConfig,
_env: string,
envConfig: Config,
_logger: Logger,
tokenIssuer: TokenIssuer,
) {
const entryPoint = envConfig.getString('entryPoint');
const issuer = envConfig.getString('issuer');
export const createSamlProvider: AuthProviderFactory = ({
config,
tokenIssuer,
}) => {
const entryPoint = config.getString('entryPoint');
const issuer = config.getString('issuer');
const opts = {
entryPoint,
issuer,
@@ -136,4 +131,4 @@ export function createSamlProvider(
};
return new SamlAuthProvider(opts);
}
};
+15 -167
View File
@@ -19,21 +19,6 @@ import { Logger } from 'winston';
import { TokenIssuer } from '../identity';
import { Config } from '@backstage/config';
export type OAuthProviderOptions = {
/**
* Client ID of the auth provider.
*/
clientId: string;
/**
* Client Secret of the auth provider.
*/
clientSecret: string;
/**
* Callback URL to be passed to the auth provider to redirect to after the user signs in.
*/
callbackUrl: string;
};
export type AuthProviderConfig = {
/**
* The protocol://domain[:port] where the app is hosted. This is used to construct the
@@ -47,48 +32,16 @@ export type AuthProviderConfig = {
appUrl: string;
};
/**
* Any OAuth provider needs to implement this interface which has provider specific
* handlers for different methods to perform authentication, get access tokens,
* refresh tokens and perform sign out.
*/
export interface OAuthProviderHandlers {
export type RedirectInfo = {
/**
* This method initiates a sign in request with an auth provider.
* @param {express.Request} req
* @param options
* URL to redirect to
*/
start(
req: express.Request,
options: Record<string, string>,
): Promise<RedirectInfo>;
url: string;
/**
* Handles the redirect from the auth provider when the user has signed in.
* @param {express.Request} req
* Status code to use for the redirect
*/
handler(
req: express.Request,
): Promise<{
response: AuthResponse<OAuthProviderInfo>;
refreshToken?: string;
}>;
/**
* (Optional) Given a refresh token and scope fetches a new access token from the auth provider.
* @param {string} refreshToken
* @param {string} scope
*/
refresh?(
refreshToken: string,
scope: string,
): Promise<AuthResponse<OAuthProviderInfo>>;
/**
* (Optional) Sign out of the auth provider.
*/
logout?(): Promise<void>;
}
status?: number;
};
/**
* Any Auth provider needs to implement this interface which handles the routes in the
@@ -155,24 +108,18 @@ export interface AuthProviderRouteHandlers {
* @param {express.Response} res
*/
logout?(req: express.Request, res: express.Response): Promise<void>;
/**
*(Optional) A method to identify the environment Context of the Request
*
*Request
*- contains the environment context information encoded in the request
* @param {express.Request} req
*/
identifyEnv?(req: express.Request): string | undefined;
}
export type AuthProviderFactoryOptions = {
globalConfig: AuthProviderConfig;
config: Config;
logger: Logger;
tokenIssuer: TokenIssuer;
};
export type AuthProviderFactory = (
globalConfig: AuthProviderConfig,
env: string,
envConfig: Config,
logger: Logger,
issuer: TokenIssuer,
) => AuthProviderRouteHandlers | undefined;
options: AuthProviderFactoryOptions,
) => AuthProviderRouteHandlers;
export type AuthResponse<ProviderInfo> = {
providerInfo: ProviderInfo;
@@ -180,8 +127,6 @@ export type AuthResponse<ProviderInfo> = {
backstageIdentity?: BackstageIdentity;
};
export type OAuthResponse = AuthResponse<OAuthProviderInfo>;
export type BackstageIdentity = {
/**
* The backstage user ID.
@@ -194,67 +139,6 @@ export type BackstageIdentity = {
idToken?: string;
};
export type OAuthProviderInfo = {
/**
* An access token issued for the signed in user.
*/
accessToken: string;
/**
* (Optional) Id token issued for the signed in user.
*/
idToken?: string;
/**
* Expiry of the access token in seconds.
*/
expiresInSeconds?: number;
/**
* Scopes granted for the access token.
*/
scope: string;
/**
* A refresh token issued for the signed in user
*/
refreshToken?: string;
};
export type OAuthPrivateInfo = {
/**
* A refresh token issued for the signed in user.
*/
refreshToken: string;
};
/**
* Payload sent as a post message after the auth request is complete.
* If successful then has a valid payload with Auth information else contains an error.
*/
export type WebMessageResponse =
| {
type: 'authorization_response';
response: AuthResponse<unknown>;
}
| {
type: 'authorization_response';
error: Error;
};
export type PassportDoneCallback<Res, Private = never> = (
err?: Error,
response?: Res,
privateInfo?: Private,
) => void;
export type RedirectInfo = {
/**
* URL to redirect to
*/
url: string;
/**
* Status code to use for the redirect
*/
status?: number;
};
/**
* Used to display login information to user, i.e. sidebar popup.
*
@@ -276,39 +160,3 @@ export type ProfileInfo = {
*/
picture?: string;
};
export type RefreshTokenResponse = {
/**
* An access token issued for the signed in user.
*/
accessToken: string;
/**
* Optionally, the server can issue a new Refresh Token for the user
*/
refreshToken?: string;
params: any;
};
export type ProviderStrategy = {
userProfile(accessToken: string, callback: Function): void;
};
export type SAMLProviderConfig = {
entryPoint: string;
issuer: string;
};
export type SAMLEnvironmentProviderConfig = {
[key: string]: SAMLProviderConfig;
};
export type OAuthState = {
/* A type for the serialized value in the `state` parameter of the OAuth authorization flow
*/
nonce: string;
env: string;
};
export type EnvironmentIdentifierFn = (
req: express.Request,
) => string | undefined;
@@ -22,6 +22,7 @@ import { Logger } from 'winston';
import { createAuthProviderRouter } from '../providers';
import { Config } from '@backstage/config';
import { DatabaseKeyStore, TokenFactory, createOidcRouter } from '../identity';
import { NotFoundError } from '@backstage/backend-common';
export interface RouterOptions {
logger: Logger;
@@ -88,5 +89,10 @@ export async function createRouter(
}),
);
router.use('/:provider/', req => {
const { provider } = req.params;
throw new NotFoundError(`No auth provider registered for '${provider}'`);
});
return router;
}