Completed Pinniped Authenticator refactor with passing unit tests

Signed-off-by: Ruben Vallejo <rvallejo@vmware.com>
This commit is contained in:
Ruben Vallejo
2023-09-06 15:57:45 -04:00
parent 501a8b5bad
commit 362a5e293f
12 changed files with 613 additions and 836 deletions
-1
View File
@@ -35,7 +35,6 @@
"@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:^",
@@ -1,5 +1,7 @@
# @backstage/plugin-auth-backend-module-pinniped-provider
# Auth Module: Pinniped Provider
The pinniped-provider backend module for the auth plugin.
This module provides an Pinniped auth provider implementation for `@backstage/plugin-auth-backend`.
_This plugin was created through the Backstage CLI_
## Links
- [Backstage](https://backstage.io)
@@ -24,11 +24,24 @@
},
"dependencies": {
"@backstage/backend-common": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"openid-client": "^5.4.3"
},
"devDependencies": {
"@backstage/backend-defaults": "workspace:^",
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^"
"@backstage/cli": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/plugin-auth-backend": "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.0",
"passport": "^0.6.0",
"supertest": "^6.3.3"
},
"files": [
"dist"
@@ -1,9 +1,23 @@
/*
* 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 {
OAuthAuthenticator,
OAuthAuthenticatorAuthenticateInput,
OAuthAuthenticatorRefreshInput,
OAuthAuthenticatorStartInput,
OAuthState,
PassportOAuthAuthenticatorHelper,
PassportProfile,
decodeOAuthState,
encodeOAuthState,
} from '@backstage/plugin-auth-node';
@@ -53,10 +67,10 @@ describe('pinnipedAuthenticator', () => {
const clusterScopedIdToken = 'dummy-token';
beforeAll(async () => {
const keyPair = await generateKeyPair('RS256');
const keyPair = await generateKeyPair('ES256');
const privateKey = await exportJWK(keyPair.privateKey);
publicKey = await exportJWK(keyPair.publicKey);
publicKey.alg = privateKey.alg = 'RS256';
publicKey.alg = privateKey.alg = 'ES256';
idToken = await new SignJWT({
sub: 'test',
@@ -69,8 +83,6 @@ describe('pinnipedAuthenticator', () => {
.sign(keyPair.privateKey);
});
beforeEach(() => {
jest.clearAllMocks();
@@ -84,20 +96,51 @@ describe('pinnipedAuthenticator', () => {
ctx.json(issuerMetadata),
),
),
)
implementation = pinnipedAuthenticator.initialize({
rest.get('https://pinniped.test/jwks.json', async (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ keys: [{ ...publicKey }] })),
),
rest.post('https://pinniped.test/oauth2/token', async (req, res, ctx) => {
const formBody = new URLSearchParams(await req.text());
const isGrantTypeTokenExchange =
formBody.get('grant_type') ===
'urn:ietf:params:oauth:grant-type:token-exchange';
const hasValidTokenExchangeParams =
formBody.get('subject_token') === 'accessToken' &&
formBody.get('audience') === 'test_cluster' &&
formBody.get('subject_token_type') ===
'urn:ietf:params:oauth:token-type:access_token' &&
formBody.get('requested_token_type') ===
'urn:ietf:params:oauth:token-type:jwt';
return res(
req.headers.get('Authorization') &&
(!isGrantTypeTokenExchange || hasValidTokenExchangeParams)
? ctx.json({
access_token: isGrantTypeTokenExchange
? clusterScopedIdToken
: 'accessToken',
refresh_token: 'refreshToken',
...(!isGrantTypeTokenExchange && { id_token: idToken }),
scope: 'testScope',
})
: ctx.status(401),
);
}),
);
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', () => {
@@ -108,7 +151,7 @@ describe('pinnipedAuthenticator', () => {
fakeSession = {};
startRequest = {
state: encodeOAuthState(oauthState),
req: {
req: {
method: 'GET',
url: 'test',
session: fakeSession,
@@ -117,16 +160,22 @@ describe('pinnipedAuthenticator', () => {
});
it('redirects to authorization endpoint returned from OIDC metadata endpoint', async () => {
const startResponse = await pinnipedAuthenticator.start(startRequest, implementation);
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 startResponse = await pinnipedAuthenticator.start(
startRequest,
implementation,
);
const { searchParams } = new URL(startResponse.url);
expect(searchParams.get('response_type')).toBe('code');
@@ -134,7 +183,10 @@ describe('pinnipedAuthenticator', () => {
it('persists audience parameter in oauth state', async () => {
startRequest.req.query = { audience: 'test-cluster' };
const startResponse = await pinnipedAuthenticator.start(startRequest, implementation);
const startResponse = await pinnipedAuthenticator.start(
startRequest,
implementation,
);
const { searchParams } = new URL(startResponse.url);
const stateParam = searchParams.get('state');
const decodedState = decodeOAuthState(stateParam!);
@@ -147,14 +199,20 @@ describe('pinnipedAuthenticator', () => {
});
it('passes client ID from config', async () => {
const startResponse = await pinnipedAuthenticator.start(startRequest, implementation);
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 startResponse = await pinnipedAuthenticator.start(
startRequest,
implementation,
);
const { searchParams } = new URL(startResponse.url);
expect(searchParams.get('redirect_uri')).toBe(
@@ -163,7 +221,10 @@ describe('pinnipedAuthenticator', () => {
});
it('generates PKCE challenge', async () => {
const startResponse = await pinnipedAuthenticator.start(startRequest, implementation);
const startResponse = await pinnipedAuthenticator.start(
startRequest,
implementation,
);
const { searchParams } = new URL(startResponse.url);
expect(searchParams.get('code_challenge_method')).toBe('S256');
@@ -176,7 +237,10 @@ describe('pinnipedAuthenticator', () => {
});
it('requests sufficient scopes for token exchange by default', async () => {
const startResponse = await pinnipedAuthenticator.start(startRequest, implementation);
const startResponse = await pinnipedAuthenticator.start(
startRequest,
implementation,
);
const { searchParams } = new URL(startResponse.url);
const scopes = searchParams.get('scope')?.split(' ') ?? [];
@@ -191,7 +255,10 @@ describe('pinnipedAuthenticator', () => {
});
it('encodes OAuth state in query param', async () => {
const startResponse = await pinnipedAuthenticator.start(startRequest, implementation);
const startResponse = await pinnipedAuthenticator.start(
startRequest,
implementation,
);
const { searchParams } = new URL(startResponse.url);
const stateParam = searchParams.get('state');
const decodedState = decodeOAuthState(stateParam!);
@@ -201,32 +268,236 @@ describe('pinnipedAuthenticator', () => {
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)
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;
describe('#authenticate', () => {
let handlerRequest: OAuthAuthenticatorAuthenticateInput;
// 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;
// });
beforeEach(() => {
handlerRequest = {
req: {
method: 'GET',
url: `https://test?code=authorization_code&state=${encodeOAuthState(
oauthState,
)}`,
session: {
'oidc:pinniped.test': {
state: encodeOAuthState(oauthState),
},
},
} as unknown as express.Request,
};
});
// })
})
it('exchanges authorization code for access token', async () => {
const handlerResponse = await pinnipedAuthenticator.authenticate(
handlerRequest,
implementation,
);
const accessToken = handlerResponse.session.accessToken;
expect(accessToken).toEqual('accessToken');
});
it('exchanges authorization code for refresh token', async () => {
const handlerResponse = await pinnipedAuthenticator.authenticate(
handlerRequest,
implementation,
);
const refreshToken = handlerResponse.session.refreshToken;
expect(refreshToken).toEqual('refreshToken');
});
it('returns granted scope', async () => {
const handlerResponse = await pinnipedAuthenticator.authenticate(
handlerRequest,
implementation,
);
const responseScope = handlerResponse.session.scope;
expect(responseScope).toEqual('testScope');
});
it('returns cluster-scoped ID token when audience is specified', async () => {
oauthState.audience = 'test_cluster';
handlerRequest = {
req: {
method: 'GET',
url: `https://test?code=authorization_code&state=${encodeOAuthState(
oauthState,
)}`,
session: {
'oidc:pinniped.test': {
state: encodeOAuthState(oauthState),
},
},
} as unknown as express.Request,
};
const handlerResponse = await pinnipedAuthenticator.authenticate(
handlerRequest,
implementation,
);
expect(handlerResponse.session.idToken).toEqual(clusterScopedIdToken);
}, 70000);
it('fails on network error during token exchange', async () => {
mswServer.use(
rest.post(
'https://pinniped.test/oauth2/token',
async (req, res, ctx) => {
const formBody = new URLSearchParams(await req.text());
const isGrantTypeTokenExchange =
formBody.get('grant_type') ===
'urn:ietf:params:oauth:grant-type:token-exchange';
const hasValidTokenExchangeParams =
formBody.get('subject_token') === 'accessToken' &&
formBody.get('audience') === 'test_cluster' &&
formBody.get('subject_token_type') ===
'urn:ietf:params:oauth:token-type:access_token' &&
formBody.get('requested_token_type') ===
'urn:ietf:params:oauth:token-type:jwt';
mswServer.use(
rest.post(
'https://pinniped.test/oauth2/token',
async (_req, response, _ctx) =>
response.networkError('Connection timed out'),
),
);
return res(
req.headers.get('Authorization') &&
(!isGrantTypeTokenExchange || hasValidTokenExchangeParams)
? ctx.json({
access_token: isGrantTypeTokenExchange
? clusterScopedIdToken
: 'accessToken',
refresh_token: 'refreshToken',
...(!isGrantTypeTokenExchange && { id_token: idToken }),
scope: 'testScope',
})
: ctx.status(401),
);
},
),
);
oauthState.audience = 'test_cluster';
handlerRequest = {
req: {
method: 'GET',
url: `https://test?code=authorization_code&state=${encodeOAuthState(
oauthState,
)}`,
session: {
'oidc:pinniped.test': {
state: encodeOAuthState(oauthState),
},
},
} as unknown as express.Request,
};
await expect(
pinnipedAuthenticator.authenticate(handlerRequest, implementation),
).rejects.toThrow(
`Failed to get cluster specific ID token for "test_cluster", RFC8693 token exchange failed with error: NetworkError: Connection timed out`,
);
});
it('fails without authorization code', async () => {
handlerRequest.req.url = 'https://test.com';
return expect(
pinnipedAuthenticator.authenticate(handlerRequest, implementation),
).rejects.toThrow('Unexpected redirect');
});
it('fails without oauth state', async () => {
return expect(
pinnipedAuthenticator.authenticate(
{
req: {
method: 'GET',
url: `https://test?code=authorization_code}`,
session: {
['oidc:pinniped.test']: {
state: { handle: 'sessionid', code_verifier: 'foo' },
},
},
} as unknown as express.Request,
},
implementation,
),
).rejects.toThrow(
'Authentication rejected, state missing from the response',
);
});
it('fails when request has no session', async () => {
return expect(
pinnipedAuthenticator.authenticate(
{
req: {
method: 'GET',
url: 'https://test.com',
} as unknown as express.Request,
},
implementation,
),
).rejects.toThrow('authentication requires session support');
});
});
describe('#refresh', () => {
let refreshRequest: OAuthAuthenticatorRefreshInput;
beforeEach(() => {
refreshRequest = {
scope: '',
refreshToken: 'otherRefreshToken',
req: {} as express.Request,
};
});
it('gets new refresh token', async () => {
const refreshResponse = await pinnipedAuthenticator.refresh(
refreshRequest,
implementation,
);
expect(refreshResponse.session.refreshToken).toBe('refreshToken');
});
it('gets access token', async () => {
const refreshResponse = await pinnipedAuthenticator.refresh(
refreshRequest,
implementation,
);
expect(refreshResponse.session.accessToken).toBe('accessToken');
});
it('gets id token', async () => {
const refreshResponse = await pinnipedAuthenticator.refresh(
refreshRequest,
implementation,
);
expect(refreshResponse.session.idToken).toBe(idToken);
});
});
});
@@ -1,18 +1,34 @@
//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'
/*
* 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 { PassportDoneCallback } from '@backstage/plugin-auth-node';
import {
createOAuthAuthenticator,
decodeOAuthState,
encodeOAuthState,
} from '@backstage/plugin-auth-node';
import { Issuer, TokenSet, Strategy as OidcStrategy } from 'openid-client';
export const pinnipedAuthenticator = createOAuthAuthenticator({
defaultProfileTransform:
PassportOAuthAuthenticatorHelper.defaultProfileTransform,
defaultProfileTransform: async (_r, _c) => ({ profile: {} }),
async initialize({ callbackUrl, config }) {
const issuer = await Issuer.discover(
`${config.getString('federationDomain')}/.well-known/openid-configuration`,
)
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'),
@@ -20,34 +36,38 @@ export const pinnipedAuthenticator = createOAuthAuthenticator({
redirect_uris: [callbackUrl],
response_types: ['code'],
scope: config.getOptionalString('scope') || '',
id_token_signed_response_alg: 'ES256',
});
const strategy = new OidcStrategy(
{
client,
passReqToCallback: false,
},
(
tokenset: TokenSet,
done: PassportDoneCallback<
{ tokenset: TokenSet },
{
refreshToken?: string;
}
>,
) => {
done(undefined, { tokenset }, {});
},
);
const strategy = new OidcStrategy({
client,
passReqToCallback: false,
},(
tokenset: TokenSet,
done: PassportDoneCallback<{ tokenset: TokenSet }, {
refreshToken?: string;
}>,
) => {
done(undefined, { tokenset }, {});
},)
return ({ strategy, client })
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 { strategy } = await implementation;
const stringifiedAudience = input.req.query?.audience as string;
const decodedState = decodeOAuthState(input.state)
const state = { ...decodedState, audience: stringifiedAudience }
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',
input.scope ||
'openid pinniped:request-audience username offline_access',
state: encodeOAuthState(state),
};
@@ -64,10 +84,12 @@ export const pinnipedAuthenticator = createOAuthAuthenticator({
async authenticate(input, implementation) {
const { strategy, client } = await implementation;
const { req } = input
const { req } = input;
const { searchParams } = new URL(req.url, 'https://pinniped.com');
const stateParam = searchParams.get('state');
const audience = stateParam ? decodeOAuthState(stateParam).audience : undefined;
const audience = stateParam
? decodeOAuthState(stateParam).audience
: undefined;
return new Promise((resolve, reject) => {
strategy.success = user => {
@@ -79,20 +101,27 @@ export const pinnipedAuthenticator = createOAuthAuthenticator({
audience,
subject_token_type:
'urn:ietf:params:oauth:token-type:access_token',
inputuested_token_type: 'urn:ietf:params:oauth:token-type:jwt',
requested_token_type: 'urn:ietf:params:oauth:token-type:jwt',
})
.then(tokenset => tokenset.access_token)
.catch(err =>
reject(
new Error(
`Failed to get cluster specific ID token for "${audience}", RFC8693 token exchange failed with error: ${err}`,
),
),
)
: Promise.resolve(user.tokenset.id_token)
).then(idToken => {
resolve({
fullProfile: {provider: " ",id: " ",displayName: " "},
fullProfile: { provider: ' ', id: ' ', displayName: ' ' },
session: {
accessToken: user.tokenset.access_token!,
tokenType: "random",
tokenType: user.tokenset.token_type ?? 'bearer',
scope: user.tokenset.scope!,
idToken,
refreshToken: user.tokenset.refresh_token
}
refreshToken: user.tokenset.refresh_token,
},
});
});
};
@@ -123,16 +152,15 @@ export const pinnipedAuthenticator = createOAuthAuthenticator({
}
resolve({
fullProfile: {provider: " ",id: " ",displayName: " "},
fullProfile: { provider: ' ', id: ' ', displayName: ' ' },
session: {
accessToken: tokenset.access_token!,
tokenType: "random",
tokenType: tokenset.token_type ?? 'bearer',
scope: tokenset.scope!,
idToken: tokenset.id_token,
refreshToken: tokenset.refresh_token
}
refreshToken: tokenset.refresh_token,
},
});
});
},
})
});
@@ -1,24 +1,44 @@
import { setupRequestMockHandlers } from "@backstage/backend-test-utils"
import { authModulePinnipedProvider } from "./module"
/*
* 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 { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import request from 'supertest';
import { setupServer } from 'msw/node';
import { rest } from 'msw';
import { Server } from "http";
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 {
AuthProviderRouteHandlers,
createOAuthRouteHandlers,
} from '@backstage/plugin-auth-node';
import Router from 'express-promise-router';
import { pinnipedAuthenticator } from "./authenticator";
import { ConfigReader } from "@backstage/config";
import { pinnipedAuthenticator } from './authenticator';
import { ConfigReader } from '@backstage/config';
import { JWK, SignJWT, exportJWK, generateKeyPair } from 'jose';
describe('authModulePinnipedProvider', () => {
let app: express.Express;
let backstageServer: Server;
let appUrl: string;
let providerRouteHandler: AuthProviderRouteHandlers
let providerRouteHandler: AuthProviderRouteHandlers;
let idToken: string;
let publicKey: JWK;
const mswServer = setupServer();
setupRequestMockHandlers(mswServer);
@@ -49,8 +69,27 @@ describe('authModulePinnipedProvider', () => {
request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'],
};
const clusterScopedIdToken = 'dummy-token';
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();
jest.clearAllMocks();
mswServer.use(
rest.get(
@@ -62,7 +101,55 @@ describe('authModulePinnipedProvider', () => {
ctx.json(issuerMetadata),
),
),
)
rest.get(
'https://pinniped.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()),
);
},
),
rest.get('https://pinniped.test/jwks.json', async (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ keys: [{ ...publicKey }] })),
),
rest.post('https://pinniped.test/oauth2/token', async (req, res, ctx) => {
const formBody = new URLSearchParams(await req.text());
const isGrantTypeTokenExchange =
formBody.get('grant_type') ===
'urn:ietf:params:oauth:grant-type:token-exchange';
const hasValidTokenExchangeParams =
formBody.get('subject_token') === 'accessToken' &&
formBody.get('audience') === 'test_cluster' &&
formBody.get('subject_token_type') ===
'urn:ietf:params:oauth:token-type:access_token' &&
formBody.get('requested_token_type') ===
'urn:ietf:params:oauth:token-type:jwt';
return res(
req.headers.get('Authorization') &&
(!isGrantTypeTokenExchange || hasValidTokenExchangeParams)
? ctx.json({
access_token: isGrantTypeTokenExchange
? clusterScopedIdToken
: 'accessToken',
refresh_token: 'refreshToken',
...(!isGrantTypeTokenExchange && { id_token: idToken }),
scope: 'testScope',
})
: ctx.status(401),
);
}),
);
const secret = 'secret';
app = express()
@@ -110,33 +197,64 @@ describe('authModulePinnipedProvider', () => {
}),
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);
})
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`,
);
const agent = request.agent(backstageServer)
expect(startResponse.status).toBe(302);
});
const startResponse = await agent.get(`/api/auth/pinniped/start?env=development&audience=test_cluster`);
it('/handler/frame exchanges authorization code from #start for Cluster Specific ID token', async () => {
const agent = request.agent('');
expect(startResponse.status).toBe(302)
}, 70000)
})
// make /start request with audience parameter
const startResponse = await agent.get(
`${appUrl}/api/auth/pinniped/start?env=development&audience=test_cluster`,
);
// follow redirect to authorization endpoint
const authorizationResponse = await agent.get(
startResponse.header.location,
);
// follow redirect to token_endpoint
const handlerResponse = await agent.get(
authorizationResponse.header.location,
);
expect(handlerResponse.text).toContain(
encodeURIComponent(
JSON.stringify({
type: 'authorization_response',
response: {
profile: {},
providerInfo: {
idToken: clusterScopedIdToken,
accessToken: 'accessToken',
scope: 'testScope',
},
},
}),
),
);
});
});
+1
View File
@@ -45,6 +45,7 @@
"@backstage/plugin-auth-backend-module-google-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-microsoft-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-pinniped-provider": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"@backstage/plugin-catalog-node": "workspace:^",
"@backstage/types": "workspace:^",
+5 -12
View File
@@ -92,18 +92,11 @@ export type OAuthProviderInfo = {
scope: string;
};
/** @public */
export type OAuthState = {
/* A type for the serialized value in the `state` parameter of the OAuth authorization flow
*/
nonce: string;
env: string;
origin?: string;
scope?: string;
redirectUrl?: string;
flow?: string;
audience? : string;
};
/**
* @public
* @deprecated import from `@backstage/plugin-auth-node` instead
*/
export type OAuthState = _OAuthState;
/**
* @public
@@ -130,9 +130,7 @@ export class OidcAuthProvider implements OAuthHandlers {
if (!tokenset.access_token) {
throw new Error('Refresh failed');
}
const userinfo = client.issuer.userinfo_endpoint
? await client.userinfo(tokenset.access_token)
: { sub: '' };
const userinfo = await client.userinfo(tokenset.access_token);
return {
response: await this.handleResult({ tokenset, userinfo }),
@@ -161,23 +159,17 @@ export class OidcAuthProvider implements OAuthHandlers {
},
(
tokenset: TokenSet,
userinfo:
| UserinfoResponse
| PassportDoneCallback<OidcAuthResult, PrivateInfo>,
done?: PassportDoneCallback<OidcAuthResult, PrivateInfo>,
userinfo: UserinfoResponse,
done: PassportDoneCallback<OidcAuthResult, PrivateInfo>,
) => {
if (typeof userinfo === 'function') {
userinfo(
undefined,
{ tokenset, userinfo: { sub: '' } },
{
refreshToken: tokenset.refresh_token,
},
if (typeof done !== 'function') {
throw new Error(
'OIDC IdP must provide a userinfo_endpoint in the metadata response',
);
}
done!(
done(
undefined,
{ tokenset, userinfo: userinfo as UserinfoResponse },
{ tokenset, userinfo },
{
refreshToken: tokenset.refresh_token,
},
@@ -13,496 +13,24 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import {
OAuthRefreshRequest,
OAuthStartRequest,
encodeState,
readState,
} from '../../lib/oauth';
import { PinnipedAuthProvider, PinnipedProviderOptions } from './provider';
import { setupServer } from 'msw/node';
import { rest } from 'msw';
import express from 'express';
import { OAuthState } from '../../lib/oauth';
import { Server } from 'http';
import cookieParser from 'cookie-parser';
import session from 'express-session';
import passport from 'passport';
import { ConfigReader } from '@backstage/config';
import Router from 'express-promise-router';
import { pinniped } from '.';
import { AuthProviderRouteHandlers } from '../types';
import { getVoidLogger } from '@backstage/backend-common';
import { AddressInfo } from 'net';
import request from 'supertest';
import { JWK, SignJWT, exportJWK, generateKeyPair } from 'jose';
describe('PinnipedAuthProvider', () => {
let provider: PinnipedAuthProvider;
let idToken: string;
let publicKey: JWK;
let oauthState: OAuthState;
import { pinnipedAuthenticator } from '@backstage/plugin-auth-backend-module-pinniped-provider';
import { createOAuthProviderFactory } from '@backstage/plugin-auth-node';
import { pinniped } from './provider';
const mswServer = setupServer();
setupRequestMockHandlers(mswServer);
jest.mock('@backstage/plugin-auth-node', () => ({
...jest.requireActual('@backstage/plugin-auth-node'),
createOAuthProviderFactory: jest.fn(() => 'provider-factory'),
}));
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'],
};
describe('createPinnipedAuthProvider', () => {
afterEach(() => jest.clearAllMocks());
const pinnipedProviderOptions: PinnipedProviderOptions = {
federationDomain: 'https://federationDomain.test',
clientId: 'clientId',
clientSecret: 'secret',
callbackUrl: 'https://backstage.test/callback',
};
it('should be created', async () => {
expect(pinniped.create()).toBe('provider-factory');
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: pinnipedProviderOptions.clientId,
exp: Date.now() + 10000,
})
.setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid })
.sign(keyPair.privateKey);
});
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),
),
),
rest.get(
'https://pinniped.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()),
);
},
),
rest.get('https://pinniped.test/jwks.json', async (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ keys: [{ ...publicKey }] })),
),
rest.post('https://pinniped.test/oauth2/token', async (req, res, ctx) => {
const formBody = new URLSearchParams(await req.text());
const isGrantTypeTokenExchange =
formBody.get('grant_type') ===
'urn:ietf:params:oauth:grant-type:token-exchange';
const hasValidTokenExchangeParams =
formBody.get('subject_token') === 'accessToken' &&
formBody.get('audience') === 'test_cluster' &&
formBody.get('subject_token_type') ===
'urn:ietf:params:oauth:token-type:access_token' &&
formBody.get('requested_token_type') ===
'urn:ietf:params:oauth:token-type:jwt';
return res(
req.headers.get('Authorization') &&
(!isGrantTypeTokenExchange || hasValidTokenExchangeParams)
? ctx.json({
access_token: isGrantTypeTokenExchange
? clusterScopedIdToken
: 'accessToken',
refresh_token: 'refreshToken',
...(!isGrantTypeTokenExchange && { id_token: idToken }),
scope: 'testScope',
})
: ctx.status(401),
);
}),
);
oauthState = {
nonce: 'nonce',
env: 'env',
};
provider = new PinnipedAuthProvider(pinnipedProviderOptions);
});
describe('#start', () => {
let fakeSession: Record<string, any>;
let startRequest: OAuthStartRequest;
beforeEach(() => {
fakeSession = {};
startRequest = {
session: fakeSession,
method: 'GET',
url: 'test',
state: oauthState,
} as unknown as OAuthStartRequest;
});
it('redirects to authorization endpoint returned from OIDC metadata endpoint', async () => {
const startResponse = await provider.start(startRequest);
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 provider.start(startRequest);
const { searchParams } = new URL(startResponse.url);
expect(searchParams.get('response_type')).toBe('code');
});
it('persists audience parameter in oauth state', async () => {
startRequest.query = { audience: 'test-cluster' };
const startResponse = await provider.start(startRequest);
const { searchParams } = new URL(startResponse.url);
const stateParam = searchParams.get('state');
const decodedState = readState(stateParam!);
expect(decodedState).toMatchObject({
nonce: 'nonce',
env: 'env',
audience: 'test-cluster',
});
});
it('passes client ID from config', async () => {
const startResponse = await provider.start(startRequest);
const { searchParams } = new URL(startResponse.url);
expect(searchParams.get('client_id')).toBe('clientId');
});
it('passes callback URL from config', async () => {
const startResponse = await provider.start(startRequest);
const { searchParams } = new URL(startResponse.url);
expect(searchParams.get('redirect_uri')).toBe(
'https://backstage.test/callback',
);
});
it('generates PKCE challenge', async () => {
const startResponse = await provider.start(startRequest);
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 provider.start(startRequest);
expect(fakeSession['oidc:pinniped.test'].code_verifier).toBeDefined();
});
it('requests sufficient scopes for token exchange', async () => {
const startResponse = await provider.start(startRequest);
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 provider.start(startRequest);
const { searchParams } = new URL(startResponse.url);
const stateParam = searchParams.get('state');
const decodedState = readState(stateParam!);
expect(decodedState).toMatchObject(oauthState);
});
it('fails when request has no session', async () => {
return expect(
provider.start({
method: 'GET',
url: 'test',
} as unknown as OAuthStartRequest),
).rejects.toThrow('authentication requires session support');
});
});
describe('#handler', () => {
let handlerRequest: express.Request;
beforeEach(() => {
handlerRequest = {
method: 'GET',
url: `https://test?code=authorization_code&state=${encodeState(
oauthState,
)}`,
session: {
'oidc:pinniped.test': {
state: encodeState(oauthState),
},
},
} as unknown as express.Request;
});
it('exchanges authorization code for access token', async () => {
const handlerResponse = await provider.handler(handlerRequest);
const accessToken = handlerResponse.response.providerInfo.accessToken;
expect(accessToken).toEqual('accessToken');
});
it('exchanges authorization code for refresh token', async () => {
const handlerResponse = await provider.handler(handlerRequest);
const refreshToken = handlerResponse.refreshToken;
expect(refreshToken).toEqual('refreshToken');
});
it('returns granted scope', async () => {
const handlerResponse = await provider.handler(handlerRequest);
const responseScope = handlerResponse.response.providerInfo.scope;
expect(responseScope).toEqual('testScope');
});
it('returns cluster-scoped ID token when audience is specified', async () => {
oauthState.audience = 'test_cluster';
handlerRequest = {
method: 'GET',
url: `https://test?code=authorization_code&state=${encodeState(
oauthState,
)}`,
session: {
'oidc:pinniped.test': {
state: encodeState(oauthState),
},
},
} as unknown as express.Request;
const handlerResponse = await provider.handler(handlerRequest);
const responseIdToken = handlerResponse.response.providerInfo.idToken;
expect(responseIdToken).toEqual(clusterScopedIdToken);
});
it('fails without authorization code', async () => {
handlerRequest.url = 'https://test.com';
return expect(provider.handler(handlerRequest)).rejects.toThrow(
'Unexpected redirect',
);
});
it('fails without oauth state', async () => {
return expect(
provider.handler({
method: 'GET',
url: `https://test?code=authorization_code}`,
session: {
['oidc:pinniped.test']: {
state: { handle: 'sessionid', code_verifier: 'foo' },
},
},
} as unknown as express.Request),
).rejects.toThrow(
'Authentication rejected, state missing from the response',
);
});
it('fails when request has no session', async () => {
return expect(
provider.handler({
method: 'GET',
url: 'https://test.com',
} as unknown as OAuthStartRequest),
).rejects.toThrow('authentication requires session support');
});
});
describe('#refresh', () => {
let refreshRequest: OAuthRefreshRequest;
beforeEach(() => {
refreshRequest = {
refreshToken: 'otherRefreshToken',
} as unknown as OAuthRefreshRequest;
});
it('gets new refresh token', async () => {
const { refreshToken } = await provider.refresh(refreshRequest);
expect(refreshToken).toBe('refreshToken');
});
it('gets access token', async () => {
const { response } = await provider.refresh(refreshRequest);
expect(response.providerInfo.accessToken).toBe('accessToken');
});
it('gets id token', async () => {
const { response } = await provider.refresh(refreshRequest);
expect(response.providerInfo.idToken).toBe(idToken);
});
});
describe('integration', () => {
let app: express.Express;
let providerRouteHandler: AuthProviderRouteHandlers;
let backstageServer: Server;
let appUrl: string;
beforeEach(async () => {
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 = pinniped.create()({
providerId: 'pinniped',
globalConfig: {
baseUrl: `${appUrl}/api/auth`,
appUrl,
isOriginAllowed: _ => true,
},
config: new ConfigReader({
development: {
federationDomain: 'https://federationDomain.test',
clientId: 'clientId',
clientSecret: 'clientSecret',
},
}),
logger: getVoidLogger(),
resolverContext: {
issueToken: async _ => ({ token: '' }),
findCatalogUser: async _ => ({
entity: {
apiVersion: '',
kind: '',
metadata: { name: '' },
},
}),
signInWithCatalogUser: async _ => ({ token: '' }),
},
baseUrl: `${appUrl}/api/auth`,
appUrl,
isOriginAllowed: _ => true,
});
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('/handler/frame exchanges authorization code from #start for Cluster Specific ID token', async () => {
const agent = request.agent('');
// make /start request with audience parameter
const startResponse = await agent.get(
`${appUrl}/api/auth/pinniped/start?env=development&audience=test_cluster`,
);
// follow redirect to authorization endpoint
const authorizationResponse = await agent.get(
startResponse.header.location,
);
// follow redirect to token_endpoint
const handlerResponse = await agent.get(
authorizationResponse.header.location,
);
expect(handlerResponse.text).toContain(
encodeURIComponent(
JSON.stringify({
type: 'authorization_response',
response: {
providerInfo: {
accessToken: 'accessToken',
scope: 'testScope',
idToken: clusterScopedIdToken,
},
profile: {},
},
}),
),
);
expect(createOAuthProviderFactory).toHaveBeenCalledWith({
authenticator: pinnipedAuthenticator,
});
});
});
@@ -13,179 +13,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Client,
Issuer,
Strategy as OidcStrategy,
TokenSet,
} from 'openid-client';
import {
OAuthHandlers,
OAuthProviderOptions,
OAuthRefreshRequest,
OAuthResponse,
OAuthStartRequest,
encodeState,
readState,
} from '../../lib/oauth';
import { PassportDoneCallback } from '../../lib/passport';
import { OAuthStartResponse } from '../types';
import express from 'express';
import { OAuthAdapter, OAuthEnvironmentHandler } from '../../lib/oauth';
import { pinnipedAuthenticator } from '@backstage/plugin-auth-backend-module-pinniped-provider';
import { createAuthProviderIntegration } from '../createAuthProviderIntegration';
type OidcImpl = {
strategy: OidcStrategy<undefined, Client>;
client: Client;
};
type PrivateInfo = {
refreshToken?: string;
};
export type PinnipedProviderOptions = OAuthProviderOptions & {
federationDomain: string;
clientId: string;
clientSecret: string;
callbackUrl: string;
scope?: string;
};
export class PinnipedAuthProvider implements OAuthHandlers {
private readonly implementation: Promise<OidcImpl>;
constructor(options: PinnipedProviderOptions) {
this.implementation = this.setupStrategy(options);
}
async start(req: OAuthStartRequest): Promise<OAuthStartResponse> {
const { strategy } = await this.implementation;
const stringifiedAudience = req.query?.audience as string;
const state = { ...req.state, audience: stringifiedAudience };
const options: Record<string, string> = {
scope:
req.scope || 'openid pinniped:request-audience username offline_access',
state: encodeState(state),
};
return new Promise((resolve, reject) => {
strategy.redirect = (url: string) => {
resolve({ url });
};
strategy.error = (error: Error) => {
reject(error);
};
strategy.authenticate(req, { ...options });
});
}
async handler(
req: express.Request,
): Promise<{ response: OAuthResponse; refreshToken?: string }> {
const { strategy, client } = await this.implementation;
const { searchParams } = new URL(req.url, 'https://pinniped.com');
const stateParam = searchParams.get('state');
const audience = stateParam ? readState(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',
requested_token_type: 'urn:ietf:params:oauth:token-type:jwt',
})
.then(tokenset => tokenset.access_token)
: Promise.resolve(user.tokenset.id_token)
).then(idToken => {
resolve({
response: {
providerInfo: {
accessToken: user.tokenset.access_token,
scope: user.tokenset.scope,
idToken,
},
profile: {},
},
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(
req: OAuthRefreshRequest,
): Promise<{ response: OAuthResponse; refreshToken?: string }> {
const { client } = await this.implementation;
const tokenset = await client.refresh(req.refreshToken);
return new Promise((resolve, reject) => {
if (!tokenset.access_token) {
reject(new Error('Refresh Failed'));
}
resolve({
response: {
providerInfo: {
accessToken: tokenset.access_token!,
scope: tokenset.scope!,
idToken: tokenset.id_token,
},
profile: {},
},
refreshToken: tokenset.refresh_token,
});
});
}
private async setupStrategy(
options: PinnipedProviderOptions,
): Promise<OidcImpl> {
const issuer = await Issuer.discover(
`${options.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: options.clientId,
client_secret: options.clientSecret,
redirect_uris: [options.callbackUrl],
response_types: ['code'],
scope: options.scope || '',
});
const strategy = new OidcStrategy(
{
client,
passReqToCallback: false,
},
(
tokenset: TokenSet,
done: PassportDoneCallback<{ tokenset: TokenSet }, PrivateInfo>,
) => {
done(undefined, { tokenset }, {});
},
);
return { strategy, client };
}
}
import { createOAuthProviderFactory } from '@backstage/plugin-auth-node';
/**
* Auth provider integration for Pinniped auth
@@ -194,27 +25,8 @@ export class PinnipedAuthProvider implements OAuthHandlers {
*/
export const pinniped = createAuthProviderIntegration({
create() {
return ({ providerId, globalConfig, config }) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const federationDomain = envConfig.getString('federationDomain');
const customCallbackUrl = envConfig.getOptionalString('callbackUrl');
const callbackUrl =
customCallbackUrl ||
`${globalConfig.baseUrl}/${providerId}/handler/frame`;
const provider = new PinnipedAuthProvider({
federationDomain,
clientId,
clientSecret,
callbackUrl,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
providerId,
callbackUrl,
});
});
return createOAuthProviderFactory({
authenticator: pinnipedAuthenticator,
});
},
});
+25 -5
View File
@@ -4979,14 +4979,27 @@ __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":
"@backstage/plugin-auth-backend-module-pinniped-provider@workspace:^, @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-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.0
openid-client: ^5.4.3
passport: ^0.6.0
supertest: ^6.3.3
languageName: unknown
linkType: soft
@@ -5010,6 +5023,7 @@ __metadata:
"@backstage/plugin-auth-backend-module-google-provider": "workspace:^"
"@backstage/plugin-auth-backend-module-microsoft-provider": "workspace:^"
"@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^"
"@backstage/plugin-auth-backend-module-pinniped-provider": "workspace:^"
"@backstage/plugin-auth-node": "workspace:^"
"@backstage/plugin-catalog-node": "workspace:^"
"@backstage/types": "workspace:^"
@@ -25908,7 +25922,6 @@ __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:^"
@@ -26117,7 +26130,7 @@ __metadata:
languageName: node
linkType: hard
"express-session@npm:^1.17.1":
"express-session@npm:^1.17.1, express-session@npm:^1.17.3":
version: 1.17.3
resolution: "express-session@npm:1.17.3"
dependencies:
@@ -30330,6 +30343,13 @@ __metadata:
languageName: node
linkType: hard
"jose@npm:^4.14.6":
version: 4.14.6
resolution: "jose@npm:4.14.6"
checksum: eae81a234e7bf1446b1bd80722b3462b014e3835b155c3a7799c1c5043163a53a0dc28d347004151b031e6b7b863403aabf8814d9cc217ce21f8c2f3ebd4b335
languageName: node
linkType: hard
"jose@npm:^4.15.1, jose@npm:^4.6.0":
version: 4.15.3
resolution: "jose@npm:4.15.3"
@@ -33457,7 +33477,7 @@ __metadata:
languageName: node
linkType: hard
"msw@npm:^1.0.0, msw@npm:^1.0.1, msw@npm:^1.2.1, msw@npm:^1.2.3, msw@npm:^1.3.1":
"msw@npm:^1.0.0, msw@npm:^1.0.1, msw@npm:^1.2.1, msw@npm:^1.2.3, msw@npm:^1.3.0, msw@npm:^1.3.1":
version: 1.3.2
resolution: "msw@npm:1.3.2"
dependencies:
@@ -34531,7 +34551,7 @@ __metadata:
languageName: node
linkType: hard
"openid-client@npm:^5.2.1, openid-client@npm:^5.3.0":
"openid-client@npm:^5.2.1, openid-client@npm:^5.3.0, openid-client@npm:^5.4.3":
version: 5.6.1
resolution: "openid-client@npm:5.6.1"
dependencies: