Merge pull request #3309 from bleathem/oidc-provider
Implement a generic OpenId-Connect auth-provider
This commit is contained in:
@@ -31,6 +31,7 @@
|
||||
"cross-fetch": "^3.0.6",
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^3.0.3",
|
||||
"express-session": "^1.17.1",
|
||||
"fs-extra": "^9.0.0",
|
||||
"got": "^11.5.2",
|
||||
"helmet": "^4.0.0",
|
||||
@@ -39,6 +40,7 @@
|
||||
"knex": "^0.21.6",
|
||||
"moment": "^2.26.0",
|
||||
"morgan": "^1.10.0",
|
||||
"openid-client": "^4.2.1",
|
||||
"passport": "^0.4.1",
|
||||
"passport-github2": "^0.1.12",
|
||||
"passport-gitlab2": "^5.0.0",
|
||||
@@ -56,16 +58,41 @@
|
||||
"@backstage/cli": "^0.3.0",
|
||||
"@types/body-parser": "^1.19.0",
|
||||
"@types/cookie-parser": "^1.4.2",
|
||||
"@types/express-session": "^1.17.2",
|
||||
"@types/jwt-decode": "2.2.1",
|
||||
"@types/nock": "^11.1.0",
|
||||
"@types/openid-client": "^3.7.0",
|
||||
"@types/passport": "^1.0.3",
|
||||
"@types/passport-github2": "^1.2.4",
|
||||
"@types/passport-google-oauth20": "^2.0.3",
|
||||
"@types/passport-microsoft": "^0.0.0",
|
||||
"@types/passport-saml": "^1.1.2",
|
||||
"msw": "^0.21.2"
|
||||
"msw": "^0.21.2",
|
||||
"nock": "^13.0.5"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"migrations"
|
||||
]
|
||||
],
|
||||
"configSchema": {
|
||||
"$schema": "https://backstage.io/schema/config-v1",
|
||||
"title": "@backstage/auth-backend",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"auth": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"secret": {
|
||||
"type": "string",
|
||||
"visibility": "secret"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { createGithubProvider } from './github';
|
||||
import { createGitlabProvider } from './gitlab';
|
||||
import { createGoogleProvider } from './google';
|
||||
import { createOAuth2Provider } from './oauth2';
|
||||
import { createOidcProvider } from './oidc';
|
||||
import { createOktaProvider } from './okta';
|
||||
import { createSamlProvider } from './saml';
|
||||
import { createAuth0Provider } from './auth0';
|
||||
@@ -34,6 +35,7 @@ const factories: { [providerId: string]: AuthProviderFactory } = {
|
||||
auth0: createAuth0Provider,
|
||||
microsoft: createMicrosoftProvider,
|
||||
oauth2: createOAuth2Provider,
|
||||
oidc: createOidcProvider,
|
||||
onelogin: createOneLoginProvider,
|
||||
};
|
||||
|
||||
|
||||
@@ -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 { createOidcProvider } from './provider';
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* 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 { Session } from 'express-session';
|
||||
import nock from 'nock';
|
||||
import { ClientMetadata, IssuerMetadata } from 'openid-client';
|
||||
import { createOidcProvider, OidcAuthProvider } from './provider';
|
||||
import { JWT, JWK } from 'jose';
|
||||
import { AuthProviderFactoryOptions } from '../types';
|
||||
import { Config } from '@backstage/config';
|
||||
import { OAuthAdapter } from '../../lib/oauth';
|
||||
|
||||
const issuerMetadata = {
|
||||
issuer: 'https://oidc.test',
|
||||
authorization_endpoint: 'https://oidc.test/as/authorization.oauth2',
|
||||
token_endpoint: 'https://oidc.test/as/token.oauth2',
|
||||
revocation_endpoint: 'https://oidc.test/as/revoke_token.oauth2',
|
||||
userinfo_endpoint: 'https://oidc.test/idp/userinfo.openid',
|
||||
introspection_endpoint: 'https://oidc.test/as/introspect.oauth2',
|
||||
jwks_uri: 'https://oidc.test/pf/JWKS',
|
||||
scopes_supported: ['openid'],
|
||||
claims_supported: ['email'],
|
||||
response_types_supported: ['code'],
|
||||
id_token_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'],
|
||||
token_endpoint_auth_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'],
|
||||
request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'],
|
||||
};
|
||||
|
||||
const clientMetadata = {
|
||||
callbackUrl: 'https://oidc.test/callback',
|
||||
clientId: 'testclientid',
|
||||
clientSecret: 'testclientsecret',
|
||||
metadataUrl: 'https://oidc.test/.well-known/openid-configuration',
|
||||
tokenSignedResponseAlg: 'none',
|
||||
};
|
||||
|
||||
describe('OidcAuthProvider', () => {
|
||||
it('hit the metadata url', async () => {
|
||||
const scope = nock('https://oidc.test')
|
||||
.get('/.well-known/openid-configuration')
|
||||
.reply(200, issuerMetadata);
|
||||
const provider = new OidcAuthProvider(clientMetadata);
|
||||
const strategy = ((await provider._strategy) as any) as {
|
||||
_client: ClientMetadata;
|
||||
_issuer: IssuerMetadata;
|
||||
};
|
||||
// Assert that the expected request to the metadaurl was made.
|
||||
expect(scope.isDone()).toBeTruthy();
|
||||
const { _client, _issuer } = strategy;
|
||||
expect(_client.client_id).toBe(clientMetadata.clientId);
|
||||
expect(_issuer.token_endpoint).toBe(issuerMetadata.token_endpoint);
|
||||
});
|
||||
|
||||
it('OidcAuthProvider#handler successfully invokes the oidc endpoints', async () => {
|
||||
const jwt = {
|
||||
sub: 'alice',
|
||||
iss: 'https://oidc.test',
|
||||
aud: clientMetadata.clientId,
|
||||
exp: Date.now() + 10000,
|
||||
};
|
||||
const scope = nock('https://oidc.test')
|
||||
.get('/.well-known/openid-configuration')
|
||||
.reply(200, issuerMetadata)
|
||||
.post('/as/token.oauth2')
|
||||
.reply(200, {
|
||||
id_token: JWT.sign(jwt, JWK.None),
|
||||
access_token: 'test',
|
||||
authorization_signed_response_alg: 'HS256',
|
||||
})
|
||||
.get('/idp/userinfo.openid')
|
||||
.reply(200, {
|
||||
sub: 'alice',
|
||||
email: 'alice@oidc.test',
|
||||
});
|
||||
const provider = new OidcAuthProvider(clientMetadata);
|
||||
const req = {
|
||||
method: 'GET',
|
||||
url: '/?code=test2',
|
||||
session: ({ 'oidc:oidc.test': 'test' } as any) as Session,
|
||||
} as express.Request;
|
||||
await provider.handler(req);
|
||||
expect(scope.isDone()).toBeTruthy();
|
||||
});
|
||||
|
||||
const options = {
|
||||
globalConfig: {
|
||||
appUrl: 'https://oidc.test',
|
||||
baseUrl: 'https://oidc.test',
|
||||
},
|
||||
config: ({
|
||||
keys: jest.fn(() => ['test']),
|
||||
getConfig: jest.fn(() => ({ getString: () => '' })),
|
||||
} as any) as Config,
|
||||
} as AuthProviderFactoryOptions;
|
||||
|
||||
it('createOidcProvider', () => {
|
||||
const provider = createOidcProvider(options) as OAuthAdapter;
|
||||
console.log(provider);
|
||||
expect(provider.start).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* 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 {
|
||||
Issuer,
|
||||
Client,
|
||||
Strategy as OidcStrategy,
|
||||
TokenSet,
|
||||
UserinfoResponse,
|
||||
} from 'openid-client';
|
||||
import {
|
||||
OAuthAdapter,
|
||||
OAuthProviderOptions,
|
||||
OAuthHandlers,
|
||||
OAuthResponse,
|
||||
OAuthEnvironmentHandler,
|
||||
OAuthStartRequest,
|
||||
encodeState,
|
||||
OAuthRefreshRequest,
|
||||
} from '../../lib/oauth';
|
||||
import {
|
||||
executeFetchUserProfileStrategy,
|
||||
executeFrameHandlerStrategy,
|
||||
executeRedirectStrategy,
|
||||
executeRefreshTokenStrategy,
|
||||
PassportDoneCallback,
|
||||
} from '../../lib/passport';
|
||||
import { RedirectInfo, AuthProviderFactory, ProfileInfo } from '../types';
|
||||
|
||||
type PrivateInfo = {
|
||||
refreshToken: string;
|
||||
};
|
||||
|
||||
export type OidcAuthProviderOptions = OAuthProviderOptions & {
|
||||
metadataUrl: string;
|
||||
tokenSignedResponseAlg?: string;
|
||||
};
|
||||
|
||||
export class OidcAuthProvider implements OAuthHandlers {
|
||||
readonly _strategy: Promise<OidcStrategy<UserinfoResponse, Client>>;
|
||||
|
||||
constructor(options: OidcAuthProviderOptions) {
|
||||
this._strategy = this.setupStrategy(options);
|
||||
}
|
||||
|
||||
async start(req: OAuthStartRequest): Promise<RedirectInfo> {
|
||||
const strategy = await this._strategy;
|
||||
return await executeRedirectStrategy(req, strategy, {
|
||||
accessType: 'offline',
|
||||
prompt: 'none',
|
||||
scope: `${req.scope} default`,
|
||||
state: encodeState(req.state),
|
||||
});
|
||||
}
|
||||
|
||||
async handler(
|
||||
req: express.Request,
|
||||
): Promise<{ response: OAuthResponse; refreshToken: string }> {
|
||||
const strategy = await this._strategy;
|
||||
const { response, privateInfo } = await executeFrameHandlerStrategy<
|
||||
OAuthResponse,
|
||||
PrivateInfo
|
||||
>(req, strategy);
|
||||
|
||||
return {
|
||||
response: await this.populateIdentity(response),
|
||||
refreshToken: privateInfo.refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
|
||||
const strategy = await this._strategy;
|
||||
const refreshTokenResponse = await executeRefreshTokenStrategy(
|
||||
strategy,
|
||||
req.refreshToken,
|
||||
req.scope,
|
||||
);
|
||||
const {
|
||||
accessToken,
|
||||
params,
|
||||
refreshToken: updatedRefreshToken,
|
||||
} = refreshTokenResponse;
|
||||
|
||||
const profile = await executeFetchUserProfileStrategy(
|
||||
strategy,
|
||||
accessToken,
|
||||
params.id_token,
|
||||
);
|
||||
|
||||
return this.populateIdentity({
|
||||
providerInfo: {
|
||||
accessToken,
|
||||
refreshToken: updatedRefreshToken,
|
||||
idToken: params.id_token,
|
||||
expiresInSeconds: params.expires_in,
|
||||
scope: params.scope,
|
||||
},
|
||||
profile,
|
||||
});
|
||||
}
|
||||
|
||||
private async setupStrategy(
|
||||
options: OidcAuthProviderOptions,
|
||||
): Promise<OidcStrategy<UserinfoResponse, Client>> {
|
||||
const issuer = await Issuer.discover(options.metadataUrl);
|
||||
const client = new issuer.Client({
|
||||
client_id: options.clientId,
|
||||
client_secret: options.clientSecret,
|
||||
redirect_uris: [options.callbackUrl],
|
||||
response_types: ['code'],
|
||||
id_token_signed_response_alg: options.tokenSignedResponseAlg || 'RS256',
|
||||
});
|
||||
|
||||
const strategy = new OidcStrategy(
|
||||
{
|
||||
client,
|
||||
passReqToCallback: false as true,
|
||||
},
|
||||
(
|
||||
tokenset: TokenSet,
|
||||
userinfo: UserinfoResponse,
|
||||
done: PassportDoneCallback<OAuthResponse, PrivateInfo>,
|
||||
) => {
|
||||
const profile: ProfileInfo = {
|
||||
displayName: userinfo.name,
|
||||
email: userinfo.email,
|
||||
picture: userinfo.picture,
|
||||
};
|
||||
|
||||
done(
|
||||
undefined,
|
||||
{
|
||||
providerInfo: {
|
||||
idToken: tokenset.id_token || '',
|
||||
accessToken: tokenset.access_token || '',
|
||||
scope: tokenset.scope || '',
|
||||
expiresInSeconds: tokenset.expires_in,
|
||||
},
|
||||
profile,
|
||||
},
|
||||
{
|
||||
refreshToken: tokenset.refresh_token || '',
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
strategy.error = console.error;
|
||||
return strategy;
|
||||
}
|
||||
|
||||
// Use this function to grab the user profile info from the token
|
||||
// Then populate the profile with it
|
||||
private async populateIdentity(
|
||||
response: OAuthResponse,
|
||||
): Promise<OAuthResponse> {
|
||||
const { profile } = response;
|
||||
|
||||
if (!profile.email) {
|
||||
throw new Error('Profile does not contain an email');
|
||||
}
|
||||
const id = profile.email.split('@')[0];
|
||||
|
||||
return { ...response, backstageIdentity: { id } };
|
||||
}
|
||||
}
|
||||
|
||||
export const createOidcProvider: AuthProviderFactory = ({
|
||||
globalConfig,
|
||||
config,
|
||||
tokenIssuer,
|
||||
}) =>
|
||||
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
|
||||
const providerId = 'oidc';
|
||||
const clientId = envConfig.getString('clientId');
|
||||
const clientSecret = envConfig.getString('clientSecret');
|
||||
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
|
||||
const metadataUrl = envConfig.getString('metadataUrl');
|
||||
const tokenSignedResponseAlg = envConfig.getString(
|
||||
'tokenSignedResponseAlg',
|
||||
);
|
||||
|
||||
const provider = new OidcAuthProvider({
|
||||
clientId,
|
||||
clientSecret,
|
||||
callbackUrl,
|
||||
tokenSignedResponseAlg,
|
||||
metadataUrl,
|
||||
});
|
||||
|
||||
return OAuthAdapter.fromConfig(globalConfig, provider, {
|
||||
disableRefresh: false,
|
||||
providerId,
|
||||
tokenIssuer,
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,8 @@ import Router from 'express-promise-router';
|
||||
import { Logger } from 'winston';
|
||||
import { createOidcRouter, DatabaseKeyStore, TokenFactory } from '../identity';
|
||||
import { createAuthProvider } from '../providers';
|
||||
import session from 'express-session';
|
||||
import passport from 'passport';
|
||||
|
||||
export interface RouterOptions {
|
||||
logger: Logger;
|
||||
@@ -59,7 +61,16 @@ export async function createRouter({
|
||||
});
|
||||
const catalogApi = new CatalogClient({ discoveryApi: discovery });
|
||||
|
||||
router.use(cookieParser());
|
||||
const secret = config.getOptionalString('auth.session.secret');
|
||||
if (secret) {
|
||||
router.use(cookieParser(secret));
|
||||
// TODO: Configure the server-side session storage. The default MemoryStore is not designed for production
|
||||
router.use(session({ secret, saveUninitialized: false, resave: false }));
|
||||
router.use(passport.initialize());
|
||||
router.use(passport.session());
|
||||
} else {
|
||||
router.use(cookieParser());
|
||||
}
|
||||
router.use(express.urlencoded({ extended: false }));
|
||||
router.use(express.json());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user