WIP: oidc auth provider module migration

Signed-off-by: Ruben Vallejo <rvallejo@vmware.com>
This commit is contained in:
Ruben Vallejo
2023-09-18 11:38:10 -04:00
committed by Jamie Klassen
parent f89777d76f
commit c541201b1b
8 changed files with 352 additions and 97 deletions
@@ -1,6 +1,6 @@
# Auth Module: GitLab Provider
# Auth Module: Oidc Provider
This module provides an GitLab auth provider implementation for `@backstage/plugin-auth-backend`.
This module provides an Oidc auth provider implementation for `@backstage/plugin-auth-backend`.
## Links
@@ -25,16 +25,22 @@
"dependencies": {
"@backstage/backend-common": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/plugin-auth-backend": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"express": "^4.18.2",
"passport": "^0.6.0",
"passport-oidc2": "^5.0.0"
"openid-client": "^5.5.0",
"passport": "^0.6.0"
},
"devDependencies": {
"@backstage/backend-defaults": "workspace:^",
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@backstage/plugin-auth-backend": "workspace:^",
"@backstage/config": "workspace:^",
"cookie-parser": "^1.4.6",
"express-promise-router": "^4.1.1",
"express-session": "^1.17.3",
"jose": "^4.14.6",
"msw": "^1.3.1",
"supertest": "^6.3.3"
},
"configSchema": "config.d.ts",
@@ -14,64 +14,110 @@
* limitations under the License.
*/
import { Strategy as GitlabStrategy } from 'passport-oidc2';
import {
Issuer,
ClientAuthMethod,
TokenSet,
UserinfoResponse,
Strategy as OidcStrategy,
} from 'openid-client';
import {
createOAuthAuthenticator,
decodeOAuthState,
encodeOAuthState,
PassportDoneCallback,
PassportOAuthAuthenticatorHelper,
PassportOAuthDoneCallback,
PassportOAuthPrivateInfo,
PassportProfile,
} from '@backstage/plugin-auth-node';
import { OidcAuthResult } from '@backstage/plugin-auth-backend';
/** @public */
export const oidcAuthenticator = createOAuthAuthenticator({
defaultProfileTransform:
PassportOAuthAuthenticatorHelper.defaultProfileTransform,
initialize({ callbackUrl, config }) {
async initialize({ callbackUrl, config }) {
const clientId = config.getString('clientId');
const clientSecret = config.getString('clientSecret');
const baseUrl =
config.getOptionalString('audience') || 'https://oidc.com';
const customCallbackUrl = config.getOptionalString('callbackUrl');
const callbackUrl2 = customCallbackUrl || callbackUrl;
const metadataUrl = config.getString('metadataUrl');
const tokenEndpointAuthMethod = config.getOptionalString(
'tokenEndpointAuthMethod',
) as ClientAuthMethod;
const tokenSignedResponseAlg = config.getOptionalString(
'tokenSignedResponseAlg',
);
const initializedScope = config.getOptionalString('scope');
const initializedPrompt = config.getOptionalString('prompt');
const issuer = await Issuer.discover(
`${metadataUrl}/.well-known/openid-configuration`,
);
const client = new issuer.Client({
access_type: 'offline', // this option must be passed to provider to receive a refresh token
client_id: clientId,
client_secret: clientSecret,
redirect_uris: [callbackUrl2],
response_types: ['code'],
token_endpoint_auth_method:
tokenEndpointAuthMethod || 'client_secret_basic',
id_token_signed_response_alg: tokenSignedResponseAlg || 'RS256',
scope: initializedScope || '',
});
return PassportOAuthAuthenticatorHelper.from(
new GitlabStrategy(
const helper = PassportOAuthAuthenticatorHelper.from(
new OidcStrategy(
{
clientID: clientId,
clientSecret: clientSecret,
callbackURL: callbackUrl,
baseURL: baseUrl,
authorizationURL: `${baseUrl}/oauth/authorize`,
tokenURL: `${baseUrl}/oauth/token`,
profileURL: `${baseUrl}/api/v4/user`,
client,
passReqToCallback: false,
},
(
accessToken: string,
refreshToken: string,
params: any,
fullProfile: PassportProfile,
done: PassportOAuthDoneCallback,
tokenset: TokenSet,
userinfo: UserinfoResponse,
done: PassportDoneCallback<OidcAuthResult, PassportOAuthPrivateInfo>,
) => {
if (typeof done !== 'function') {
throw new Error(
'OIDC IdP must provide a userinfo_endpoint in the metadata response',
);
}
done(
undefined,
{ fullProfile, params, accessToken },
{ refreshToken },
{ tokenset, userinfo },
{
refreshToken: tokenset.refresh_token,
},
);
},
),
);
return { helper, client, initializedScope, initializedPrompt };
},
async start(input, helper) {
async start(input, implementation) {
const { initializedScope, initializedPrompt, helper } =
await implementation;
const options: Record<string, string> = {
scope: input.scope || initializedScope || 'openid profile email',
state: input.state,
};
const prompt = initializedPrompt || 'none';
if (prompt !== 'auto') {
options.prompt = prompt;
}
return helper.start(input, {
accessType: 'offline',
prompt: 'consent',
...options,
});
},
async authenticate(input, helper) {
return helper.authenticate(input);
async authenticate(input, implementation) {
return (await implementation).helper.authenticate(input);
},
async refresh(input, helper) {
return helper.refresh(input);
async refresh(input, implementation) {
return (await implementation).helper.refresh(input);
},
});
@@ -18,39 +18,190 @@ import { mockServices, startTestBackend } from '@backstage/backend-test-utils';
import { authPlugin } from '@backstage/plugin-auth-backend';
import { authModuleOidcProvider } from './module';
import request from 'supertest';
import { decodeOAuthState } from '@backstage/plugin-auth-node';
import {
AuthProviderRouteHandlers,
AuthResolverContext,
createOAuthRouteHandlers,
decodeOAuthState,
} from '@backstage/plugin-auth-node';
import { setupServer } from 'msw';
import { ClientMetadata, IssuerMetadata } from 'openid-client';
import { rest } from 'msw';
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import { Server } from 'http';
import { AddressInfo } from 'net';
import express from 'express';
import { JWK, SignJWT, exportJWK, generateKeyPair } from 'jose';
import cookieParser from 'cookie-parser';
import passport from 'passport';
import session from 'express-session';
import { oidcAuthenticator } from './authenticator';
import { ConfigReader } from '@backstage/config';
import Router from 'express-promise-router';
describe('authModuleOidcProvider', () => {
it('should start', async () => {
const { server } = await startTestBackend({
features: [
authPlugin,
authModuleOidcProvider,
mockServices.rootConfig.factory({
data: {
app: {
baseUrl: 'http://localhost:3000',
},
auth: {
providers: {
oidc: {
development: {
clientId: 'my-client-id',
clientSecret: 'my-client-secret',
},
},
},
},
},
let app: express.Express;
let backstageServer: Server;
let appUrl: string;
let providerRouteHandler: AuthProviderRouteHandlers;
let idToken: string;
let publicKey: JWK;
const mswServer = setupServer();
setupRequestMockHandlers(mswServer);
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 = {
authHandler: async input => ({
profile: {
displayName: input.userinfo.email,
},
}),
resolverContext: {} as AuthResolverContext,
callbackUrl: 'https://oidc.test/callback',
clientId: 'testclientid',
clientSecret: 'testclientsecret',
metadataUrl: 'https://oidc.test/.well-known/openid-configuration',
tokenEndpointAuthMethod: 'none',
tokenSignedResponseAlg: 'none',
};
beforeAll(async () => {
const keyPair = await generateKeyPair('ES256');
const privateKey = await exportJWK(keyPair.privateKey);
publicKey = await exportJWK(keyPair.publicKey);
publicKey.alg = privateKey.alg = 'ES256';
idToken = await new SignJWT({
sub: 'test',
iss: 'https://pinniped.test',
iat: Date.now(),
aud: 'clientId',
exp: Date.now() + 10000,
})
.setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid })
.sign(keyPair.privateKey);
});
beforeEach(async () => {
jest.clearAllMocks();
mswServer.use(
rest.get(
'https://oidc.test/.well-known/openid-configuration',
(_req, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(issuerMetadata),
),
),
rest.get('https://oidc.test/oauth2/authorize', async (req, res, ctx) => {
const callbackUrl = new URL(req.url.searchParams.get('redirect_uri')!);
callbackUrl.searchParams.set('code', 'authorization_code');
callbackUrl.searchParams.set(
'state',
req.url.searchParams.get('state')!,
);
callbackUrl.searchParams.set('scope', 'test-scope');
return res(
ctx.status(302),
ctx.set('Location', callbackUrl.toString()),
);
}),
);
const secret = 'secret';
app = express()
.use(cookieParser(secret))
.use(
session({
secret,
saveUninitialized: false,
resave: false,
cookie: { secure: false },
}),
],
)
.use(passport.initialize())
.use(passport.session());
await new Promise(resolve => {
backstageServer = app.listen(0, '0.0.0.0', () => {
appUrl = `http://127.0.0.1:${
(backstageServer.address() as AddressInfo).port
}`;
resolve(null);
});
});
const agent = request.agent(server);
mswServer.use(rest.all(`${appUrl}/*`, req => req.passthrough()));
const res = await agent.get('/api/auth/oidc/start?env=development');
providerRouteHandler = createOAuthRouteHandlers({
authenticator: oidcAuthenticator,
appUrl,
baseUrl: `${appUrl}/api/auth`,
isOriginAllowed: _ => true,
providerId: 'oidc',
config: new ConfigReader({
metadataUrl: 'https://oidc.test',
clientId: 'clientId',
clientSecret: 'clientSecret',
}),
resolverContext: {
issueToken: async _ => ({ token: '' }),
findCatalogUser: async _ => ({
entity: {
apiVersion: '',
kind: '',
metadata: { name: '' },
},
}),
signInWithCatalogUser: async _ => ({ token: '' }),
},
});
expect(res.status).toEqual(302);
const router = Router();
router
.use(
'/api/auth/pinniped/start',
providerRouteHandler.start.bind(providerRouteHandler),
)
.use(
'/api/auth/pinniped/handler/frame',
providerRouteHandler.frameHandler.bind(providerRouteHandler),
);
app.use(router);
});
afterEach(() => {
backstageServer.close();
});
it('should start', async () => {
const agent = request.agent('');
const startResponse = await agent.get(
`${appUrl}/api/auth/oidc/start?env=development`,
);
expect(startResponse.status).toEqual(302);
const nonceCookie = agent.jar.getCookie('oidc-nonce', {
domain: 'localhost',
@@ -60,14 +211,16 @@ describe('authModuleOidcProvider', () => {
});
expect(nonceCookie).toBeDefined();
const startUrl = new URL(res.get('location'));
const startUrl = new URL(startResponse.get('location'));
expect(startUrl.origin).toBe('https://oidc.com');
expect(startUrl.pathname).toBe('/oauth/authorize');
expect(Object.fromEntries(startUrl.searchParams)).toEqual({
response_type: 'code',
scope: 'read_user',
client_id: 'my-client-id',
redirect_uri: `http://localhost:${server.port()}/api/auth/oidc/handler/frame`,
redirect_uri: `http://localhost:${
(backstageServer.address() as AddressInfo).port
}/api/auth/oidc/handler/frame`,
state: expect.any(String),
});
@@ -75,5 +228,5 @@ describe('authModuleOidcProvider', () => {
env: 'development',
nonce: decodeURIComponent(nonceCookie.value),
});
});
}, 70000);
});
@@ -19,13 +19,13 @@ import {
commonSignInResolvers,
createOAuthProviderFactory,
} from '@backstage/plugin-auth-node';
import { gitlabAuthenticator } from './authenticator';
import { gitlabSignInResolvers } from './resolvers';
import { oidcAuthenticator } from './authenticator';
import { oidcSignInResolvers } from './resolvers';
/** @public */
export const authModuleGitlabProvider = createBackendModule({
export const authModuleOidcProvider = createBackendModule({
pluginId: 'auth',
moduleId: 'gitlab-provider',
moduleId: 'oidc-provider',
register(reg) {
reg.registerInit({
deps: {
@@ -33,11 +33,11 @@ export const authModuleGitlabProvider = createBackendModule({
},
async init({ providers }) {
providers.registerProvider({
providerId: 'gitlab',
providerId: 'oidc',
factory: createOAuthProviderFactory({
authenticator: gitlabAuthenticator,
authenticator: oidcAuthenticator,
signInResolverFactories: {
...gitlabSignInResolvers,
...oidcSignInResolvers,
...commonSignInResolvers,
},
}),
@@ -22,13 +22,13 @@ import {
} from '@backstage/plugin-auth-node';
/**
* Available sign-in resolvers for the GitLab auth provider.
* Available sign-in resolvers for the Oidc auth provider.
*
* @public
*/
export namespace gitlabSignInResolvers {
export namespace oidcSignInResolvers {
/**
* Looks up the user by matching their GitLab username to the entity name.
* Looks up the user by matching their Oidc username to the entity name.
*/
export const usernameMatchingUserEntityName = createSignInResolverFactory({
create() {
@@ -40,11 +40,32 @@ export namespace gitlabSignInResolvers {
const id = result.fullProfile.username;
if (!id) {
throw new Error(`GitLab user profile does not contain a username`);
throw new Error(`Oidc user profile does not contain a username`);
}
return ctx.signInWithCatalogUser({ entityRef: { name: id } });
};
},
});
/**
* Looks up the user by matching their email to the `google.com/email` annotation. Still working out this resolver.....
*/
export const emailMatchingUserEntityAnnotation = createSignInResolverFactory({
create() {
return async (info: SignInInfo<GcpIapResult>, ctx) => {
const email = info.result.iapToken.email;
if (!email) {
throw new Error('Google IAP sign-in result is missing email');
}
return ctx.signInWithCatalogUser({
annotations: {
'google.com/email': email,
},
});
};
},
});
}
@@ -1,25 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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.
*/
declare module 'passport-gitlab2' {
import { Request } from 'express';
import { StrategyCreated } from 'passport';
export class Strategy {
constructor(options: any, verify: any);
authenticate(this: StrategyCreated<this>, req: Request, options?: any): any;
}
}
+54
View File
@@ -4672,6 +4672,30 @@ __metadata:
languageName: unknown
linkType: soft
"@backstage/plugin-auth-backend-module-oidc-provider@workspace:plugins/auth-backend-module-oidc-provider":
version: 0.0.0-use.local
resolution: "@backstage/plugin-auth-backend-module-oidc-provider@workspace:plugins/auth-backend-module-oidc-provider"
dependencies:
"@backstage/backend-common": "workspace:^"
"@backstage/backend-defaults": "workspace:^"
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/backend-test-utils": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/plugin-auth-backend": "workspace:^"
"@backstage/plugin-auth-node": "workspace:^"
cookie-parser: ^1.4.6
express: ^4.18.2
express-promise-router: ^4.1.1
express-session: ^1.17.3
jose: ^4.14.6
msw: ^1.3.1
openid-client: ^5.5.0
passport: ^0.6.0
supertest: ^6.3.3
languageName: unknown
linkType: soft
"@backstage/plugin-auth-backend-module-okta-provider@workspace:^, @backstage/plugin-auth-backend-module-okta-provider@workspace:plugins/auth-backend-module-okta-provider":
version: 0.0.0-use.local
resolution: "@backstage/plugin-auth-backend-module-okta-provider@workspace:plugins/auth-backend-module-okta-provider"
@@ -31245,6 +31269,13 @@ __metadata:
languageName: node
linkType: hard
"jose@npm:^4.14.4":
version: 4.14.6
resolution: "jose@npm:4.14.6"
checksum: eae81a234e7bf1446b1bd80722b3462b014e3835b155c3a7799c1c5043163a53a0dc28d347004151b031e6b7b863403aabf8814d9cc217ce21f8c2f3ebd4b335
languageName: node
linkType: hard
"jose@npm:^4.14.6, jose@npm:^4.15.4, jose@npm:^4.6.0":
version: 4.15.4
resolution: "jose@npm:4.15.4"
@@ -35636,6 +35667,18 @@ __metadata:
languageName: node
linkType: hard
"openid-client@npm:^5.5.0":
version: 5.5.0
resolution: "openid-client@npm:5.5.0"
dependencies:
jose: ^4.14.4
lru-cache: ^6.0.0
object-hash: ^2.2.0
oidc-token-hash: ^5.0.3
checksum: d2617b5bb0d9a0da338aeb7489bcbe3a79df9681189c7b61c2a3284289eee7110dfee2b04b49a9fdd4f064b7e2057ddb0becfedd9c19388e7788ae15b24c8e4c
languageName: node
linkType: hard
"oppa@npm:^0.4.0":
version: 0.4.0
resolution: "oppa@npm:0.4.0"
@@ -36273,6 +36316,17 @@ __metadata:
languageName: node
linkType: hard
"passport@npm:^0.6.0":
version: 0.6.0
resolution: "passport@npm:0.6.0"
dependencies:
passport-strategy: 1.x.x
pause: 0.0.1
utils-merge: ^1.0.1
checksum: ef932ad671d50de34765c7a53cd1e058d8331a82a6df09265a9c6c1168911aee4a7b5215803d0101110ab7f317e096b4954ca7e18fb2c33b9929f0bd17dbe159
languageName: node
linkType: hard
"passport@npm:^0.7.0":
version: 0.7.0
resolution: "passport@npm:0.7.0"