Merge pull request #19846 from RubenV-dev/pinniped-auth

Pinniped Auth Provider
This commit is contained in:
Jamie Klassen
2023-10-13 11:36:24 -04:00
committed by GitHub
17 changed files with 1223 additions and 4 deletions
+5
View File
@@ -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.
+5
View File
@@ -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,
},
}),
});
},
});
},
});
+1
View File
@@ -399,6 +399,7 @@ export type OAuthState = {
scope?: string;
redirectUrl?: string;
flow?: string;
audience?: string;
};
// @public (undocumented)
+1
View File
@@ -29,6 +29,7 @@ export type OAuthState = {
scope?: string;
redirectUrl?: string;
flow?: string;
audience?: string;
};
/** @public */
+28 -4
View File
@@ -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: