authenticator alongside passing unit tests

Signed-off-by: Ruben Vallejo <rvallejo@vmware.com>
This commit is contained in:
Ruben Vallejo
2023-09-27 17:47:22 -04:00
committed by Jamie Klassen
parent 55c639839a
commit a3c911e636
9 changed files with 825 additions and 532 deletions
+4 -1
View File
@@ -25,8 +25,11 @@ export interface Config {
* @visibility secret
*/
clientSecret: string;
audience?: string;
metadataUrl: string;
callbackUrl?: string;
tokenSignedResponseAlg?: string;
scope?: string;
prompt?: string;
};
};
};
@@ -0,0 +1,394 @@
/*
* 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',
token_type: '',
})
: 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',
}),
),
),
);
implementation = oidcAuthenticator.initialize({
callbackUrl: 'https://backstage.test/callback',
config: new ConfigReader({
metadataUrl: 'https://oidc.test',
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();
});
// without the offline_access scope refresh should not work should we add in the test that is a required scope to test for? curently dont see that by defualt in the provider implementation.
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 handlerResponse = await oidcAuthenticator.authenticate(
handlerRequest,
implementation,
);
const accessToken = handlerResponse.session.accessToken;
expect(accessToken).toEqual('accessToken');
});
it('exchanges authorization code for refresh token', async () => {
const handlerResponse = await oidcAuthenticator.authenticate(
handlerRequest,
implementation,
);
const refreshToken = handlerResponse.session.refreshToken;
expect(refreshToken).toEqual('refreshToken');
});
it('returns granted scope', async () => {
const handlerResponse = await oidcAuthenticator.authenticate(
handlerRequest,
implementation,
);
const responseScope = handlerResponse.session.scope;
expect(responseScope).toEqual('testScope');
});
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);
});
});
});
@@ -23,15 +23,10 @@ import {
} from 'openid-client';
import {
createOAuthAuthenticator,
decodeOAuthState,
encodeOAuthState,
PassportDoneCallback,
PassportOAuthAuthenticatorHelper,
PassportOAuthDoneCallback,
PassportOAuthPrivateInfo,
PassportProfile,
} from '@backstage/plugin-auth-node';
import { OidcAuthResult } from '@backstage/plugin-auth-backend';
// import { BACKSTAGE_SESSION_EXPIRATION } from '@backstage/plugin-auth-backend/src/lib/session';
/** @public */
export const oidcAuthenticator = createOAuthAuthenticator({
@@ -40,9 +35,9 @@ export const oidcAuthenticator = createOAuthAuthenticator({
async initialize({ callbackUrl, config }) {
const clientId = config.getString('clientId');
const clientSecret = config.getString('clientSecret');
const metadataUrl = config.getString('metadataUrl');
const customCallbackUrl = config.getOptionalString('callbackUrl');
const callbackUrl2 = customCallbackUrl || callbackUrl;
const metadataUrl = config.getString('metadataUrl');
const tokenEndpointAuthMethod = config.getOptionalString(
'tokenEndpointAuthMethod',
) as ClientAuthMethod;
@@ -66,39 +61,54 @@ export const oidcAuthenticator = createOAuthAuthenticator({
scope: initializedScope || '',
});
const helper = PassportOAuthAuthenticatorHelper.from(
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 strategy = new OidcStrategy(
{
client,
passReqToCallback: false,
},
(
tokenset: TokenSet,
userinfo: UserinfoResponse,
done: PassportOAuthDoneCallback,
) => {
if (typeof done !== 'function') {
throw new Error(
'OIDC IdP must provide a userinfo_endpoint in the metadata response',
);
},
),
}
const expiresInSeconds = !tokenset.expires_in
? 3600
: Math.min(tokenset.expires_in!, 3600);
done(
undefined,
{
fullProfile: {
provider: 'oidc',
id: userinfo.sub,
displayName: userinfo.name!,
},
accessToken: tokenset.access_token!,
params: {
scope: tokenset.scope!,
expires_in: expiresInSeconds,
},
},
{
refreshToken: tokenset.refresh_token,
},
);
},
);
return { helper, client, initializedScope, initializedPrompt };
const helper = PassportOAuthAuthenticatorHelper.from(strategy);
return { helper, client, initializedScope, initializedPrompt, strategy };
},
async start(input, implementation) {
const { initializedScope, initializedPrompt, helper } =
await implementation;
async start(input, ctx) {
const { initializedScope, initializedPrompt, helper, strategy } = await ctx;
const options: Record<string, string> = {
scope: input.scope || initializedScope || 'openid profile email',
state: input.state,
@@ -108,16 +118,48 @@ export const oidcAuthenticator = createOAuthAuthenticator({
options.prompt = prompt;
}
return helper.start(input, {
...options,
return new Promise((resolve, reject) => {
strategy.error = reject;
return helper
.start(input, {
...options,
})
.then(resolve);
});
},
async authenticate(input, implementation) {
return (await implementation).helper.authenticate(input);
async authenticate(input, ctx) {
return (await ctx).helper.authenticate(input);
},
async refresh(input, implementation) {
return (await implementation).helper.refresh(input);
async refresh(input, ctx) {
const { client } = await ctx;
const tokenset = await client.refresh(input.refreshToken);
if (!tokenset.access_token) {
throw new Error('Refresh failed');
}
const userinfo = await client.userinfo(tokenset.access_token);
return new Promise((resolve, reject) => {
if (!tokenset.access_token) {
reject(new Error('Refresh Failed'));
}
resolve({
fullProfile: {
provider: 'oidc',
id: userinfo.sub,
displayName: userinfo.name!,
},
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,
},
});
});
},
});
@@ -67,10 +67,10 @@ describe('authModuleOidcProvider', () => {
};
beforeAll(async () => {
const keyPair = await generateKeyPair('ES256');
const keyPair = await generateKeyPair('RS256');
const privateKey = await exportJWK(keyPair.privateKey);
publicKey = await exportJWK(keyPair.publicKey);
publicKey.alg = privateKey.alg = 'ES256';
publicKey.alg = privateKey.alg = 'RS256';
idToken = await new SignJWT({
sub: 'test',
@@ -109,6 +109,36 @@ describe('authModuleOidcProvider', () => {
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: '',
})
: 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',
}),
),
),
);
const secret = 'secret';
@@ -212,5 +242,38 @@ describe('authModuleOidcProvider', () => {
});
});
// TODO: This seems to be the place for integration testing, so far only have test that hit metadata endpoints along with /start but might be missing responses for handler?
it('#authenticate exchanges authorization code for a access_token', async () => {
const agent = request.agent('');
// make /start request with audience parameter
const startResponse = await agent.get(
`${appUrl}/api/auth/oidc/start?env=development`,
);
// 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: {
displayName: 'Alice Adams',
},
providerInfo: {
accessToken: 'accessToken',
scope: 'testScope',
expiresInSeconds: 3600,
},
},
}),
),
);
});
});
@@ -14,12 +14,7 @@
* limitations under the License.
*/
import {
createSignInResolverFactory,
OAuthAuthenticatorResult,
PassportProfile,
SignInInfo,
} from '@backstage/plugin-auth-node';
import { commonSignInResolvers } from '@backstage/plugin-auth-node';
/**
* Available sign-in resolvers for the Oidc auth provider.
@@ -28,44 +23,16 @@ import {
*/
export namespace oidcSignInResolvers {
/**
* Looks up the user by matching their Oidc username to the entity name.
* A oidc resolver that looks up the user using the local part of
* their email address as the entity name.
*/
export const usernameMatchingUserEntityName = createSignInResolverFactory({
create() {
return async (
info: SignInInfo<OAuthAuthenticatorResult<PassportProfile>>,
ctx,
) => {
const { result } = info;
const id = result.fullProfile.username;
if (!id) {
throw new Error(`Oidc user profile does not contain a username`);
}
return ctx.signInWithCatalogUser({ entityRef: { name: id } });
};
},
});
export const emailLocalPartMatchingUserEntityName =
commonSignInResolvers.emailLocalPartMatchingUserEntityName;
/**
* Looks up the user by matching their email to the `google.com/email` annotation. Still working out this resolver.....
* A oidc resolver that looks up the user using their email address
* as email of the entity.
*/
export const emailMatchingUserEntityAnnotation = createSignInResolverFactory({
create() {
return async (info: SignInInfo<GcpIapResult>, ctx) => {
const email = info.result.iapToken.email;
if (!email) {
throw new Error('Google IAP sign-in result is missing email');
}
return ctx.signInWithCatalogUser({
annotations: {
'google.com/email': email,
},
});
};
},
});
export const emailMatchingUserEntityProfileEmail =
commonSignInResolvers.emailMatchingUserEntityProfileEmail;
}
+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:^",
@@ -1,189 +0,0 @@
/*
* 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.
*/
import { Config, ConfigReader } from '@backstage/config';
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import express from 'express';
import { Session } from 'express-session';
import { UnsecuredJWT } 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';
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'],
};
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',
};
describe('OidcAuthProvider', () => {
const worker = setupServer();
setupRequestMockHandlers(worker);
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),
);
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()({
globalConfig: {
appUrl: 'https://oidc.test',
baseUrl: 'https://oidc.test',
},
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);
});
});
@@ -14,278 +14,282 @@
* 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 { OAuthResult } from '../../lib/oauth';
import { createAuthProviderIntegration } from '../createAuthProviderIntegration';
import { createOAuthProviderFactory } from '@backstage/plugin-auth-node';
import {
commonByEmailLocalPartResolver,
commonByEmailResolver,
} from '../resolvers';
import { BACKSTAGE_SESSION_EXPIRATION } from '../../lib/session';
adaptLegacyOAuthHandler,
adaptLegacyOAuthSignInResolver,
} from '../../lib/legacy';
type PrivateInfo = {
refreshToken?: string;
};
import { oidcAuthenticator } from '@backstage/plugin-auth-backend-module-oidc-provider';
type OidcImpl = {
strategy: OidcStrategy<UserinfoResponse, Client>;
client: Client;
};
// type PrivateInfo = {
// refreshToken?: string;
// };
/**
* 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;
};
// type OidcImpl = {
// strategy: OidcStrategy<UserinfoResponse, Client>;
// client: Client;
// };
export type Options = OAuthProviderOptions & {
metadataUrl: string;
scope?: string;
prompt?: string;
tokenEndpointAuthMethod?: ClientAuthMethod;
tokenSignedResponseAlg?: string;
signInResolver?: SignInResolver<OidcAuthResult>;
authHandler: AuthHandler<OidcAuthResult>;
resolverContext: AuthResolverContext;
};
// /**
// * 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 class OidcAuthProvider implements OAuthHandlers {
private readonly implementation: Promise<OidcImpl>;
private readonly scope?: string;
private readonly prompt?: string;
// export type Options = OAuthProviderOptions & {
// metadataUrl: string;
// scope?: string;
// prompt?: string;
// tokenEndpointAuthMethod?: ClientAuthMethod;
// tokenSignedResponseAlg?: string;
// signInResolver?: SignInResolver<OidcAuthResult>;
// authHandler: AuthHandler<OidcAuthResult>;
// resolverContext: AuthResolverContext;
// };
private readonly signInResolver?: SignInResolver<OidcAuthResult>;
private readonly authHandler: AuthHandler<OidcAuthResult>;
private readonly resolverContext: AuthResolverContext;
// export class OidcAuthProvider implements OAuthHandlers {
// private readonly implementation: Promise<OidcImpl>;
// private readonly scope?: string;
// private readonly prompt?: string;
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;
}
// private readonly signInResolver?: SignInResolver<OidcAuthResult>;
// private readonly authHandler: AuthHandler<OidcAuthResult>;
// private readonly resolverContext: AuthResolverContext;
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);
}
// 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 handler(req: express.Request) {
const { strategy } = await this.implementation;
const { result, privateInfo } = await executeFrameHandlerStrategy<
OidcAuthResult,
PrivateInfo
>(req, strategy);
// 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);
// }
return {
response: await this.handleResult(result),
refreshToken: privateInfo.refreshToken,
};
}
// async handler(req: express.Request) {
// const { strategy } = await this.implementation;
// const { result, privateInfo } = await executeFrameHandlerStrategy<
// OidcAuthResult,
// PrivateInfo
// >(req, strategy);
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);
// 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(result),
// refreshToken: privateInfo.refreshToken,
// };
// }
return {
response: await this.handleResult({ tokenset, userinfo }),
refreshToken: tokenset.refresh_token,
};
}
// 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');
// }
// const userinfo = await client.userinfo(tokenset.access_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 || '',
});
// return {
// response: await this.handleResult({ tokenset, userinfo }),
// refreshToken: tokenset.refresh_token,
// };
// }
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 };
}
// 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 || '',
// });
// 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 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 };
// }
const expiresInSeconds =
result.tokenset.expires_in === undefined
? BACKSTAGE_SESSION_EXPIRATION
: Math.min(result.tokenset.expires_in, BACKSTAGE_SESSION_EXPIRATION);
// 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);
let backstageIdentity = undefined;
if (this.signInResolver) {
backstageIdentity = await this.signInResolver(
{
result,
profile,
},
this.resolverContext,
);
}
// const expiresInSeconds =
// result.tokenset.expires_in === undefined
// ? BACKSTAGE_SESSION_EXPIRATION
// : Math.min(result.tokenset.expires_in, BACKSTAGE_SESSION_EXPIRATION);
return {
backstageIdentity,
providerInfo: {
idToken: result.tokenset.id_token,
accessToken: result.tokenset.access_token!,
scope: result.tokenset.scope!,
expiresInSeconds,
},
profile,
};
}
}
// 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
*
* @public
*/
// export const oidc = createAuthProviderIntegration({
// create(options?: {
// authHandler?: AuthHandler<OidcAuthResult>;
// 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,
// });
// });
// },
// resolvers: {
// /**
// * Looks up the user by matching their email local part to the entity name.
// */
// emailLocalPartMatchingUserEntityName: () => commonByEmailLocalPartResolver,
// /**
// * Looks up the user by matching their email to the entity email.
// */
// emailMatchingUserEntityProfileEmail: () => commonByEmailResolver,
// },
// });
export const oidc = createAuthProviderIntegration({
create(options?: {
authHandler?: AuthHandler<OidcAuthResult>;
/**
* The profile transformation function used to verify and convert the auth response
* into the profile that will be presented to the user.
*/
authHandler?: AuthHandler<OAuthResult>;
/**
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
*/
signIn?: {
resolver: SignInResolver<OidcAuthResult>;
resolver: SignInResolver<OAuthResult>;
};
}) {
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,
});
});
},
resolvers: {
/**
* Looks up the user by matching their email local part to the entity name.
*/
emailLocalPartMatchingUserEntityName: () => commonByEmailLocalPartResolver,
/**
* Looks up the user by matching their email to the entity email.
*/
emailMatchingUserEntityProfileEmail: () => commonByEmailResolver,
return createOAuthProviderFactory({
authenticator: oidcAuthenticator,
profileTransform: adaptLegacyOAuthHandler(options?.authHandler),
signInResolver: adaptLegacyOAuthSignInResolver(options?.signIn?.resolver),
});
},
});
+23 -15
View File
@@ -4672,7 +4672,7 @@ __metadata:
languageName: unknown
linkType: soft
"@backstage/plugin-auth-backend-module-oidc-provider@workspace:plugins/auth-backend-module-oidc-provider":
"@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:
@@ -4777,6 +4777,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:^"
@@ -31276,13 +31277,20 @@ __metadata:
languageName: node
linkType: hard
"jose@npm:^4.14.6, jose@npm:^4.15.4, jose@npm:^4.6.0":
"jose@npm:^4.14.6, jose@npm:^4.15.1":
version: 4.15.4
resolution: "jose@npm:4.15.4"
checksum: dccad91cb3357f36423774a0b89ad830dd84b31090de65cd139b85488439f16a00f8c59c0773825e8a1adb0dd9d13ad725ad66e6ea33880ecb3959bb99e1ea5b
languageName: node
linkType: hard
"jose@npm:^4.6.0":
version: 4.15.2
resolution: "jose@npm:4.15.2"
checksum: 8f0cab1eef31243abe14a935b2b330cd95f10f9b69808fd642088ae5000e50e566664934537d2c6413ab2f6b54acd8265a5033da05157aa1260c5f1d7e57fab0
languageName: node
linkType: hard
"joycon@npm:^3.0.1":
version: 3.1.0
resolution: "joycon@npm:3.1.0"
@@ -35655,19 +35663,7 @@ __metadata:
languageName: node
linkType: hard
"openid-client@npm:^5.2.1, openid-client@npm:^5.3.0, openid-client@npm:^5.4.3":
version: 5.6.4
resolution: "openid-client@npm:5.6.4"
dependencies:
jose: ^4.15.4
lru-cache: ^6.0.0
object-hash: ^2.2.0
oidc-token-hash: ^5.0.3
checksum: 69843f078dacbbc6bad6d65ca6689414ac73f095dfe2f8e606822e6cfc9d9cd7d0dfaf2649352eda604653806f0ea65326ad2d6266da897e4740ec93d26d21f6
languageName: node
linkType: hard
"openid-client@npm:^5.5.0":
"openid-client@npm:^5.2.1, openid-client@npm:^5.3.0, openid-client@npm:^5.5.0":
version: 5.5.0
resolution: "openid-client@npm:5.5.0"
dependencies:
@@ -35679,6 +35675,18 @@ __metadata:
languageName: node
linkType: hard
"openid-client@npm:^5.4.3":
version: 5.6.1
resolution: "openid-client@npm:5.6.1"
dependencies:
jose: ^4.15.1
lru-cache: ^6.0.0
object-hash: ^2.2.0
oidc-token-hash: ^5.0.3
checksum: 9d939cec57540e6dd3f67e9a248ec5ecec3b439b7ab5bd2e9fb4481bd03e8d030deedd87a447348194be7f3e93e84085841b0414033caf86479870f526cdbc2f
languageName: node
linkType: hard
"oppa@npm:^0.4.0":
version: 0.4.0
resolution: "oppa@npm:0.4.0"