Implement a generic OpenIdConnect auth-provider
This commit is contained in:
@@ -235,6 +235,20 @@ auth:
|
||||
$env: AUTH_OAUTH2_AUTH_URL
|
||||
tokenUrl:
|
||||
$env: AUTH_OAUTH2_TOKEN_URL
|
||||
oidc:
|
||||
development:
|
||||
adUrl:
|
||||
$env: AUTH_OIDC_AD_URL
|
||||
clientId:
|
||||
$env: AUTH_OIDC_CLIENT_ID
|
||||
clientSecret:
|
||||
$env: AUTH_OIDC_CLIENT_SECRET
|
||||
authorizationUrl:
|
||||
$env: AUTH_OIDC_AUTH_URL
|
||||
tokenUrl:
|
||||
$env: AUTH_OIDC_TOKEN_URL
|
||||
tokenSignedResponseAlg:
|
||||
$env: AUTH_OIDC_TOKEN_SIGNED_RESPONSE_ALG
|
||||
auth0:
|
||||
development:
|
||||
clientId:
|
||||
|
||||
@@ -22,9 +22,16 @@ import {
|
||||
samlAuthApiRef,
|
||||
microsoftAuthApiRef,
|
||||
oneloginAuthApiRef,
|
||||
oidcApiRef,
|
||||
} from '@backstage/core';
|
||||
|
||||
export const providers = [
|
||||
{
|
||||
id: 'oidc-auth-provider',
|
||||
title: 'Oidc',
|
||||
message: 'Sign In using OpenId Connect',
|
||||
apiRef: oidcApiRef,
|
||||
},
|
||||
{
|
||||
id: 'google-auth-provider',
|
||||
title: 'Google',
|
||||
|
||||
@@ -311,6 +311,20 @@ export const oauth2ApiRef: ApiRef<
|
||||
description: 'Example of how to use oauth2 custom provider',
|
||||
});
|
||||
|
||||
/**
|
||||
* Provides authentication for custom OpenID Connect identity providers.
|
||||
*/
|
||||
export const oidcApiRef: ApiRef<
|
||||
OAuthApi &
|
||||
OpenIdConnectApi &
|
||||
ProfileInfoApi &
|
||||
BackstageIdentityApi &
|
||||
SessionApi
|
||||
> = createApiRef({
|
||||
id: 'core.auth.oidc',
|
||||
description: 'Example of how to use oidc custom provider',
|
||||
});
|
||||
|
||||
/**
|
||||
* Provides authentication for saml based identity providers
|
||||
*/
|
||||
|
||||
@@ -23,3 +23,4 @@ export * from './saml';
|
||||
export * from './auth0';
|
||||
export * from './microsoft';
|
||||
export * from './onelogin';
|
||||
export * from './oidc';
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* 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 OpenIdConnect from './Oidc';
|
||||
|
||||
const theFuture = new Date(Date.now() + 3600000);
|
||||
const thePast = new Date(Date.now() - 10);
|
||||
|
||||
const PREFIX = 'https://www.googleapis.com/auth/';
|
||||
|
||||
const scopeTransform = (x: string[]) => x;
|
||||
|
||||
describe('OpenIdConnect', () => {
|
||||
it('should get refreshed access token', async () => {
|
||||
const getSession = jest.fn().mockResolvedValue({
|
||||
providerInfo: { accessToken: 'access-token', expiresAt: theFuture },
|
||||
});
|
||||
const openIdConnect = new OpenIdConnect({
|
||||
sessionManager: { getSession } as any,
|
||||
scopeTransform,
|
||||
});
|
||||
|
||||
expect(await openIdConnect.getAccessToken('my-scope my-scope2')).toBe(
|
||||
'access-token',
|
||||
);
|
||||
expect(getSession).toBeCalledTimes(1);
|
||||
expect(getSession.mock.calls[0][0].scopes).toEqual(
|
||||
new Set(['my-scope', 'my-scope2']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should transform scopes', async () => {
|
||||
const getSession = jest.fn().mockResolvedValue({
|
||||
providerInfo: { accessToken: 'access-token', expiresAt: theFuture },
|
||||
});
|
||||
const openIdConnect = new OpenIdConnect({
|
||||
sessionManager: { getSession } as any,
|
||||
scopeTransform: scopes => scopes.map(scope => `my-prefix/${scope}`),
|
||||
});
|
||||
|
||||
expect(await openIdConnect.getAccessToken('my-scope')).toBe('access-token');
|
||||
expect(getSession).toBeCalledTimes(1);
|
||||
expect(getSession.mock.calls[0][0].scopes).toEqual(
|
||||
new Set(['my-prefix/my-scope']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should get refreshed id token', async () => {
|
||||
const getSession = jest.fn().mockResolvedValue({
|
||||
providerInfo: { idToken: 'id-token', expiresAt: theFuture },
|
||||
});
|
||||
const openIdConnect = new OpenIdConnect({
|
||||
sessionManager: { getSession } as any,
|
||||
scopeTransform,
|
||||
});
|
||||
|
||||
expect(await openIdConnect.getIdToken()).toBe('id-token');
|
||||
expect(getSession).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should get optional id token', async () => {
|
||||
const getSession = jest.fn().mockResolvedValue({
|
||||
providerInfo: { idToken: 'id-token', expiresAt: theFuture },
|
||||
});
|
||||
const openIdConnect = new OpenIdConnect({
|
||||
sessionManager: { getSession } as any,
|
||||
scopeTransform,
|
||||
});
|
||||
|
||||
expect(await openIdConnect.getIdToken({ optional: true })).toBe('id-token');
|
||||
expect(getSession).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should share popup closed errors', async () => {
|
||||
const error = new Error('NOPE');
|
||||
error.name = 'RejectedError';
|
||||
const getSession = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
providerInfo: {
|
||||
accessToken: 'access-token',
|
||||
expiresAt: theFuture,
|
||||
scopes: new Set([`${PREFIX}not-enough`]),
|
||||
},
|
||||
})
|
||||
.mockRejectedValue(error);
|
||||
const openIdConnect = new OpenIdConnect({
|
||||
sessionManager: { getSession } as any,
|
||||
scopeTransform,
|
||||
});
|
||||
|
||||
// Make sure we have a session before we do the double request, so that we get past the !this.currentSession check
|
||||
await expect(openIdConnect.getAccessToken()).resolves.toBe('access-token');
|
||||
|
||||
const promise1 = openIdConnect.getAccessToken('more');
|
||||
const promise2 = openIdConnect.getAccessToken('more');
|
||||
await expect(promise1).rejects.toBe(error);
|
||||
await expect(promise2).rejects.toBe(error);
|
||||
expect(getSession).toBeCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should wait for all session refreshes', async () => {
|
||||
const initialSession = {
|
||||
providerInfo: {
|
||||
idToken: 'token1',
|
||||
expiresAt: theFuture,
|
||||
scopes: new Set(),
|
||||
},
|
||||
};
|
||||
const getSession = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(initialSession)
|
||||
.mockResolvedValue({
|
||||
providerInfo: {
|
||||
idToken: 'token2',
|
||||
expiresAt: theFuture,
|
||||
scopes: new Set(),
|
||||
},
|
||||
});
|
||||
const openIdConnect = new OpenIdConnect({
|
||||
sessionManager: { getSession } as any,
|
||||
scopeTransform,
|
||||
});
|
||||
|
||||
// Grab the expired session first
|
||||
await expect(openIdConnect.getIdToken()).resolves.toBe('token1');
|
||||
expect(getSession).toBeCalledTimes(1);
|
||||
|
||||
initialSession.providerInfo.expiresAt = thePast;
|
||||
|
||||
const promise1 = openIdConnect.getIdToken();
|
||||
const promise2 = openIdConnect.getIdToken();
|
||||
const promise3 = openIdConnect.getIdToken();
|
||||
await expect(promise1).resolves.toBe('token2');
|
||||
await expect(promise2).resolves.toBe('token2');
|
||||
await expect(promise3).resolves.toBe('token2');
|
||||
expect(getSession).toBeCalledTimes(4); // De-duping of session requests happens in client
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* 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 OpenIdConnectIcon from '@material-ui/icons/AcUnit';
|
||||
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
|
||||
import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager';
|
||||
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
|
||||
import { Observable } from '../../../../types';
|
||||
import {
|
||||
AuthRequestOptions,
|
||||
BackstageIdentity,
|
||||
OAuthApi,
|
||||
OpenIdConnectApi,
|
||||
ProfileInfo,
|
||||
ProfileInfoApi,
|
||||
SessionState,
|
||||
SessionApi,
|
||||
BackstageIdentityApi,
|
||||
} from '../../../definitions/auth';
|
||||
import { OpenIdConnectSession } from './types';
|
||||
import { OAuthApiCreateOptions } from '../types';
|
||||
|
||||
type Options = {
|
||||
sessionManager: SessionManager<OpenIdConnectSession>;
|
||||
scopeTransform: (scopes: string[]) => string[];
|
||||
};
|
||||
|
||||
type CreateOptions = OAuthApiCreateOptions & {
|
||||
scopeTransform?: (scopes: string[]) => string[];
|
||||
};
|
||||
|
||||
export type OpenIdConnectResponse = {
|
||||
providerInfo: {
|
||||
accessToken: string;
|
||||
idToken: string;
|
||||
scope: string;
|
||||
expiresInSeconds: number;
|
||||
};
|
||||
profile: ProfileInfo;
|
||||
backstageIdentity: BackstageIdentity;
|
||||
};
|
||||
|
||||
const DEFAULT_PROVIDER = {
|
||||
id: 'oidc',
|
||||
title: 'Open ID Connect Identity Provider',
|
||||
icon: OpenIdConnectIcon,
|
||||
};
|
||||
|
||||
class OpenIdConnect
|
||||
implements
|
||||
OAuthApi,
|
||||
OpenIdConnectApi,
|
||||
ProfileInfoApi,
|
||||
BackstageIdentityApi,
|
||||
SessionApi {
|
||||
static create({
|
||||
discoveryApi,
|
||||
environment = 'development',
|
||||
provider = DEFAULT_PROVIDER,
|
||||
oauthRequestApi,
|
||||
defaultScopes = [],
|
||||
scopeTransform = x => x,
|
||||
}: CreateOptions) {
|
||||
const connector = new DefaultAuthConnector({
|
||||
discoveryApi,
|
||||
environment,
|
||||
provider,
|
||||
oauthRequestApi: oauthRequestApi,
|
||||
sessionTransform(res: OpenIdConnectResponse): OpenIdConnectSession {
|
||||
return {
|
||||
...res,
|
||||
providerInfo: {
|
||||
idToken: res.providerInfo.idToken,
|
||||
accessToken: res.providerInfo.accessToken,
|
||||
scopes: OpenIdConnect.normalizeScopes(
|
||||
scopeTransform,
|
||||
res.providerInfo.scope,
|
||||
),
|
||||
expiresAt: new Date(
|
||||
Date.now() + res.providerInfo.expiresInSeconds * 1000,
|
||||
),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const sessionManager = new RefreshingAuthSessionManager({
|
||||
connector,
|
||||
defaultScopes: new Set(defaultScopes),
|
||||
sessionScopes: (session: OpenIdConnectSession) =>
|
||||
session.providerInfo.scopes,
|
||||
sessionShouldRefresh: (session: OpenIdConnectSession) => {
|
||||
const expiresInSec =
|
||||
(session.providerInfo.expiresAt.getTime() - Date.now()) / 1000;
|
||||
return expiresInSec < 60 * 5;
|
||||
},
|
||||
});
|
||||
|
||||
return new OpenIdConnect({ sessionManager, scopeTransform });
|
||||
}
|
||||
|
||||
private readonly sessionManager: SessionManager<OpenIdConnectSession>;
|
||||
private readonly scopeTransform: (scopes: string[]) => string[];
|
||||
|
||||
constructor(options: Options) {
|
||||
this.sessionManager = options.sessionManager;
|
||||
this.scopeTransform = options.scopeTransform;
|
||||
}
|
||||
|
||||
async signIn() {
|
||||
await this.getAccessToken();
|
||||
}
|
||||
|
||||
async signOut() {
|
||||
await this.sessionManager.removeSession();
|
||||
}
|
||||
|
||||
sessionState$(): Observable<SessionState> {
|
||||
return this.sessionManager.sessionState$();
|
||||
}
|
||||
|
||||
async getAccessToken(
|
||||
scope?: string | string[],
|
||||
options?: AuthRequestOptions,
|
||||
) {
|
||||
const normalizedScopes = OpenIdConnect.normalizeScopes(
|
||||
this.scopeTransform,
|
||||
scope,
|
||||
);
|
||||
const session = await this.sessionManager.getSession({
|
||||
...options,
|
||||
scopes: normalizedScopes,
|
||||
});
|
||||
return session?.providerInfo.accessToken ?? '';
|
||||
}
|
||||
|
||||
async getIdToken(options: AuthRequestOptions = {}) {
|
||||
const session = await this.sessionManager.getSession(options);
|
||||
return session?.providerInfo.idToken ?? '';
|
||||
}
|
||||
|
||||
async getBackstageIdentity(
|
||||
options: AuthRequestOptions = {},
|
||||
): Promise<BackstageIdentity | undefined> {
|
||||
const session = await this.sessionManager.getSession(options);
|
||||
return session?.backstageIdentity;
|
||||
}
|
||||
|
||||
async getProfile(options: AuthRequestOptions = {}) {
|
||||
const session = await this.sessionManager.getSession(options);
|
||||
return session?.profile;
|
||||
}
|
||||
|
||||
private static normalizeScopes(
|
||||
scopeTransform: (scopes: string[]) => string[],
|
||||
scopes?: string | string[],
|
||||
): Set<string> {
|
||||
if (!scopes) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const scopeList = Array.isArray(scopes)
|
||||
? scopes
|
||||
: scopes.split(/[\s|,]/).filter(Boolean);
|
||||
|
||||
return new Set(scopeTransform(scopeList));
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenIdConnect;
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* 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 { default as OpenIdConnect } from './Oidc';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* 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 { ProfileInfo, BackstageIdentity } from '../../../definitions';
|
||||
|
||||
export type OpenIdConnectSession = {
|
||||
providerInfo: {
|
||||
idToken: string;
|
||||
accessToken: string;
|
||||
scopes: Set<string>;
|
||||
expiresAt: Date;
|
||||
};
|
||||
profile: ProfileInfo;
|
||||
backstageIdentity: BackstageIdentity;
|
||||
};
|
||||
@@ -46,6 +46,8 @@ import {
|
||||
SamlAuth,
|
||||
oneloginAuthApiRef,
|
||||
OneLoginAuth,
|
||||
oidcApiRef,
|
||||
OpenIdConnect,
|
||||
} from '@backstage/core-api';
|
||||
|
||||
export const defaultApis = [
|
||||
@@ -153,4 +155,13 @@ export const defaultApis = [
|
||||
factory: ({ discoveryApi, oauthRequestApi }) =>
|
||||
OneLoginAuth.create({ discoveryApi, oauthRequestApi }),
|
||||
}),
|
||||
createApiFactory({
|
||||
api: oidcApiRef,
|
||||
deps: {
|
||||
discoveryApi: discoveryApiRef,
|
||||
oauthRequestApi: oauthRequestApiRef,
|
||||
},
|
||||
factory: ({ discoveryApi, oauthRequestApi }) =>
|
||||
OpenIdConnect.create({ discoveryApi, oauthRequestApi }),
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -25,12 +25,15 @@
|
||||
"@backstage/catalog-model": "^0.2.0",
|
||||
"@backstage/config": "^0.1.1",
|
||||
"@types/express": "^4.17.6",
|
||||
"@types/express-session": "^1.17.2",
|
||||
"@types/openid-client": "^3.7.0",
|
||||
"compression": "^1.7.4",
|
||||
"cookie-parser": "^1.4.5",
|
||||
"cors": "^2.8.5",
|
||||
"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 +42,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",
|
||||
|
||||
@@ -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,216 @@
|
||||
/*
|
||||
* 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 & {
|
||||
authorizationUrl: string;
|
||||
tokenUrl: string;
|
||||
adUrl: string;
|
||||
tokenSignedResponseAlg?: string;
|
||||
};
|
||||
|
||||
export class OidcAuthProvider implements OAuthHandlers {
|
||||
private 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: 'consent',
|
||||
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.adUrl);
|
||||
// console.log('Discovered issuer %s %O', issuer.issuer, issuer.metadata);
|
||||
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 authorizationUrl = envConfig.getString('authorizationUrl');
|
||||
const adUrl = envConfig.getString('adUrl');
|
||||
const tokenUrl = envConfig.getString('tokenUrl');
|
||||
const tokenSignedResponseAlg = envConfig.getString(
|
||||
'tokenSignedResponseAlg',
|
||||
);
|
||||
|
||||
const provider = new OidcAuthProvider({
|
||||
clientId,
|
||||
clientSecret,
|
||||
callbackUrl,
|
||||
authorizationUrl,
|
||||
tokenUrl,
|
||||
tokenSignedResponseAlg,
|
||||
adUrl,
|
||||
});
|
||||
|
||||
return OAuthAdapter.fromConfig(globalConfig, provider, {
|
||||
disableRefresh: false,
|
||||
providerId,
|
||||
tokenIssuer,
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ 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';
|
||||
|
||||
export interface RouterOptions {
|
||||
logger: Logger;
|
||||
@@ -59,7 +60,9 @@ export async function createRouter({
|
||||
});
|
||||
const catalogApi = new CatalogClient({ discoveryApi: discovery });
|
||||
|
||||
router.use(cookieParser());
|
||||
const secret = 'backstage secret'; // TODO: Allow an override here
|
||||
router.use(cookieParser(secret));
|
||||
router.use(session({ secret }));
|
||||
router.use(express.urlencoded({ extended: false }));
|
||||
router.use(express.json());
|
||||
|
||||
|
||||
@@ -5138,6 +5138,13 @@
|
||||
"@types/qs" "*"
|
||||
"@types/range-parser" "*"
|
||||
|
||||
"@types/express-session@^1.17.2":
|
||||
version "1.17.2"
|
||||
resolved "https://registry.npmjs.org/@types/express-session/-/express-session-1.17.2.tgz#ed6a36dd9f267c7fef86004f653bfb9b5cea3c21"
|
||||
integrity sha512-QRm/fUuvr/BAosL9CvK351SDQP7wpD8+h3S8ZEE/8IvHJ/ZqHrjZbjx/flYfazyPw7yNi9O5fbjFZbh0vZ1ccg==
|
||||
dependencies:
|
||||
"@types/express" "*"
|
||||
|
||||
"@types/express@*", "@types/express@4.17.7", "@types/express@^4.17.6", "@types/express@^4.17.7":
|
||||
version "4.17.7"
|
||||
resolved "https://registry.npmjs.org/@types/express/-/express-4.17.7.tgz#42045be6475636d9801369cd4418ef65cdb0dd59"
|
||||
@@ -5566,6 +5573,13 @@
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/openid-client@^3.7.0":
|
||||
version "3.7.0"
|
||||
resolved "https://registry.npmjs.org/@types/openid-client/-/openid-client-3.7.0.tgz#8406e12798d16083df09cc3625973f5a00dd57fa"
|
||||
integrity sha512-hW+QbAeUyfUHABwUjUECOcv56Mf5tN29xK3GPN7wy3R3XfIQdkm37XELA4wbe2RNG9GYUpN8l2ytfoW05XmpgQ==
|
||||
dependencies:
|
||||
openid-client "*"
|
||||
|
||||
"@types/ora@^3.2.0":
|
||||
version "3.2.0"
|
||||
resolved "https://registry.npmjs.org/@types/ora/-/ora-3.2.0.tgz#b2f65d1283a8f36d8b0f9ee767e0732a2f429362"
|
||||
@@ -11564,6 +11578,20 @@ express-promise-router@^3.0.3:
|
||||
lodash.flattendeep "^4.0.0"
|
||||
methods "^1.0.0"
|
||||
|
||||
express-session@^1.17.1:
|
||||
version "1.17.1"
|
||||
resolved "https://registry.npmjs.org/express-session/-/express-session-1.17.1.tgz#36ecbc7034566d38c8509885c044d461c11bf357"
|
||||
integrity sha512-UbHwgqjxQZJiWRTMyhvWGvjBQduGCSBDhhZXYenziMFjxst5rMV+aJZ6hKPHZnPyHGsrqRICxtX8jtEbm/z36Q==
|
||||
dependencies:
|
||||
cookie "0.4.0"
|
||||
cookie-signature "1.0.6"
|
||||
debug "2.6.9"
|
||||
depd "~2.0.0"
|
||||
on-headers "~1.0.2"
|
||||
parseurl "~1.3.3"
|
||||
safe-buffer "5.2.0"
|
||||
uid-safe "~2.1.5"
|
||||
|
||||
express@^4.0.0, express@^4.17.0, express@^4.17.1:
|
||||
version "4.17.1"
|
||||
resolved "https://registry.npmjs.org/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134"
|
||||
@@ -12792,7 +12820,7 @@ got@^11.6.2:
|
||||
p-cancelable "^2.0.0"
|
||||
responselike "^2.0.0"
|
||||
|
||||
got@^11.7.0:
|
||||
got@^11.7.0, got@^11.8.0:
|
||||
version "11.8.0"
|
||||
resolved "https://registry.npmjs.org/got/-/got-11.8.0.tgz#be0920c3586b07fd94add3b5b27cb28f49e6545f"
|
||||
integrity sha512-k9noyoIIY9EejuhaBNLyZ31D5328LeqnyPNXJQb2XlJZcKakLqN5m6O/ikhq/0lw56kUYS54fVm+D1x57YC9oQ==
|
||||
@@ -17740,6 +17768,20 @@ opencollective-postinstall@^2.0.2:
|
||||
resolved "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.2.tgz#5657f1bede69b6e33a45939b061eb53d3c6c3a89"
|
||||
integrity sha512-pVOEP16TrAO2/fjej1IdOyupJY8KDUM1CvsaScRbw6oddvpQoOfGk4ywha0HKKVAD6RkW4x6Q+tNBwhf3Bgpuw==
|
||||
|
||||
openid-client@*, openid-client@^4.2.1:
|
||||
version "4.2.1"
|
||||
resolved "https://registry.npmjs.org/openid-client/-/openid-client-4.2.1.tgz#8200c0ab6a3b8e954727dfa790847dc5cb8999c2"
|
||||
integrity sha512-07eOcJeMH3ZHNvx5DVMZQmy3vZSTQqKSSunbtM1pXb+k5LBPi5hMum1vJCFReXlo4wuLEqZ/OgbsZvXPhbGRtA==
|
||||
dependencies:
|
||||
base64url "^3.0.1"
|
||||
got "^11.8.0"
|
||||
jose "^2.0.2"
|
||||
lru-cache "^6.0.0"
|
||||
make-error "^1.3.6"
|
||||
object-hash "^2.0.1"
|
||||
oidc-token-hash "^5.0.0"
|
||||
p-any "^3.0.0"
|
||||
|
||||
openid-client@^4.1.1:
|
||||
version "4.1.1"
|
||||
resolved "https://registry.npmjs.org/openid-client/-/openid-client-4.1.1.tgz#3e8a25584c4292e9b9b03e60358f5549fb85197a"
|
||||
@@ -19573,6 +19615,11 @@ ramda@^0.26, ramda@~0.26.1:
|
||||
resolved "https://registry.npmjs.org/ramda/-/ramda-0.26.1.tgz#8d41351eb8111c55353617fc3bbffad8e4d35d06"
|
||||
integrity sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ==
|
||||
|
||||
random-bytes@~1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz#4f68a1dc0ae58bd3fb95848c30324db75d64360b"
|
||||
integrity sha1-T2ih3Arli9P7lYSMMDJNt11kNgs=
|
||||
|
||||
randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5, randombytes@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"
|
||||
@@ -21086,7 +21133,7 @@ safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
|
||||
resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
|
||||
integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
|
||||
|
||||
safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@~5.2.0:
|
||||
safe-buffer@5.2.0, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@~5.2.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519"
|
||||
integrity sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==
|
||||
@@ -23357,6 +23404,13 @@ uid-number@0.0.6:
|
||||
resolved "https://registry.npmjs.org/uid-number/-/uid-number-0.0.6.tgz#0ea10e8035e8eb5b8e4449f06da1c730663baa81"
|
||||
integrity sha1-DqEOgDXo61uOREnwbaHHMGY7qoE=
|
||||
|
||||
uid-safe@~2.1.5:
|
||||
version "2.1.5"
|
||||
resolved "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz#2b3d5c7240e8fc2e58f8aa269e5ee49c0857bd3a"
|
||||
integrity sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==
|
||||
dependencies:
|
||||
random-bytes "~1.0.0"
|
||||
|
||||
uid2@0.0.3, uid2@0.0.x:
|
||||
version "0.0.3"
|
||||
resolved "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz#483126e11774df2f71b8b639dcd799c376162b82"
|
||||
|
||||
Reference in New Issue
Block a user