WIP: new auth pattern refactor, intro module and authenticator tests, #start refactor complete

Signed-off-by: Ruben Vallejo <rvallejo@vmware.com>
This commit is contained in:
Ruben Vallejo
2023-08-31 14:26:42 -04:00
parent 8d3e9c7277
commit 501a8b5bad
11 changed files with 658 additions and 0 deletions
+1
View File
@@ -35,6 +35,7 @@
"@backstage/plugin-adr-backend": "workspace:^",
"@backstage/plugin-app-backend": "workspace:^",
"@backstage/plugin-auth-backend": "workspace:^",
"@backstage/plugin-auth-backend-module-pinniped-provider": "^0.0.0",
"@backstage/plugin-auth-node": "workspace:^",
"@backstage/plugin-azure-devops-backend": "workspace:^",
"@backstage/plugin-azure-sites-backend": "workspace:^",
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -0,0 +1,5 @@
# @backstage/plugin-auth-backend-module-pinniped-provider
The pinniped-provider backend module for the auth plugin.
_This plugin was created through the Backstage CLI_
@@ -0,0 +1,26 @@
/*
* Copyright 2023 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.
*/
import { createBackend } from '@backstage/backend-defaults';
import { authPlugin } from '@backstage/plugin-auth-backend';
import { authModulePinnipedProvider } from '../src';
const backend = createBackend();
backend.add(authPlugin);
backend.add(authModulePinnipedProvider);
backend.start();
@@ -0,0 +1,36 @@
{
"name": "@backstage/plugin-auth-backend-module-pinniped-provider",
"description": "The pinniped-provider backend module for the auth plugin.",
"version": "0.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "backend-plugin-module"
},
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"clean": "backstage-cli package clean",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack"
},
"dependencies": {
"@backstage/backend-common": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^"
},
"files": [
"dist"
]
}
@@ -0,0 +1,232 @@
import {
OAuthAuthenticator,
OAuthAuthenticatorStartInput,
OAuthState,
PassportOAuthAuthenticatorHelper,
PassportProfile,
decodeOAuthState,
encodeOAuthState,
} from '@backstage/plugin-auth-node';
import { pinnipedAuthenticator } from './authenticator';
import { setupServer } from 'msw/node';
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import { ConfigReader } from '@backstage/config';
import { JWK, SignJWT, exportJWK, generateKeyPair } from 'jose';
import { rest } from 'msw';
import express from 'express';
describe('pinnipedAuthenticator', () => {
let implementation: any;
let oauthState: OAuthState;
let idToken: string;
let publicKey: JWK;
const mswServer = setupServer();
setupRequestMockHandlers(mswServer);
const issuerMetadata = {
issuer: 'https://pinniped.test',
authorization_endpoint: 'https://pinniped.test/oauth2/authorize',
token_endpoint: 'https://pinniped.test/oauth2/token',
revocation_endpoint: 'https://pinniped.test/oauth2/revoke_token',
userinfo_endpoint: 'https://pinniped.test/idp/userinfo.openid',
introspection_endpoint: 'https://pinniped.test/introspect.oauth2',
jwks_uri: 'https://pinniped.test/jwks.json',
scopes_supported: [
'openid',
'offline_access',
'pinniped:request-audience',
'username',
'groups',
],
claims_supported: ['email', 'username', 'groups', 'additionalClaims'],
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 clusterScopedIdToken = 'dummy-token';
beforeAll(async () => {
const keyPair = await generateKeyPair('RS256');
const privateKey = await exportJWK(keyPair.privateKey);
publicKey = await exportJWK(keyPair.publicKey);
publicKey.alg = privateKey.alg = 'RS256';
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(() => {
jest.clearAllMocks();
mswServer.use(
rest.get(
'https://federationDomain.test/.well-known/openid-configuration',
(_req, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(issuerMetadata),
),
),
)
implementation = pinnipedAuthenticator.initialize({
callbackUrl: 'https://backstage.test/callback',
config: new ConfigReader({
federationDomain: 'https://federationDomain.test',
clientId: 'clientId',
clientSecret: 'clientSecret',
})
})
oauthState = {
nonce: 'nonce',
env: 'env',
}
});
describe('#start', () => {
let fakeSession: Record<string, any>;
let startRequest: OAuthAuthenticatorStartInput;
beforeEach(() => {
fakeSession = {};
startRequest = {
state: encodeOAuthState(oauthState),
req: {
method: 'GET',
url: 'test',
session: fakeSession,
},
} as unknown as OAuthAuthenticatorStartInput;
});
it('redirects to authorization endpoint returned from OIDC metadata endpoint', async () => {
const startResponse = await pinnipedAuthenticator.start(startRequest, implementation);
const url = new URL(startResponse.url);
expect(url.protocol).toBe('https:');
expect(url.hostname).toBe('pinniped.test');
expect(url.pathname).toBe('/oauth2/authorize');
});
it('initiates authorization code grant', async () => {
const startResponse = await pinnipedAuthenticator.start(startRequest, implementation);
const { searchParams } = new URL(startResponse.url);
expect(searchParams.get('response_type')).toBe('code');
});
it('persists audience parameter in oauth state', async () => {
startRequest.req.query = { audience: 'test-cluster' };
const startResponse = await pinnipedAuthenticator.start(startRequest, implementation);
const { searchParams } = new URL(startResponse.url);
const stateParam = searchParams.get('state');
const decodedState = decodeOAuthState(stateParam!);
expect(decodedState).toMatchObject({
nonce: 'nonce',
env: 'env',
audience: 'test-cluster',
});
});
it('passes client ID from config', async () => {
const startResponse = await pinnipedAuthenticator.start(startRequest, implementation);
const { searchParams } = new URL(startResponse.url);
expect(searchParams.get('client_id')).toBe('clientId');
});
it('passes callback URL from config', async () => {
const startResponse = await pinnipedAuthenticator.start(startRequest, implementation);
const { searchParams } = new URL(startResponse.url);
expect(searchParams.get('redirect_uri')).toBe(
'https://backstage.test/callback',
);
});
it('generates PKCE challenge', async () => {
const startResponse = await pinnipedAuthenticator.start(startRequest, implementation);
const { searchParams } = new URL(startResponse.url);
expect(searchParams.get('code_challenge_method')).toBe('S256');
expect(searchParams.get('code_challenge')).not.toBeNull();
});
it('stores PKCE verifier in session', async () => {
await pinnipedAuthenticator.start(startRequest, implementation);
expect(fakeSession['oidc:pinniped.test'].code_verifier).toBeDefined();
});
it('requests sufficient scopes for token exchange by default', async () => {
const startResponse = await pinnipedAuthenticator.start(startRequest, implementation);
const { searchParams } = new URL(startResponse.url);
const scopes = searchParams.get('scope')?.split(' ') ?? [];
expect(scopes).toEqual(
expect.arrayContaining([
'openid',
'pinniped:request-audience',
'username',
'offline_access',
]),
);
});
it('encodes OAuth state in query param', async () => {
const startResponse = await pinnipedAuthenticator.start(startRequest, implementation);
const { searchParams } = new URL(startResponse.url);
const stateParam = searchParams.get('state');
const decodedState = decodeOAuthState(stateParam!);
expect(decodedState).toMatchObject(oauthState);
});
it('fails when request has no session', async () => {
return expect(
pinnipedAuthenticator.start({state: encodeOAuthState(oauthState),req: {
method: 'GET',
url: 'test',
}} as unknown as OAuthAuthenticatorStartInput,
implementation)
).rejects.toThrow('authentication requires session support');
});
});
// describe('#authenticate', () => {
// let handlerRequest: express.Request;
// beforeEach(() => {
// handlerRequest = {
// method: 'GET',
// url: `https://test?code=authorization_code&state=${encodeOAuthState(
// oauthState,
// )}`,
// session: {
// 'oidc:pinniped.test': {
// state: encodeOAuthState(oauthState),
// },
// },
// } as unknown as express.Request;
// });
// })
})
@@ -0,0 +1,138 @@
//bunch of new authenticator logic for our provider goes in here
import { PassportDoneCallback } from "@backstage/plugin-auth-backend/src/lib/passport";
import { PassportOAuthAuthenticatorHelper, createOAuthAuthenticator, decodeOAuthState, encodeOAuthState } from "@backstage/plugin-auth-node";
import { Issuer, TokenSet, Strategy as OidcStrategy } from 'openid-client'
export const pinnipedAuthenticator = createOAuthAuthenticator({
defaultProfileTransform:
PassportOAuthAuthenticatorHelper.defaultProfileTransform,
async initialize({ callbackUrl, config }) {
const issuer = await Issuer.discover(
`${config.getString('federationDomain')}/.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: config.getString('clientId'),
client_secret: config.getString('clientSecret'),
redirect_uris: [callbackUrl],
response_types: ['code'],
scope: config.getOptionalString('scope') || '',
});
const strategy = new OidcStrategy({
client,
passReqToCallback: false,
},(
tokenset: TokenSet,
done: PassportDoneCallback<{ tokenset: TokenSet }, {
refreshToken?: string;
}>,
) => {
done(undefined, { tokenset }, {});
},)
return ({ strategy, client })
},
//how does this helper get defined in other providers an what is the reason i get void type for it in my implementation
async start(input, implementation) {
const { strategy } = await implementation
const stringifiedAudience = input.req.query?.audience as string;
const decodedState = decodeOAuthState(input.state)
const state = { ...decodedState, audience: stringifiedAudience }
const options: Record<string, string> = {
scope:
input.scope || 'openid pinniped:request-audience username offline_access',
state: encodeOAuthState(state),
};
return new Promise((resolve, reject) => {
strategy.redirect = (url: string) => {
resolve({ url });
};
strategy.error = (error: Error) => {
reject(error);
};
strategy.authenticate(input.req, { ...options });
});
},
async authenticate(input, implementation) {
const { strategy, client } = await implementation;
const { req } = input
const { searchParams } = new URL(req.url, 'https://pinniped.com');
const stateParam = searchParams.get('state');
const audience = stateParam ? decodeOAuthState(stateParam).audience : undefined;
return new Promise((resolve, reject) => {
strategy.success = user => {
(audience
? client
.grant({
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
subject_token: user.tokenset.access_token,
audience,
subject_token_type:
'urn:ietf:params:oauth:token-type:access_token',
inputuested_token_type: 'urn:ietf:params:oauth:token-type:jwt',
})
.then(tokenset => tokenset.access_token)
: Promise.resolve(user.tokenset.id_token)
).then(idToken => {
resolve({
fullProfile: {provider: " ",id: " ",displayName: " "},
session: {
accessToken: user.tokenset.access_token!,
tokenType: "random",
scope: user.tokenset.scope!,
idToken,
refreshToken: user.tokenset.refresh_token
}
});
});
};
strategy.fail = info => {
reject(new Error(`Authentication rejected, ${info.message || ''}`));
};
strategy.error = (error: Error) => {
reject(error);
};
strategy.redirect = () => {
reject(new Error('Unexpected redirect'));
};
strategy.authenticate(req);
});
},
async refresh(input, implementation) {
const { client } = await implementation;
const tokenset = await client.refresh(input.refreshToken);
return new Promise((resolve, reject) => {
if (!tokenset.access_token) {
reject(new Error('Refresh Failed'));
}
resolve({
fullProfile: {provider: " ",id: " ",displayName: " "},
session: {
accessToken: tokenset.access_token!,
tokenType: "random",
scope: tokenset.scope!,
idToken: tokenset.id_token,
refreshToken: tokenset.refresh_token
}
});
});
},
})
@@ -0,0 +1,24 @@
/*
* Copyright 2023 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.
*/
/**
* The pinniped-provider backend module for the auth plugin.
*
* @packageDocumentation
*/
export { pinnipedAuthenticator } from './authenticator';
export { authModulePinnipedProvider } from './module';
@@ -0,0 +1,142 @@
import { setupRequestMockHandlers } from "@backstage/backend-test-utils"
import { authModulePinnipedProvider } from "./module"
import request from 'supertest';
import { setupServer } from 'msw/node';
import { rest } from 'msw';
import { Server } from "http";
import express from 'express';
import cookieParser from 'cookie-parser';
import session from 'express-session';
import passport from 'passport';
import { AddressInfo } from 'net';
import { AuthProviderRouteHandlers, createOAuthRouteHandlers } from "@backstage/plugin-auth-node";
import Router from 'express-promise-router';
import { pinnipedAuthenticator } from "./authenticator";
import { ConfigReader } from "@backstage/config";
describe('authModulePinnipedProvider', () => {
let app: express.Express;
let backstageServer: Server;
let appUrl: string;
let providerRouteHandler: AuthProviderRouteHandlers
const mswServer = setupServer();
setupRequestMockHandlers(mswServer);
const issuerMetadata = {
issuer: 'https://pinniped.test',
authorization_endpoint: 'https://pinniped.test/oauth2/authorize',
token_endpoint: 'https://pinniped.test/oauth2/token',
revocation_endpoint: 'https://pinniped.test/oauth2/revoke_token',
userinfo_endpoint: 'https://pinniped.test/idp/userinfo.openid',
introspection_endpoint: 'https://pinniped.test/introspect.oauth2',
jwks_uri: 'https://pinniped.test/jwks.json',
scopes_supported: [
'openid',
'offline_access',
'pinniped:request-audience',
'username',
'groups',
],
claims_supported: ['email', 'username', 'groups', 'additionalClaims'],
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'],
};
beforeEach(async () => {
// jest.clearAllMocks();
mswServer.use(
rest.get(
'https://federationDomain.test/.well-known/openid-configuration',
(_req, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(issuerMetadata),
),
),
)
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);
});
});
mswServer.use(rest.all(`${appUrl}/*`, req => req.passthrough()));
providerRouteHandler = createOAuthRouteHandlers({
authenticator: pinnipedAuthenticator,
appUrl,
baseUrl: `${appUrl}/api/auth`,
isOriginAllowed: _ => true,
providerId: 'pinniped',
config: new ConfigReader({
federationDomain: 'https://federationDomain.test',
clientId: 'clientId',
clientSecret: 'clientSecret',
}),
resolverContext: {
issueToken: async _ => ({ token: '' }),
findCatalogUser: async _ => ({
entity: {
apiVersion: '',
kind: '',
metadata: { name: '' },
},
}),
signInWithCatalogUser: async _ => ({ token: '' }),
},
})
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();
});
//TODO: are we actually testing the auth module here since our setup makes use of creating the Oauthfactory directly and not through the module, how can we attach it using the module instead????
it('should start', async () => {
const agent = request.agent(backstageServer)
const startResponse = await agent.get(`/api/auth/pinniped/start?env=development&audience=test_cluster`);
expect(startResponse.status).toBe(302)
}, 70000)
})
@@ -0,0 +1,41 @@
/*
* Copyright 2023 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.
*/
import { createBackendModule } from '@backstage/backend-plugin-api';
import { authProvidersExtensionPoint, commonSignInResolvers, createOAuthProviderFactory } from '@backstage/plugin-auth-node';
import { pinnipedAuthenticator } from './authenticator';
export const authModulePinnipedProvider = createBackendModule({
pluginId: 'auth',
moduleId: 'pinniped-provider',
register(reg) {
reg.registerInit({
deps: {
providers: authProvidersExtensionPoint,
},
async init({ providers }) {
providers.registerProvider({
providerId: 'pinniped',
factory: createOAuthProviderFactory({
authenticator: pinnipedAuthenticator,
signInResolverFactories: {
...commonSignInResolvers
}
})
})
},
});
},
});
+12
View File
@@ -4979,6 +4979,17 @@ __metadata:
languageName: unknown
linkType: soft
"@backstage/plugin-auth-backend-module-pinniped-provider@^0.0.0, @backstage/plugin-auth-backend-module-pinniped-provider@workspace:plugins/auth-backend-module-pinniped-provider":
version: 0.0.0-use.local
resolution: "@backstage/plugin-auth-backend-module-pinniped-provider@workspace:plugins/auth-backend-module-pinniped-provider"
dependencies:
"@backstage/backend-common": "workspace:^"
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/backend-test-utils": "workspace:^"
"@backstage/cli": "workspace:^"
languageName: unknown
linkType: soft
"@backstage/plugin-auth-backend@workspace:^, @backstage/plugin-auth-backend@workspace:plugins/auth-backend":
version: 0.0.0-use.local
resolution: "@backstage/plugin-auth-backend@workspace:plugins/auth-backend"
@@ -25897,6 +25908,7 @@ __metadata:
"@backstage/plugin-adr-backend": "workspace:^"
"@backstage/plugin-app-backend": "workspace:^"
"@backstage/plugin-auth-backend": "workspace:^"
"@backstage/plugin-auth-backend-module-pinniped-provider": ^0.0.0
"@backstage/plugin-auth-node": "workspace:^"
"@backstage/plugin-azure-devops-backend": "workspace:^"
"@backstage/plugin-azure-sites-backend": "workspace:^"