Merge pull request #20282 from RubenV-dev/auth-backend-oidc-module

auth-backend: migrate oidc auth provider to separate module
This commit is contained in:
Patrik Oldsberg
2024-01-21 15:19:15 +01:00
committed by GitHub
22 changed files with 1387 additions and 434 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': patch
---
Migrated oidc auth provider to new `@backstage/plugin-auth-backend-module-oidc-provider` module package.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend-module-oidc-provider': minor
---
Created new `@backstage/plugin-auth-backend-module-oidc-provider` module package to house oidc auth provider migration.
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -0,0 +1,8 @@
# Auth Module: Oidc Provider
This module provides an Oidc auth provider implementation for `@backstage/plugin-auth-backend`.
## Links
- [Repository](https://oidc.com/backstage/backstage/tree/master/plugins/auth-backend-module-oidc-provider)
- [Backstage Project Homepage](https://backstage.io)
@@ -0,0 +1,50 @@
## API Report File for "@backstage/plugin-auth-backend-module-oidc-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 { PassportOAuthAuthenticatorHelper } from '@backstage/plugin-auth-node';
import { SignInResolverFactory } from '@backstage/plugin-auth-node';
import { Strategy } from 'openid-client';
import { TokenSet } from 'openid-client';
import { UserinfoResponse } from 'openid-client';
// @public (undocumented)
const authModuleOidcProvider: () => BackendFeature;
export default authModuleOidcProvider;
// @public (undocumented)
export const oidcAuthenticator: OAuthAuthenticator<
{
initializedScope: string | undefined;
initializedPrompt: string | undefined;
promise: Promise<{
helper: PassportOAuthAuthenticatorHelper;
client: BaseClient;
strategy: Strategy<OidcAuthResult, BaseClient>;
}>;
},
OidcAuthResult
>;
// @public
export type OidcAuthResult = {
tokenset: TokenSet;
userinfo: UserinfoResponse;
};
// @public
export namespace oidcSignInResolvers {
const emailLocalPartMatchingUserEntityName: SignInResolverFactory<
unknown,
unknown
>;
const emailMatchingUserEntityProfileEmail: SignInResolverFactory<
unknown,
unknown
>;
}
```
@@ -0,0 +1,10 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: backstage-plugin-auth-backend-module-oidc-provider
title: '@backstage/plugin-auth-backend-module-oidc-provider'
description: The oidc-provider backend module for the auth plugin.
spec:
lifecycle: experimental
type: backstage-backend-plugin-module
owner: maintainers
+38
View File
@@ -0,0 +1,38 @@
/*
* 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 {
auth?: {
providers?: {
/** @visibility frontend */
oidc?: {
[authEnv: string]: {
clientId: string;
/**
* @visibility secret
*/
clientSecret: string;
metadataUrl: string;
callbackUrl?: string;
tokenEndpointAuthMethod?: string;
tokenSignedResponseAlg?: string;
scope?: string;
prompt?: 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.
*/
import { createBackend } from '@backstage/backend-defaults';
const backend = createBackend();
backend.add(import('@backstage/plugin-auth-backend'));
backend.add(import('../src'));
backend.start();
@@ -0,0 +1,51 @@
{
"name": "@backstage/plugin-auth-backend-module-oidc-provider",
"description": "The oidc-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-backend": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"express": "^4.18.2",
"openid-client": "^5.5.0",
"passport": "^0.6.0"
},
"devDependencies": {
"@backstage/backend-defaults": "workspace:^",
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@backstage/config": "workspace:^",
"cookie-parser": "^1.4.6",
"express-promise-router": "^4.1.1",
"express-session": "^1.17.3",
"jose": "^4.14.6",
"msw": "^1.3.1",
"supertest": "^6.3.3"
},
"configSchema": "config.d.ts",
"files": [
"dist",
"config.d.ts"
]
}
@@ -0,0 +1,437 @@
/*
* 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 { oidcAuthenticator } 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('oidcAuthenticator', () => {
let implementation: any;
let oauthState: OAuthState;
let idToken: string;
let publicKey: JWK;
const mswServer = setupServer();
setupRequestMockHandlers(mswServer);
const issuerMetadata = {
issuer: 'https://oidc.test',
authorization_endpoint: 'https://oidc.test/oauth2/authorize',
token_endpoint: 'https://oidc.test/oauth2/token',
revocation_endpoint: 'https://oidc.test/oauth2/revoke_token',
userinfo_endpoint: 'https://oidc.test/idp/userinfo.openid',
introspection_endpoint: 'https://oidc.test/introspect.oauth2',
jwks_uri: 'https://oidc.test/jwks.json',
scopes_supported: [
'openid',
'offline_access',
'oidc: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'],
};
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://oidc.test',
iat: Date.now(),
aud: 'clientId',
exp: Date.now() + 10000,
})
.setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid })
.sign(keyPair.privateKey);
});
beforeEach(() => {
mswServer.use(
rest.get(
'https://oidc.test/.well-known/openid-configuration',
(_req, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(issuerMetadata),
),
),
rest.get('https://oidc.test/jwks.json', async (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ keys: [{ ...publicKey }] })),
),
rest.post('https://oidc.test/oauth2/token', async (req, res, ctx) => {
return res(
req.headers.get('Authorization')
? ctx.json({
access_token: 'accessToken',
id_token: idToken,
refresh_token: 'refreshToken',
scope: 'testScope',
expires_in: 3600,
})
: ctx.status(401),
);
}),
rest.get(
'https://oidc.test/idp/userinfo.openid',
async (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
sub: 'test',
name: 'Alice Adams',
given_name: 'Alice',
family_name: 'Adams',
email: 'alice@test.com',
picture: 'http://testPictureUrl/photo.jpg',
}),
),
),
);
implementation = oidcAuthenticator.initialize({
callbackUrl: 'https://backstage.test/callback',
config: new ConfigReader({
metadataUrl: 'https://oidc.test/.well-known/openid-configuration',
clientId: 'clientId',
clientSecret: 'clientSecret',
}),
});
oauthState = {
nonce: 'nonce',
env: 'env',
};
});
afterEach(() => {
jest.clearAllMocks();
});
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 oidcAuthenticator.start(
startRequest,
implementation,
);
const url = new URL(startResponse.url);
expect(url.protocol).toBe('https:');
expect(url.hostname).toBe('oidc.test');
expect(url.pathname).toBe('/oauth2/authorize');
});
it('initiates authorization code grant', async () => {
const startResponse = await oidcAuthenticator.start(
startRequest,
implementation,
);
const { searchParams } = new URL(startResponse.url);
expect(searchParams.get('response_type')).toBe('code');
});
it('passes client ID from config', async () => {
const startResponse = await oidcAuthenticator.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 oidcAuthenticator.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 oidcAuthenticator.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 oidcAuthenticator.start(startRequest, implementation);
expect(fakeSession['oidc:oidc.test'].code_verifier).toBeDefined();
});
it('requests default scopes if none are provided in config', async () => {
const startResponse = await oidcAuthenticator.start(
startRequest,
implementation,
);
const { searchParams } = new URL(startResponse.url);
const scopes = searchParams.get('scope')?.split(' ') ?? [];
expect(scopes).toEqual(
expect.arrayContaining(['openid', 'profile', 'email']),
);
});
it('encodes OAuth state in query param', async () => {
const startResponse = await oidcAuthenticator.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(
oidcAuthenticator.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:oidc.test': {
state: encodeOAuthState(oauthState),
},
},
} as unknown as express.Request,
};
});
it('exchanges authorization code for access token', async () => {
const authenticatorResult = await oidcAuthenticator.authenticate(
handlerRequest,
implementation,
);
const accessToken = authenticatorResult.session.accessToken;
expect(accessToken).toEqual('accessToken');
});
it('exchanges authorization code for refresh token', async () => {
const authenticatorResult = await oidcAuthenticator.authenticate(
handlerRequest,
implementation,
);
const refreshToken = authenticatorResult.session.refreshToken;
expect(refreshToken).toEqual('refreshToken');
});
it('returns granted scope', async () => {
const authenticatorResult = await oidcAuthenticator.authenticate(
handlerRequest,
implementation,
);
const responseScope = authenticatorResult.session.scope;
expect(responseScope).toEqual('testScope');
});
it('returns a default session.tokentype field', async () => {
const authenticatorResult = await oidcAuthenticator.authenticate(
handlerRequest,
implementation,
);
const tokenType = authenticatorResult.session.tokenType;
expect(tokenType).toEqual('bearer');
});
it('returns picture and email', async () => {
const authenticatorResult = await oidcAuthenticator.authenticate(
handlerRequest,
implementation,
);
expect(authenticatorResult).toMatchObject({
fullProfile: {
userinfo: {
email: 'alice@test.com',
picture: 'http://testPictureUrl/photo.jpg',
name: 'Alice Adams',
},
},
});
});
it('returns idToken', async () => {
const authenticatorResult = await oidcAuthenticator.authenticate(
handlerRequest,
implementation,
);
expect(authenticatorResult).toMatchObject({
session: {
idToken,
},
});
expect(
Math.abs(authenticatorResult.session.expiresInSeconds! - 3600),
).toBeLessThan(5);
});
it('fails without authorization code', async () => {
handlerRequest.req.url = 'https://test.com';
return expect(
oidcAuthenticator.authenticate(handlerRequest, implementation),
).rejects.toThrow('Unexpected redirect');
});
it('fails without oauth state', async () => {
return expect(
oidcAuthenticator.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 failed, did not find expected authorization request details in session, req.session["oidc:oidc.test"] is undefined',
);
});
it('fails when request has no session', async () => {
return expect(
oidcAuthenticator.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 oidcAuthenticator.refresh(
refreshRequest,
implementation,
);
expect(refreshResponse.session.refreshToken).toBe('refreshToken');
});
it('gets access token', async () => {
const refreshResponse = await oidcAuthenticator.refresh(
refreshRequest,
implementation,
);
expect(refreshResponse.session.accessToken).toBe('accessToken');
});
it('gets id token', async () => {
const refreshResponse = await oidcAuthenticator.refresh(
refreshRequest,
implementation,
);
expect(refreshResponse.session.idToken).toBe(idToken);
});
});
});
@@ -0,0 +1,187 @@
/*
* 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 {
Issuer,
ClientAuthMethod,
TokenSet,
UserinfoResponse,
Strategy as OidcStrategy,
} from 'openid-client';
import {
createOAuthAuthenticator,
OAuthAuthenticatorResult,
PassportDoneCallback,
PassportHelpers,
PassportOAuthAuthenticatorHelper,
PassportOAuthPrivateInfo,
} from '@backstage/plugin-auth-node';
/**
* authentication result for the OIDC which includes the token set and user
* profile response
* @public
*/
export type OidcAuthResult = {
tokenset: TokenSet;
userinfo: UserinfoResponse;
};
/** @public */
export const oidcAuthenticator = createOAuthAuthenticator({
defaultProfileTransform: async (
input: OAuthAuthenticatorResult<OidcAuthResult>,
) => ({
profile: {
email: input.fullProfile.userinfo.email,
picture: input.fullProfile.userinfo.picture,
displayName: input.fullProfile.userinfo.name,
},
}),
initialize({ callbackUrl, config }) {
const clientId = config.getString('clientId');
const clientSecret = config.getString('clientSecret');
const metadataUrl = config.getString('metadataUrl');
const customCallbackUrl = config.getOptionalString('callbackUrl');
const tokenEndpointAuthMethod = config.getOptionalString(
'tokenEndpointAuthMethod',
) as ClientAuthMethod;
const tokenSignedResponseAlg = config.getOptionalString(
'tokenSignedResponseAlg',
);
const initializedScope = config.getOptionalString('scope');
const initializedPrompt = config.getOptionalString('prompt');
const promise = Issuer.discover(metadataUrl).then(issuer => {
const client = new issuer.Client({
access_type: 'offline', // this option must be passed to provider to receive a refresh token
client_id: clientId,
client_secret: clientSecret,
redirect_uris: [customCallbackUrl || callbackUrl],
response_types: ['code'],
token_endpoint_auth_method:
tokenEndpointAuthMethod || 'client_secret_basic',
id_token_signed_response_alg: tokenSignedResponseAlg || 'RS256',
scope: initializedScope || '',
});
const strategy = new OidcStrategy(
{
client,
passReqToCallback: false,
},
(
tokenset: TokenSet,
userinfo: UserinfoResponse,
done: PassportDoneCallback<OidcAuthResult, PassportOAuthPrivateInfo>,
) => {
if (typeof done !== 'function') {
throw new Error(
'OIDC IdP must provide a userinfo_endpoint in the metadata response',
);
}
done(
undefined,
{ tokenset, userinfo },
{ refreshToken: tokenset.refresh_token },
);
},
);
const helper = PassportOAuthAuthenticatorHelper.from(strategy);
return { helper, client, strategy };
});
return { initializedScope, initializedPrompt, promise };
},
async start(input, ctx) {
const { initializedScope, initializedPrompt, promise } = ctx;
const { helper, strategy } = await promise;
const options: Record<string, string> = {
scope: input.scope || initializedScope || 'openid profile email',
state: input.state,
};
const prompt = initializedPrompt || 'none';
if (prompt !== 'auto') {
options.prompt = prompt;
}
return new Promise((resolve, reject) => {
strategy.error = reject;
return helper
.start(input, {
...options,
})
.then(resolve);
});
},
async authenticate(
input,
ctx,
): Promise<OAuthAuthenticatorResult<OidcAuthResult>> {
const { strategy } = await ctx.promise;
const { result, privateInfo } =
await PassportHelpers.executeFrameHandlerStrategy<
OidcAuthResult,
PassportOAuthPrivateInfo
>(input.req, strategy);
return {
fullProfile: result,
session: {
accessToken: result.tokenset.access_token!,
tokenType: result.tokenset.token_type ?? 'bearer',
scope: result.tokenset.scope!,
expiresInSeconds: result.tokenset.expires_in,
idToken: result.tokenset.id_token,
refreshToken: privateInfo.refreshToken,
},
};
},
async refresh(input, ctx) {
const { client } = await ctx.promise;
const tokenset = await client.refresh(input.refreshToken);
if (!tokenset.access_token) {
throw new Error('Refresh failed');
}
if (!tokenset.scope) {
tokenset.scope = input.scope;
}
const userinfo = await client.userinfo(tokenset.access_token);
return new Promise((resolve, reject) => {
if (!tokenset.access_token) {
reject(new Error('Refresh Failed'));
}
resolve({
fullProfile: { userinfo, tokenset },
session: {
accessToken: tokenset.access_token!,
tokenType: tokenset.token_type ?? 'bearer',
scope: tokenset.scope!,
expiresInSeconds: tokenset.expires_in,
idToken: tokenset.id_token,
refreshToken: tokenset.refresh_token,
},
});
});
},
});
@@ -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.
*/
/**
* The oidc-provider backend module for the auth plugin.
*
* @packageDocumentation
*/
export { oidcAuthenticator } from './authenticator';
export type { OidcAuthResult } from './authenticator';
export { authModuleOidcProvider as default } from './module';
export { oidcSignInResolvers } from './resolvers';
@@ -0,0 +1,223 @@
/*
* 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 request from 'supertest';
import { decodeOAuthState } from '@backstage/plugin-auth-node';
import { setupServer } from 'msw/node';
import { rest } from 'msw';
import {
mockServices,
setupRequestMockHandlers,
startTestBackend,
} from '@backstage/backend-test-utils';
import { Server } from 'http';
import { JWK, SignJWT, exportJWK, generateKeyPair } from 'jose';
import { authModuleOidcProvider } from './module';
describe('authModuleOidcProvider', () => {
let backstageServer: Server;
let appUrl: string;
let idToken: string;
let publicKey: JWK;
const mswServer = setupServer();
setupRequestMockHandlers(mswServer);
const issuerMetadata = {
issuer: 'https://oidc.test',
authorization_endpoint: 'https://oidc.test/oauth2/authorize',
token_endpoint: 'https://oidc.test/oauth2/token',
revocation_endpoint: 'https://oidc.test/oauth2/revoke_token',
userinfo_endpoint: 'https://oidc.test/idp/userinfo.openid',
introspection_endpoint: 'https://oidc.test/as/introspect.oauth2',
jwks_uri: 'https://oidc.test/jwks.json',
scopes_supported: ['openid'],
claims_supported: ['email'],
response_types_supported: ['code'],
id_token_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'],
token_endpoint_auth_signing_alg_values_supported: [
'RS256',
'RS512',
'HS256',
],
request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'],
};
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://oidc.test',
iat: Date.now(),
aud: 'clientId',
exp: Date.now() + 10000,
})
.setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid })
.sign(keyPair.privateKey);
});
beforeEach(async () => {
jest.clearAllMocks();
mswServer.use(
rest.get(
'https://oidc.test/.well-known/openid-configuration',
(_req, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(issuerMetadata),
),
),
rest.get('https://oidc.test/oauth2/authorize', async (req, res, ctx) => {
const callbackUrl = new URL(req.url.searchParams.get('redirect_uri')!);
callbackUrl.searchParams.set('code', 'authorization_code');
callbackUrl.searchParams.set(
'state',
req.url.searchParams.get('state')!,
);
callbackUrl.searchParams.set('scope', 'test-scope');
return res(
ctx.status(302),
ctx.set('Location', callbackUrl.toString()),
);
}),
rest.get('https://oidc.test/jwks.json', async (_req, res, ctx) =>
res(ctx.status(200), ctx.json({ keys: [{ ...publicKey }] })),
),
rest.post('https://oidc.test/oauth2/token', async (req, res, ctx) => {
return res(
req.headers.get('Authorization')
? ctx.json({
access_token: 'accessToken',
id_token: idToken,
refresh_token: 'refreshToken',
scope: 'testScope',
token_type: '',
expires_in: 3600,
})
: ctx.status(401),
);
}),
rest.get(
'https://oidc.test/idp/userinfo.openid',
async (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
sub: 'test',
name: 'Alice Adams',
given_name: 'Alice',
family_name: 'Adams',
email: 'alice@test.com',
picture: 'http://testPictureUrl/photo.jpg',
}),
),
),
);
const backend = await startTestBackend({
features: [
authModuleOidcProvider,
import('@backstage/plugin-auth-backend'),
mockServices.rootConfig.factory({
data: {
app: { baseUrl: 'http://localhost' },
auth: {
session: { secret: 'test' },
providers: {
oidc: {
development: {
metadataUrl:
'https://oidc.test/.well-known/openid-configuration',
clientId: 'clientId',
clientSecret: 'clientSecret',
},
},
},
},
},
}),
],
});
backstageServer = backend.server;
const port = backend.server.port();
appUrl = `http://localhost:${port}`;
mswServer.use(rest.all(`http://*:${port}/*`, req => req.passthrough()));
});
afterEach(() => {
backstageServer.close();
});
it('should start', async () => {
const agent = request.agent(backstageServer);
const startResponse = await agent.get(
`/api/auth/oidc/start?env=development`,
);
expect(startResponse.status).toEqual(302);
const nonceCookie = agent.jar.getCookie('oidc-nonce', {
domain: 'localhost',
path: '/api/auth/oidc/handler',
script: false,
secure: false,
});
expect(nonceCookie).toBeDefined();
const startUrl = new URL(startResponse.get('location'));
expect(startUrl.origin).toBe('https://oidc.test');
expect(startUrl.pathname).toBe('/oauth2/authorize');
expect(Object.fromEntries(startUrl.searchParams)).toEqual({
response_type: 'code',
scope: 'openid profile email',
client_id: 'clientId',
redirect_uri: `${appUrl}/api/auth/oidc/handler/frame`,
state: expect.any(String),
prompt: 'none',
code_challenge: expect.any(String),
code_challenge_method: `S256`,
});
expect(decodeOAuthState(startUrl.searchParams.get('state')!)).toEqual({
env: 'development',
nonce: decodeURIComponent(nonceCookie.value),
});
});
it('#authenticate exchanges authorization code for a access_token', async () => {
const agent = request.agent('');
const startResponse = await agent.get(
`${appUrl}/api/auth/oidc/start?env=development`,
);
const authorizationResponse = await agent.get(
startResponse.header.location,
);
const handlerResponse = await agent.get(
authorizationResponse.header.location,
);
expect(handlerResponse.text).toContain(
encodeURIComponent(`"accessToken":"accessToken"`),
);
});
});
@@ -0,0 +1,48 @@
/*
* 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 { oidcAuthenticator } from './authenticator';
import { oidcSignInResolvers } from './resolvers';
/** @public */
export const authModuleOidcProvider = createBackendModule({
pluginId: 'auth',
moduleId: 'oidc-provider',
register(reg) {
reg.registerInit({
deps: {
providers: authProvidersExtensionPoint,
},
async init({ providers }) {
providers.registerProvider({
providerId: 'oidc',
factory: createOAuthProviderFactory({
authenticator: oidcAuthenticator,
signInResolverFactories: {
...oidcSignInResolvers,
...commonSignInResolvers,
},
}),
});
},
});
},
});
@@ -0,0 +1,38 @@
/*
* 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 { commonSignInResolvers } from '@backstage/plugin-auth-node';
/**
* Available sign-in resolvers for the Oidc auth provider.
*
* @public
*/
export namespace oidcSignInResolvers {
/**
* A oidc resolver that looks up the user using the local part of
* their email address as the entity name.
*/
export const emailLocalPartMatchingUserEntityName =
commonSignInResolvers.emailLocalPartMatchingUserEntityName;
/**
* A oidc resolver that looks up the user using their email address
* as email of the entity.
*/
export const emailMatchingUserEntityProfileEmail =
commonSignInResolvers.emailMatchingUserEntityProfileEmail;
}
+5 -9
View File
@@ -25,6 +25,7 @@ import { LoggerService } from '@backstage/backend-plugin-api';
import { OAuth2ProxyResult as OAuth2ProxyResult_2 } from '@backstage/plugin-auth-backend-module-oauth2-proxy-provider';
import { OAuthEnvironmentHandler as OAuthEnvironmentHandler_2 } from '@backstage/plugin-auth-node';
import { OAuthState as OAuthState_2 } from '@backstage/plugin-auth-node';
import { OidcAuthResult as OidcAuthResult_2 } from '@backstage/plugin-auth-backend-module-oidc-provider';
import { PluginDatabaseManager } from '@backstage/backend-common';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { prepareBackstageIdentityResponse as prepareBackstageIdentityResponse_2 } from '@backstage/plugin-auth-node';
@@ -34,9 +35,7 @@ import { SignInInfo as SignInInfo_2 } from '@backstage/plugin-auth-node';
import { SignInResolver as SignInResolver_2 } from '@backstage/plugin-auth-node';
import { TokenManager } from '@backstage/backend-common';
import { TokenParams as TokenParams_2 } from '@backstage/plugin-auth-node';
import { TokenSet } from 'openid-client';
import { UserEntity } from '@backstage/catalog-model';
import { UserinfoResponse } from 'openid-client';
import { WebMessageResponse as WebMessageResponse_2 } from '@backstage/plugin-auth-node';
// @public @deprecated
@@ -340,11 +339,8 @@ export type OAuthStartResponse = {
// @public @deprecated (undocumented)
export type OAuthState = OAuthState_2;
// @public
export type OidcAuthResult = {
tokenset: TokenSet;
userinfo: UserinfoResponse;
};
// @public @deprecated (undocumented)
export type OidcAuthResult = OidcAuthResult_2;
// @public @deprecated (undocumented)
export const postMessageResponse: (
@@ -564,10 +560,10 @@ export const providers: Readonly<{
create: (
options?:
| {
authHandler?: AuthHandler<OidcAuthResult> | undefined;
authHandler?: AuthHandler<OidcAuthResult_2> | undefined;
signIn?:
| {
resolver: SignInResolver<OidcAuthResult>;
resolver: SignInResolver<OidcAuthResult_2>;
}
| undefined;
}
-16
View File
@@ -149,22 +149,6 @@ export interface Config {
};
};
/** @visibility frontend */
oidc?: {
[authEnv: string]: {
clientId: string;
/**
* @visibility secret
*/
clientSecret: string;
callbackUrl?: string;
metadataUrl: string;
tokenEndpointAuthMethod?: string;
tokenSignedResponseAlg?: string;
scope?: string;
prompt?: string;
};
};
/** @visibility frontend */
auth0?: {
[authEnv: string]: {
clientId: string;
+1 -1
View File
@@ -45,6 +45,7 @@
"@backstage/plugin-auth-backend-module-google-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-oauth2-proxy-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-oidc-provider": "workspace:^",
"@backstage/plugin-auth-backend-module-okta-provider": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"@backstage/plugin-catalog-node": "workspace:^",
@@ -74,7 +75,6 @@
"passport-auth0": "^1.4.3",
"passport-bitbucket-oauth2": "^0.1.2",
"passport-github2": "^0.1.12",
"passport-gitlab2": "^5.0.0",
"passport-google-oauth20": "^2.0.0",
"passport-microsoft": "^1.0.0",
"passport-oauth2": "^1.6.1",
@@ -15,4 +15,11 @@
*/
export { oidc } from './provider';
export type { OidcAuthResult } from './provider';
import { OidcAuthResult as OidcAuthResult_ } from '@backstage/plugin-auth-backend-module-oidc-provider';
/**
* @public
* @deprecated Use OidcAuthResult from `@backstage/plugin-auth-backend-module-oidc-provider` instead
*/
export type OidcAuthResult = OidcAuthResult_;
@@ -1,5 +1,5 @@
/*
* Copyright 2020 The Backstage Authors
* Copyright 2024 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.
@@ -13,177 +13,157 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Config, ConfigReader } from '@backstage/config';
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import { getVoidLogger } from '@backstage/backend-common';
import { LoggerService } from '@backstage/backend-plugin-api';
import { Config, ConfigReader } from '@backstage/config';
import {
AuthProviderConfig,
AuthResolverContext,
CookieConfigurer,
} from '@backstage/plugin-auth-node';
import express from 'express';
import { Session } from 'express-session';
import { UnsecuredJWT } from 'jose';
import { JWK, SignJWT, exportJWK, generateKeyPair } from 'jose';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { ClientMetadata, IssuerMetadata } from 'openid-client';
import { OAuthAdapter } from '../../lib/oauth';
import { oidc, OidcAuthProvider, Options } from './provider';
import { AuthResolverContext } from '../types';
import { oidc } from './provider';
const issuerMetadata = {
issuer: 'https://oidc.test',
authorization_endpoint: 'https://oidc.test/as/authorization.oauth2',
token_endpoint: 'https://oidc.test/as/token.oauth2',
revocation_endpoint: 'https://oidc.test/as/revoke_token.oauth2',
userinfo_endpoint: 'https://oidc.test/idp/userinfo.openid',
introspection_endpoint: 'https://oidc.test/as/introspect.oauth2',
jwks_uri: 'https://oidc.test/pf/JWKS',
scopes_supported: ['openid'],
claims_supported: ['email'],
response_types_supported: ['code'],
id_token_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'],
token_endpoint_auth_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'],
request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'],
};
describe('oidc.create', () => {
const userinfo = {
sub: 'test',
iss: 'https://oidc.test',
aud: 'clientId',
nonce: 'foo',
};
const server = setupServer();
setupRequestMockHandlers(server);
const clientMetadata: Options = {
authHandler: async input => ({
profile: {
displayName: input.userinfo.email,
},
}),
resolverContext: {} as AuthResolverContext,
callbackUrl: 'https://oidc.test/callback',
clientId: 'testclientid',
clientSecret: 'testclientsecret',
metadataUrl: 'https://oidc.test/.well-known/openid-configuration',
tokenEndpointAuthMethod: 'none',
tokenSignedResponseAlg: 'none',
};
let publicKey: JWK;
let tokenset: object;
let providerFactoryOptions: {
providerId: string;
globalConfig: AuthProviderConfig;
config: Config;
logger: LoggerService;
resolverContext: AuthResolverContext;
baseUrl: string;
appUrl: string;
isOriginAllowed: (origin: string) => boolean;
cookieConfigurer?: CookieConfigurer;
};
describe('OidcAuthProvider', () => {
const worker = setupServer();
setupRequestMockHandlers(worker);
beforeAll(async () => {
const keyPair = await generateKeyPair('RS256');
const privateKey = await exportJWK(keyPair.privateKey);
publicKey = await exportJWK(keyPair.publicKey);
publicKey.alg = privateKey.alg = 'RS256';
tokenset = {
id_token: await new SignJWT({
iat: Date.now(),
exp: Date.now() + 10000,
...userinfo,
})
.setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid })
.sign(keyPair.privateKey),
access_token: 'accessToken',
};
});
beforeEach(() => {
jest.clearAllMocks();
});
it('hit the metadata url', async () => {
const handler = jest.fn((_req, res, ctx) => {
return res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(issuerMetadata),
);
});
worker.use(
rest.get('https://oidc.test/.well-known/openid-configuration', handler),
server.use(
rest.get(
'https://oidc.test/.well-known/openid-configuration',
(_req, res, ctx) =>
res(
ctx.json({
issuer: 'https://oidc.test',
token_endpoint: 'https://oidc.test/oauth2/token',
userinfo_endpoint: 'https://oidc.test/idp/userinfo.openid',
jwks_uri: 'https://oidc.test/jwks.json',
}),
),
),
rest.post('https://oidc.test/oauth2/token', (_req, res, ctx) =>
res(ctx.json(tokenset)),
),
rest.get('https://oidc.test/jwks.json', async (_req, res, ctx) =>
res(ctx.json({ keys: [{ ...publicKey }] })),
),
rest.get(
'https://oidc.test/idp/userinfo.openid',
async (_req, res, ctx) => res(ctx.json(userinfo)),
),
);
const provider = new OidcAuthProvider(clientMetadata);
const { strategy } = (await (provider as any).implementation) as any as {
strategy: {
_client: ClientMetadata;
_issuer: IssuerMetadata;
};
};
// Assert that the expected request to the metadaurl was made.
expect(handler).toHaveBeenCalledTimes(1);
const { _client, _issuer } = strategy;
expect(_client.client_id).toBe(clientMetadata.clientId);
expect(_issuer.token_endpoint).toBe(issuerMetadata.token_endpoint);
});
it('OidcAuthProvider#handler successfully invokes the oidc endpoints', async () => {
const sub = 'alice';
const iss = 'https://oidc.test';
const iat = Date.now();
const aud = clientMetadata.clientId;
const exp = Date.now() + 10000;
const jwt = await new UnsecuredJWT({ iss, sub, aud, iat, exp })
.setIssuer(iss)
.setAudience(aud)
.setSubject(sub)
.setIssuedAt(iat)
.setExpirationTime(exp)
.encode();
const requestSequence: Array<string> = [];
// The array of expected requests executed by the provider handler
const requests: Array<{
method: 'get' | 'post';
url: string;
payload: object;
}> = [
{
method: 'get',
url: 'https://oidc.test/.well-known/openid-configuration',
payload: issuerMetadata,
},
{
method: 'post',
url: 'https://oidc.test/as/token.oauth2',
payload: {
id_token: jwt,
access_token: 'test',
authorization_signed_response_alg: 'HS256',
},
},
{
method: 'get',
url: 'https://oidc.test/idp/userinfo.openid',
payload: {
sub: 'alice',
email: 'alice@oidc.test',
},
},
];
worker.use(
...requests.map(r => {
return rest[r.method](r.url, (_req, res, ctx) => {
requestSequence.push(r.url);
return res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(r.payload),
);
});
}),
);
const provider = new OidcAuthProvider(clientMetadata);
const req = {
method: 'GET',
url: 'https://oidc.test/?code=test2',
session: { 'oidc:oidc.test': 'test' } as any as Session,
} as express.Request;
await provider.handler(req);
expect(requestSequence).toEqual([0, 1, 2].map(i => requests[i].url));
});
it('oidc.create', async () => {
const handler = jest.fn((_req, res, ctx) => {
return res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(issuerMetadata),
);
});
worker.use(
rest.get('https://oidc.test/.well-known/openid-configuration', handler),
);
const config: Config = new ConfigReader({
testEnv: {
...clientMetadata,
metadataUrl: 'https://oidc.test/.well-known/openid-configuration',
},
} as any);
const provider = oidc.create()({
providerFactoryOptions = {
providerId: 'myoidc',
baseUrl: 'http://backstage.test/api/auth',
appUrl: 'http://backstage.test',
isOriginAllowed: _ => true,
globalConfig: {
appUrl: 'https://oidc.test',
baseUrl: 'https://oidc.test',
baseUrl: 'http://backstage.test/api/auth',
appUrl: 'http://backstage.test',
isOriginAllowed: _ => true,
},
config,
} as any) as OAuthAdapter;
expect(provider.start).toBeDefined();
// Cast provider as any here to be able to inspect private members
await (provider as any).handlers.get('testEnv').handlers.implementation;
// Assert that the expected request to the metadaurl was made.
expect(handler).toHaveBeenCalledTimes(1);
config: new ConfigReader({
development: {
metadataUrl: 'https://oidc.test/.well-known/openid-configuration',
clientId: 'clientId',
clientSecret: 'clientSecret',
},
}),
logger: getVoidLogger(),
resolverContext: {
issueToken: jest.fn(),
findCatalogUser: jest.fn(),
signInWithCatalogUser: jest.fn(),
},
};
});
it('invokes authHandler with tokenset and userinfo response', async () => {
const authHandler = jest.fn();
const provider = oidc.create({ authHandler })(providerFactoryOptions);
const state = Buffer.from('nonce=foo&env=development').toString('hex');
await provider.frameHandler(
{
method: 'GET',
url: `http://backstage.test/api/auth/myoidc/handler/frame?code=blahblah&state=${state}`,
query: { state },
cookies: { 'myoidc-nonce': 'foo' },
session: { 'oidc:oidc.test': { state, nonce: 'foo' } },
} as unknown as express.Request,
{ setHeader: jest.fn(), end: jest.fn() } as unknown as express.Response,
);
expect(authHandler).toHaveBeenCalledWith(
{ tokenset, userinfo },
providerFactoryOptions.resolverContext,
);
});
it('invokes sign-in resolver with tokenset and userinfo response', async () => {
const resolver = jest.fn();
const provider = oidc.create({ signIn: { resolver } })(
providerFactoryOptions,
);
const state = Buffer.from('nonce=foo&env=development').toString('hex');
await provider.frameHandler(
{
method: 'GET',
url: `http://backstage.test/api/auth/myoidc/handler/frame?code=blahblah&state=${state}`,
query: { state },
cookies: { 'myoidc-nonce': 'foo' },
session: { 'oidc:oidc.test': { state, nonce: 'foo' } },
} as unknown as express.Request,
{ setHeader: jest.fn(), end: jest.fn() } as unknown as express.Response,
);
expect(resolver).toHaveBeenCalledWith(
expect.objectContaining({ result: { tokenset, userinfo } }),
providerFactoryOptions.resolverContext,
);
});
});
@@ -14,208 +14,23 @@
* limitations under the License.
*/
import express from 'express';
import {
Client,
ClientAuthMethod,
Issuer,
Strategy as OidcStrategy,
TokenSet,
UserinfoResponse,
} from 'openid-client';
import {
encodeState,
OAuthAdapter,
OAuthEnvironmentHandler,
OAuthHandlers,
OAuthProviderOptions,
OAuthRefreshRequest,
OAuthResponse,
OAuthStartRequest,
} from '../../lib/oauth';
import {
executeFrameHandlerStrategy,
executeRedirectStrategy,
PassportDoneCallback,
} from '../../lib/passport';
import {
AuthHandler,
AuthResolverContext,
OAuthStartResponse,
SignInResolver,
} from '../types';
import { AuthHandler, SignInResolver } from '../types';
import { createAuthProviderIntegration } from '../createAuthProviderIntegration';
import {
createOAuthProviderFactory,
AuthResolverContext,
BackstageSignInResult,
OAuthAuthenticatorResult,
SignInInfo,
} from '@backstage/plugin-auth-node';
import {
oidcAuthenticator,
OidcAuthResult,
} from '@backstage/plugin-auth-backend-module-oidc-provider';
import {
commonByEmailLocalPartResolver,
commonByEmailResolver,
} from '../resolvers';
import { BACKSTAGE_SESSION_EXPIRATION } from '../../lib/session';
type PrivateInfo = {
refreshToken?: string;
};
type OidcImpl = {
strategy: OidcStrategy<UserinfoResponse, Client>;
client: Client;
};
/**
* authentication result for the OIDC which includes the token set and user information (a profile response sent by OIDC server)
* @public
*/
export type OidcAuthResult = {
tokenset: TokenSet;
userinfo: UserinfoResponse;
};
export type Options = OAuthProviderOptions & {
metadataUrl: string;
scope?: string;
prompt?: string;
tokenEndpointAuthMethod?: ClientAuthMethod;
tokenSignedResponseAlg?: string;
signInResolver?: SignInResolver<OidcAuthResult>;
authHandler: AuthHandler<OidcAuthResult>;
resolverContext: AuthResolverContext;
};
export class OidcAuthProvider implements OAuthHandlers {
private readonly implementation: Promise<OidcImpl>;
private readonly scope?: string;
private readonly prompt?: string;
private readonly signInResolver?: SignInResolver<OidcAuthResult>;
private readonly authHandler: AuthHandler<OidcAuthResult>;
private readonly resolverContext: AuthResolverContext;
constructor(options: Options) {
this.implementation = this.setupStrategy(options);
this.scope = options.scope;
this.prompt = options.prompt;
this.signInResolver = options.signInResolver;
this.authHandler = options.authHandler;
this.resolverContext = options.resolverContext;
}
async start(req: OAuthStartRequest): Promise<OAuthStartResponse> {
const { strategy } = await this.implementation;
const options: Record<string, string> = {
scope: req.scope || this.scope || 'openid profile email',
state: encodeState(req.state),
};
const prompt = this.prompt || 'none';
if (prompt !== 'auto') {
options.prompt = prompt;
}
return await executeRedirectStrategy(req, strategy, options);
}
async handler(req: express.Request) {
const { strategy } = await this.implementation;
const { result, privateInfo } = await executeFrameHandlerStrategy<
OidcAuthResult,
PrivateInfo
>(req, strategy);
return {
response: await this.handleResult(result),
refreshToken: privateInfo.refreshToken,
};
}
async refresh(req: OAuthRefreshRequest) {
const { client } = await this.implementation;
const tokenset = await client.refresh(req.refreshToken);
if (!tokenset.access_token) {
throw new Error('Refresh failed');
}
if (!tokenset.scope) {
tokenset.scope = req.scope;
}
const userinfo = await client.userinfo(tokenset.access_token);
return {
response: await this.handleResult({ tokenset, userinfo }),
refreshToken: tokenset.refresh_token,
};
}
private async setupStrategy(options: Options): Promise<OidcImpl> {
const issuer = await Issuer.discover(options.metadataUrl);
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'],
token_endpoint_auth_method:
options.tokenEndpointAuthMethod || 'client_secret_basic',
id_token_signed_response_alg: options.tokenSignedResponseAlg || 'RS256',
scope: options.scope || '',
});
const strategy = new OidcStrategy(
{
client,
passReqToCallback: false,
},
(
tokenset: TokenSet,
userinfo: UserinfoResponse,
done: PassportDoneCallback<OidcAuthResult, PrivateInfo>,
) => {
if (typeof done !== 'function') {
throw new Error(
'OIDC IdP must provide a userinfo_endpoint in the metadata response',
);
}
done(
undefined,
{ tokenset, userinfo },
{
refreshToken: tokenset.refresh_token,
},
);
},
);
strategy.error = console.error;
return { strategy, client };
}
// Use this function to grab the user profile info from the token
// Then populate the profile with it
private async handleResult(result: OidcAuthResult): Promise<OAuthResponse> {
const { profile } = await this.authHandler(result, this.resolverContext);
const expiresInSeconds =
result.tokenset.expires_in === undefined
? BACKSTAGE_SESSION_EXPIRATION
: Math.min(result.tokenset.expires_in, BACKSTAGE_SESSION_EXPIRATION);
let backstageIdentity = undefined;
if (this.signInResolver) {
backstageIdentity = await this.signInResolver(
{
result,
profile,
},
this.resolverContext,
);
}
return {
backstageIdentity,
providerInfo: {
idToken: result.tokenset.id_token,
accessToken: result.tokenset.access_token!,
scope: result.tokenset.scope!,
expiresInSeconds,
},
profile,
};
}
}
/**
* Auth provider integration for generic OpenID Connect auth
@@ -224,59 +39,44 @@ export class OidcAuthProvider implements OAuthHandlers {
*/
export const oidc = createAuthProviderIntegration({
create(options?: {
/**
* The profile transformation function used to verify and convert the auth response
* into the profile that will be presented to the user.
*/
authHandler?: AuthHandler<OidcAuthResult>;
/**
* Configure sign-in for this provider; convert user profile respones into
* Backstage identities.
*/
signIn?: {
resolver: SignInResolver<OidcAuthResult>;
};
}) {
return ({ providerId, globalConfig, config, resolverContext }) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const customCallbackUrl = envConfig.getOptionalString('callbackUrl');
const callbackUrl =
customCallbackUrl ||
`${globalConfig.baseUrl}/${providerId}/handler/frame`;
const metadataUrl = envConfig.getString('metadataUrl');
const tokenEndpointAuthMethod = envConfig.getOptionalString(
'tokenEndpointAuthMethod',
) as ClientAuthMethod;
const tokenSignedResponseAlg = envConfig.getOptionalString(
'tokenSignedResponseAlg',
);
const scope = envConfig.getOptionalString('scope');
const prompt = envConfig.getOptionalString('prompt');
const authHandler: AuthHandler<OidcAuthResult> = options?.authHandler
? options.authHandler
: async ({ userinfo }) => ({
profile: {
displayName: userinfo.name,
email: userinfo.email,
picture: userinfo.picture,
},
});
const provider = new OidcAuthProvider({
clientId,
clientSecret,
callbackUrl,
tokenEndpointAuthMethod,
tokenSignedResponseAlg,
metadataUrl,
scope,
prompt,
signInResolver: options?.signIn?.resolver,
authHandler,
resolverContext,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
providerId,
callbackUrl,
});
});
const authHandler = options?.authHandler;
const signInResolver = options?.signIn?.resolver;
return createOAuthProviderFactory({
authenticator: oidcAuthenticator,
profileTransform:
authHandler &&
((
result: OAuthAuthenticatorResult<OidcAuthResult>,
context: AuthResolverContext,
) => authHandler(result.fullProfile, context)),
signInResolver:
signInResolver &&
((
info: SignInInfo<OAuthAuthenticatorResult<OidcAuthResult>>,
context: AuthResolverContext,
): Promise<BackstageSignInResult> =>
signInResolver(
{
result: info.result.fullProfile,
profile: info.profile,
},
context,
)),
});
},
resolvers: {
/**
+37 -2
View File
@@ -4674,6 +4674,30 @@ __metadata:
languageName: unknown
linkType: soft
"@backstage/plugin-auth-backend-module-oidc-provider@workspace:^, @backstage/plugin-auth-backend-module-oidc-provider@workspace:plugins/auth-backend-module-oidc-provider":
version: 0.0.0-use.local
resolution: "@backstage/plugin-auth-backend-module-oidc-provider@workspace:plugins/auth-backend-module-oidc-provider"
dependencies:
"@backstage/backend-common": "workspace:^"
"@backstage/backend-defaults": "workspace:^"
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/backend-test-utils": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/plugin-auth-backend": "workspace:^"
"@backstage/plugin-auth-node": "workspace:^"
cookie-parser: ^1.4.6
express: ^4.18.2
express-promise-router: ^4.1.1
express-session: ^1.17.3
jose: ^4.14.6
msw: ^1.3.1
openid-client: ^5.5.0
passport: ^0.6.0
supertest: ^6.3.3
languageName: unknown
linkType: soft
"@backstage/plugin-auth-backend-module-okta-provider@workspace:^, @backstage/plugin-auth-backend-module-okta-provider@workspace:plugins/auth-backend-module-okta-provider":
version: 0.0.0-use.local
resolution: "@backstage/plugin-auth-backend-module-okta-provider@workspace:plugins/auth-backend-module-okta-provider"
@@ -4755,6 +4779,7 @@ __metadata:
"@backstage/plugin-auth-backend-module-google-provider": "workspace:^"
"@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^"
"@backstage/plugin-auth-backend-module-oauth2-proxy-provider": "workspace:^"
"@backstage/plugin-auth-backend-module-oidc-provider": "workspace:^"
"@backstage/plugin-auth-backend-module-okta-provider": "workspace:^"
"@backstage/plugin-auth-node": "workspace:^"
"@backstage/plugin-catalog-node": "workspace:^"
@@ -4796,7 +4821,6 @@ __metadata:
passport-auth0: ^1.4.3
passport-bitbucket-oauth2: ^0.1.2
passport-github2: ^0.1.12
passport-gitlab2: ^5.0.0
passport-google-oauth20: ^2.0.0
passport-microsoft: ^1.0.0
passport-oauth2: ^1.6.1
@@ -35700,7 +35724,7 @@ __metadata:
languageName: node
linkType: hard
"openid-client@npm:^5.2.1, openid-client@npm:^5.3.0, openid-client@npm:^5.4.3":
"openid-client@npm:^5.2.1, openid-client@npm:^5.3.0, openid-client@npm:^5.4.3, openid-client@npm:^5.5.0":
version: 5.6.4
resolution: "openid-client@npm:5.6.4"
dependencies:
@@ -36349,6 +36373,17 @@ __metadata:
languageName: node
linkType: hard
"passport@npm:^0.6.0":
version: 0.6.0
resolution: "passport@npm:0.6.0"
dependencies:
passport-strategy: 1.x.x
pause: 0.0.1
utils-merge: ^1.0.1
checksum: ef932ad671d50de34765c7a53cd1e058d8331a82a6df09265a9c6c1168911aee4a7b5215803d0101110ab7f317e096b4954ca7e18fb2c33b9929f0bd17dbe159
languageName: node
linkType: hard
"passport@npm:^0.7.0":
version: 0.7.0
resolution: "passport@npm:0.7.0"