Merge pull request #19846 from RubenV-dev/pinniped-auth
Pinniped Auth Provider
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend-module-pinniped-provider': minor
|
||||
---
|
||||
|
||||
Add new Pinniped auth module and authenticator to be used alongside the new Pinniped auth provider.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-node': patch
|
||||
---
|
||||
|
||||
Adding optional audience parameter to OAuthState type declaration
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
@@ -0,0 +1,7 @@
|
||||
# Auth Module: Pinniped Provider
|
||||
|
||||
This module provides an Pinniped auth provider implementation for `@backstage/plugin-auth-backend`.
|
||||
|
||||
## Links
|
||||
|
||||
- [Backstage](https://backstage.io)
|
||||
@@ -0,0 +1,28 @@
|
||||
## API Report File for "@backstage/plugin-auth-backend-module-pinniped-provider"
|
||||
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { BackendFeature } from '@backstage/backend-plugin-api';
|
||||
import { BaseClient } from 'openid-client';
|
||||
import { OAuthAuthenticator } from '@backstage/plugin-auth-node';
|
||||
import { Strategy } from 'openid-client';
|
||||
import { TokenSet } from 'openid-client';
|
||||
|
||||
// @public (undocumented)
|
||||
export const authModulePinnipedProvider: () => BackendFeature;
|
||||
|
||||
// @public (undocumented)
|
||||
export const pinnipedAuthenticator: OAuthAuthenticator<
|
||||
Promise<{
|
||||
providerStrategy: Strategy<
|
||||
{
|
||||
tokenset: TokenSet;
|
||||
},
|
||||
BaseClient
|
||||
>;
|
||||
client: BaseClient;
|
||||
}>,
|
||||
unknown
|
||||
>;
|
||||
```
|
||||
@@ -0,0 +1,10 @@
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: backstage-plugin-auth-backend-module-pinniped-provider
|
||||
title: '@backstage/plugin-auth-backend-module-pinniped-provider'
|
||||
description: The pinniped-provider backend module for the auth plugin.
|
||||
spec:
|
||||
lifecycle: experimental
|
||||
type: backstage-backend-plugin-module
|
||||
owner: maintainers
|
||||
@@ -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,49 @@
|
||||
{
|
||||
"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:^",
|
||||
"@backstage/plugin-auth-node": "workspace:^",
|
||||
"openid-client": "^5.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/backend-defaults": "workspace:^",
|
||||
"@backstage/backend-test-utils": "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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
/*
|
||||
* 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 {
|
||||
OAuthAuthenticatorAuthenticateInput,
|
||||
OAuthAuthenticatorRefreshInput,
|
||||
OAuthAuthenticatorStartInput,
|
||||
OAuthState,
|
||||
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('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(() => {
|
||||
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/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', () => {
|
||||
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: OAuthAuthenticatorAuthenticateInput;
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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": Error: 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* 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 {
|
||||
Client,
|
||||
Issuer,
|
||||
TokenSet,
|
||||
Strategy as OidcStrategy,
|
||||
} from 'openid-client';
|
||||
|
||||
const rfc8693TokenExchange = async ({
|
||||
subject_token,
|
||||
target_audience,
|
||||
ctx,
|
||||
}: {
|
||||
subject_token: string;
|
||||
target_audience: string;
|
||||
ctx: Promise<{
|
||||
providerStrategy: OidcStrategy<{}>;
|
||||
client: Client;
|
||||
}>;
|
||||
}): Promise<string | undefined> => {
|
||||
const { client } = await ctx;
|
||||
return client
|
||||
.grant({
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
|
||||
subject_token,
|
||||
audience: target_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)
|
||||
.catch(err => {
|
||||
throw new Error(`RFC8693 token exchange failed with error: ${err}`);
|
||||
});
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export const pinnipedAuthenticator = createOAuthAuthenticator({
|
||||
defaultProfileTransform: async (_r, _c) => ({ profile: {} }),
|
||||
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') || '',
|
||||
id_token_signed_response_alg: 'ES256',
|
||||
});
|
||||
const providerStrategy = new OidcStrategy(
|
||||
{
|
||||
client,
|
||||
passReqToCallback: false,
|
||||
},
|
||||
(
|
||||
tokenset: TokenSet,
|
||||
done: PassportDoneCallback<
|
||||
{ tokenset: TokenSet },
|
||||
{
|
||||
refreshToken?: string;
|
||||
}
|
||||
>,
|
||||
) => {
|
||||
done(undefined, { tokenset }, {});
|
||||
},
|
||||
);
|
||||
|
||||
return { providerStrategy, client };
|
||||
},
|
||||
|
||||
async start(input, ctx) {
|
||||
const { providerStrategy } = await ctx;
|
||||
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) => {
|
||||
const strategy = Object.create(providerStrategy);
|
||||
strategy.redirect = (url: string) => {
|
||||
resolve({ url });
|
||||
};
|
||||
strategy.error = (error: Error) => {
|
||||
reject(error);
|
||||
};
|
||||
strategy.authenticate(input.req, { ...options });
|
||||
});
|
||||
},
|
||||
|
||||
async authenticate(input, ctx) {
|
||||
const { providerStrategy } = await ctx;
|
||||
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) => {
|
||||
const strategy = Object.create(providerStrategy);
|
||||
strategy.success = (user: any) => {
|
||||
(audience
|
||||
? rfc8693TokenExchange({
|
||||
subject_token: user.tokenset.access_token,
|
||||
target_audience: audience,
|
||||
ctx,
|
||||
}).catch(err =>
|
||||
reject(
|
||||
new Error(
|
||||
`Failed to get cluster specific ID token for "${audience}": ${err}`,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Promise.resolve(user.tokenset.id_token)
|
||||
).then(idToken => {
|
||||
resolve({
|
||||
fullProfile: { provider: '', id: '', displayName: '' },
|
||||
session: {
|
||||
accessToken: user.tokenset.access_token!,
|
||||
tokenType: user.tokenset.token_type ?? 'bearer',
|
||||
scope: user.tokenset.scope!,
|
||||
idToken,
|
||||
refreshToken: user.tokenset.refresh_token,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
strategy.fail = (info: any) => {
|
||||
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, ctx) {
|
||||
const { client } = await ctx;
|
||||
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: tokenset.token_type ?? 'bearer',
|
||||
scope: tokenset.scope!,
|
||||
idToken: tokenset.id_token,
|
||||
refreshToken: tokenset.refresh_token,
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export interface Config {
|
||||
/** Configuration options for the auth plugin */
|
||||
auth?: {
|
||||
providers?: {
|
||||
pinniped?: {
|
||||
[authEnv: string]: {
|
||||
clientId: string;
|
||||
federationDomain: string;
|
||||
/**
|
||||
* @visibility secret
|
||||
*/
|
||||
clientSecret: string;
|
||||
scope?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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,260 @@
|
||||
/*
|
||||
* 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 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';
|
||||
import { JWK, SignJWT, exportJWK, generateKeyPair } from 'jose';
|
||||
|
||||
describe('authModulePinnipedProvider', () => {
|
||||
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://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('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://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),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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: {
|
||||
profile: {},
|
||||
providerInfo: {
|
||||
idToken: clusterScopedIdToken,
|
||||
accessToken: 'accessToken',
|
||||
scope: 'testScope',
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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';
|
||||
|
||||
/** @public */
|
||||
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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -399,6 +399,7 @@ export type OAuthState = {
|
||||
scope?: string;
|
||||
redirectUrl?: string;
|
||||
flow?: string;
|
||||
audience?: string;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
|
||||
@@ -29,6 +29,7 @@ export type OAuthState = {
|
||||
scope?: string;
|
||||
redirectUrl?: string;
|
||||
flow?: string;
|
||||
audience?: string;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
|
||||
@@ -4970,6 +4970,30 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@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
|
||||
|
||||
"@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"
|
||||
@@ -26017,7 +26041,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:
|
||||
@@ -30221,7 +30245,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"jose@npm:^4.15.1, jose@npm:^4.6.0":
|
||||
"jose@npm:^4.14.6, jose@npm:^4.15.1, jose@npm:^4.6.0":
|
||||
version: 4.15.3
|
||||
resolution: "jose@npm:4.15.3"
|
||||
checksum: b76eeccc1d40d0eaf26dfaadc0f88fc15802c9105ab66a24ee223bd84369f7cb217f4a2cb852f5080ff6996170b3a73db2b2d26878b8905d99c36ca432628134
|
||||
@@ -33348,7 +33372,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:
|
||||
@@ -34395,7 +34419,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:
|
||||
|
||||
Reference in New Issue
Block a user