auth-backend-module-*: update OAuth providers to use new scope handling

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-06-11 11:55:52 +02:00
parent bdabd9952e
commit 8efc6cf0d4
41 changed files with 183 additions and 124 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend-module-atlassian-provider': minor
---
**BREAKING**: The `scope` and `scopes` config options have been removed and replaced by the standard `additionalScopes` config. In addition, the `offline_access`, `read:jira-work`, and `read:jira-user` scopes have been set to required and will always be present.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend-module-bitbucket-provider': patch
---
Added support for the new shared `additionalScopes` configuration. In addition, the `account` scope has been set to required and will always be present.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend-module-github-provider': patch
---
Added support for the new shared `additionalScopes` configuration. In addition, the `read:user` scope has been set to required and will always be present.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend-module-gitlab-provider': patch
---
Added support for the new shared `additionalScopes` configuration. In addition, the `read_user` scope has been set to required and will always be present.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend-module-google-provider': patch
---
Added support for the new shared `additionalScopes` configuration. In addition, the `openid`, `userinfo.email`, and `userinfo.profile` scopes have been set to required and will always be present.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend-module-microsoft-provider': patch
---
Added support for the new shared `additionalScopes` configuration.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend-module-oauth2-provider': minor
---
**BREAKING**: The `scope` config option have been removed and replaced by the standard `additionalScopes` config.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend-module-oidc-provider': minor
---
**BREAKING**: The `scope` config option have been removed and replaced by the standard `additionalScopes` config. In addition, `openid`, `profile`, and `email` scopes have been set to required and will always be present.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend-module-okta-provider': patch
---
Added support for the new shared `additionalScopes` configuration, which means it can now also be specified as an array. In addition, the `openid`, `email`, `profile`, and `offline_access` scopes have been set to required and will always be present.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend-module-pinniped-provider': patch
---
**BREAKING**: The `scope` config option have been removed and replaced by the standard `additionalScopes` config. In addition, the `openid`, `pinniped:request-audience`, `username`, and `offline_access` scopes have been set to required and will always be present.
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend-module-vmware-cloud-provider': minor
---
**BREAKING**: The `scope` config option have been removed and replaced by the standard `additionalScopes` config. In addition, `openid`, and `offline_access` scopes have been set to required and will always be present.
+1 -1
View File
@@ -27,7 +27,7 @@ export interface Config {
clientSecret: string;
audience?: string;
callbackUrl?: string;
scope?: string;
additionalScopes?: string | string[];
};
};
};
@@ -26,15 +26,20 @@ import { Strategy as AtlassianStrategy } from 'passport-atlassian-oauth2';
export const atlassianAuthenticator = createOAuthAuthenticator({
defaultProfileTransform:
PassportOAuthAuthenticatorHelper.defaultProfileTransform,
scopes: {
required: ['offline_access', 'read:jira-work', 'read:jira-user'],
},
initialize({ callbackUrl, config }) {
const clientId = config.getString('clientId');
const clientSecret = config.getString('clientSecret');
const scope =
config.getOptionalString('scope') ??
config.getOptionalString('scopes') ??
'offline_access read:jira-work read:jira-user';
const baseUrl = 'https://auth.atlassian.com';
if (config.has('scope') || config.has('scopes')) {
throw new Error(
'The atlassian provider no longer supports the "scope" or "scopes" configuration options. Please use the "additionalScopes" option instead.',
);
}
return PassportOAuthAuthenticatorHelper.from(
new AtlassianStrategy(
{
@@ -45,7 +50,6 @@ export const atlassianAuthenticator = createOAuthAuthenticator({
authorizationURL: `${baseUrl}/authorize`,
tokenURL: `${baseUrl}/oauth/token`,
profileURL: `${baseUrl}/api/v4/user`,
scope,
},
(
accessToken: string,
@@ -75,7 +75,8 @@ describe('authModuleAtlassianProvider', () => {
nonce: decodeURIComponent(nonceCookie.value),
});
});
it('should start with and use custom scopes from scope config field', async () => {
it('should start with and use custom scopes from additionalScopes config field', async () => {
const { server } = await startTestBackend({
features: [
import('@backstage/plugin-auth-backend'),
@@ -91,7 +92,10 @@ describe('authModuleAtlassianProvider', () => {
development: {
clientId: 'my-client-id',
clientSecret: 'my-client-secret',
scope: 'offline_access read:filter:jira read:jira-work',
additionalScopes: [
'read:filter:jira',
'read:jira-work', // already required
],
},
},
},
@@ -123,7 +127,7 @@ describe('authModuleAtlassianProvider', () => {
client_id: 'my-client-id',
redirect_uri: `http://localhost:${server.port()}/api/auth/atlassian/handler/frame`,
state: expect.any(String),
scope: 'offline_access read:filter:jira read:jira-work',
scope: 'offline_access read:jira-work read:jira-user read:filter:jira',
});
expect(decodeOAuthState(startUrl.searchParams.get('state')!)).toEqual({
@@ -131,60 +135,35 @@ describe('authModuleAtlassianProvider', () => {
nonce: decodeURIComponent(nonceCookie.value),
});
});
it('should start with and use custom scopes from scopes config field for backward compatibility', async () => {
const { server } = await startTestBackend({
features: [
import('@backstage/plugin-auth-backend'),
authModuleAtlassianProvider,
mockServices.rootConfig.factory({
data: {
app: {
baseUrl: 'http://localhost:3000',
},
auth: {
providers: {
atlassian: {
development: {
clientId: 'my-client-id',
clientSecret: 'my-client-secret',
scopes: 'offline_access read:filter:jira read:jira-work',
it('should fail to start with scope or scopes config', async () => {
await expect(
startTestBackend({
features: [
import('@backstage/plugin-auth-backend'),
authModuleAtlassianProvider,
mockServices.rootConfig.factory({
data: {
app: {
baseUrl: 'http://localhost:3000',
},
auth: {
providers: {
atlassian: {
development: {
clientId: 'my-client-id',
clientSecret: 'my-client-secret',
scope: 'foo',
},
},
},
},
},
},
}),
],
});
const agent = request.agent(server);
const res = await agent.get('/api/auth/atlassian/start?env=development');
expect(res.status).toEqual(302);
const nonceCookie = agent.jar.getCookie('atlassian-nonce', {
domain: 'localhost',
path: '/api/auth/atlassian/handler',
script: false,
secure: false,
});
expect(nonceCookie).toBeDefined();
const startUrl = new URL(res.get('location'));
expect(startUrl.origin).toBe('https://auth.atlassian.com');
expect(startUrl.pathname).toBe('/authorize');
expect(Object.fromEntries(startUrl.searchParams)).toEqual({
response_type: 'code',
client_id: 'my-client-id',
redirect_uri: `http://localhost:${server.port()}/api/auth/atlassian/handler/frame`,
state: expect.any(String),
scope: 'offline_access read:filter:jira read:jira-work',
});
expect(decodeOAuthState(startUrl.searchParams.get('state')!)).toEqual({
env: 'development',
nonce: decodeURIComponent(nonceCookie.value),
});
}),
],
}),
).rejects.toThrow(
/atlassian provider no longer supports the "scope" or "scopes" configuration options/,
);
});
});
@@ -25,6 +25,7 @@ export interface Config {
* @visibility secret
*/
clientSecret: string;
additionalScopes?: string | string[];
};
};
};
@@ -26,6 +26,9 @@ import {
export const bitbucketAuthenticator = createOAuthAuthenticator({
defaultProfileTransform:
PassportOAuthAuthenticatorHelper.defaultProfileTransform,
scopes: {
required: ['account'],
},
initialize({ callbackUrl, config }) {
const clientID = config.getString('clientId');
const clientSecret = config.getString('clientSecret');
@@ -67,6 +67,7 @@ describe('authModuleBitbucketProvider', () => {
client_id: 'my-client-id',
redirect_uri: `http://localhost:${server.port()}/api/auth/bitbucket/handler/frame`,
state: expect.any(String),
scope: 'account',
});
expect(decodeOAuthState(startUrl.searchParams.get('state')!)).toEqual({
@@ -27,6 +27,7 @@ export interface Config {
clientSecret: string;
callbackUrl?: string;
enterpriseInstanceUrl?: string;
additionalScopes?: string | string[];
};
};
};
@@ -28,7 +28,10 @@ const ACCESS_TOKEN_PREFIX = 'access-token.';
export const githubAuthenticator = createOAuthAuthenticator({
defaultProfileTransform:
PassportOAuthAuthenticatorHelper.defaultProfileTransform,
shouldPersistScopes: true,
scopes: {
persist: true,
required: ['read:user'],
},
initialize({ callbackUrl, config }) {
const clientId = config.getString('clientId');
const clientSecret = config.getString('clientSecret');
@@ -67,12 +67,13 @@ describe('authModuleGithubProvider', () => {
client_id: 'my-client-id',
redirect_uri: `http://localhost:${server.port()}/api/auth/github/handler/frame`,
state: expect.any(String),
scope: 'read:user',
});
expect(decodeOAuthState(startUrl.searchParams.get('state')!)).toEqual({
env: 'development',
nonce: decodeURIComponent(nonceCookie.value),
scope: '',
scope: 'read:user',
});
});
});
@@ -27,6 +27,7 @@ export interface Config {
clientSecret: string;
audience?: string;
callbackUrl?: string;
additionalScopes?: string | string[];
};
};
};
@@ -26,6 +26,9 @@ import {
export const gitlabAuthenticator = createOAuthAuthenticator({
defaultProfileTransform:
PassportOAuthAuthenticatorHelper.defaultProfileTransform,
scopes: {
required: ['read_user'],
},
initialize({ callbackUrl, config }) {
const clientId = config.getString('clientId');
const clientSecret = config.getString('clientSecret');
@@ -26,6 +26,7 @@ export interface Config {
*/
clientSecret: string;
callbackUrl?: string;
additionalScopes?: string | string[];
};
};
};
@@ -27,6 +27,13 @@ import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
export const googleAuthenticator = createOAuthAuthenticator({
defaultProfileTransform:
PassportOAuthAuthenticatorHelper.defaultProfileTransform,
scopes: {
required: [
'openid',
`https://www.googleapis.com/auth/userinfo.email`,
`https://www.googleapis.com/auth/userinfo.profile`,
],
},
initialize({ callbackUrl, config }) {
const clientId = config.getString('clientId');
const clientSecret = config.getString('clientSecret');
@@ -69,6 +69,8 @@ describe('authModuleGoogleProvider', () => {
client_id: 'my-client-id',
redirect_uri: `http://localhost:${server.port()}/api/auth/google/handler/frame`,
state: expect.any(String),
scope:
'openid https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile',
});
expect(decodeOAuthState(startUrl.searchParams.get('state')!)).toEqual({
+1 -1
View File
@@ -28,7 +28,7 @@ export interface Config {
clientSecret: string;
domainHint?: string;
callbackUrl?: string;
additionalScopes?: string[];
additionalScopes?: string | string[];
};
};
};
@@ -27,7 +27,9 @@ export interface Config {
clientSecret: string;
authorizationUrl: string;
tokenUrl: string;
/** @deprecated use `additionalScopes` instead */
scope?: string;
additionalScopes?: string | string[];
disableRefresh?: boolean;
includeBasicAuth?: boolean;
};
@@ -31,9 +31,14 @@ export const oauth2Authenticator = createOAuthAuthenticator({
const clientSecret = config.getString('clientSecret');
const authorizationUrl = config.getString('authorizationUrl');
const tokenUrl = config.getString('tokenUrl');
const scope = config.getOptionalString('scope');
const includeBasicAuth = config.getOptionalBoolean('includeBasicAuth');
if (config.has('scope')) {
throw new Error(
'The oauth2 provider no longer supports the "scope" configuration option. Please use the "additionalScopes" option instead.',
);
}
return PassportOAuthAuthenticatorHelper.from(
new Oauth2Strategy(
{
@@ -43,7 +48,6 @@ export const oauth2Authenticator = createOAuthAuthenticator({
authorizationURL: authorizationUrl,
tokenURL: tokenUrl,
passReqToCallback: false,
scope: scope,
customHeaders: includeBasicAuth
? {
Authorization: `Basic ${encodeClientCredentials(
@@ -19,7 +19,6 @@ export default authModuleOidcProvider;
// @public (undocumented)
export const oidcAuthenticator: OAuthAuthenticator<
{
initializedScope: string | undefined;
initializedPrompt: string | undefined;
promise: Promise<{
helper: PassportOAuthAuthenticatorHelper;
+1 -1
View File
@@ -29,7 +29,7 @@ export interface Config {
callbackUrl?: string;
tokenEndpointAuthMethod?: string;
tokenSignedResponseAlg?: string;
scope?: string;
additionalScopes?: string | string[];
prompt?: string;
};
};
@@ -254,19 +254,6 @@ describe('oidcAuthenticator', () => {
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,
@@ -53,7 +53,6 @@ export type OidcAuthResult = {
/** @public */
export const oidcAuthenticator = createOAuthAuthenticator({
shouldPersistScopes: true,
defaultProfileTransform: async (
input: OAuthAuthenticatorResult<OidcAuthResult>,
) => ({
@@ -63,6 +62,10 @@ export const oidcAuthenticator = createOAuthAuthenticator({
displayName: input.fullProfile.userinfo.name,
},
}),
scopes: {
persist: true,
required: ['openid', 'profile', 'email'],
},
initialize({ callbackUrl, config }) {
const clientId = config.getString('clientId');
const clientSecret = config.getString('clientSecret');
@@ -74,9 +77,14 @@ export const oidcAuthenticator = createOAuthAuthenticator({
const tokenSignedResponseAlg = config.getOptionalString(
'tokenSignedResponseAlg',
);
const initializedScope = config.getOptionalString('scope');
const initializedPrompt = config.getOptionalString('prompt');
if (config.has('scope')) {
throw new Error(
'The oidc provider no longer supports the "scope" configuration option. Please use the "additionalScopes" option instead.',
);
}
Issuer[custom.http_options] = httpOptionsProvider;
const promise = Issuer.discover(metadataUrl).then(issuer => {
issuer[custom.http_options] = httpOptionsProvider;
@@ -91,7 +99,6 @@ export const oidcAuthenticator = createOAuthAuthenticator({
token_endpoint_auth_method:
tokenEndpointAuthMethod || 'client_secret_basic',
id_token_signed_response_alg: tokenSignedResponseAlg || 'RS256',
scope: initializedScope || '',
});
client[custom.http_options] = httpOptionsProvider;
@@ -123,14 +130,14 @@ export const oidcAuthenticator = createOAuthAuthenticator({
return { helper, client, strategy };
});
return { initializedScope, initializedPrompt, promise };
return { initializedPrompt, promise };
},
async start(input, ctx) {
const { initializedScope, initializedPrompt, promise } = ctx;
const { initializedPrompt, promise } = ctx;
const { helper, strategy } = await promise;
const options: Record<string, string> = {
scope: input.scope || initializedScope || 'openid profile email',
scope: input.scope,
state: input.state,
nonce: crypto.randomBytes(16).toString('base64'),
};
@@ -212,7 +212,7 @@ describe('authModuleOidcProvider', () => {
expect(decodeOAuthState(startUrl.searchParams.get('state')!)).toEqual({
env: 'development',
nonce: decodeURIComponent(nonceCookie.value),
scope: '',
scope: 'openid profile email',
});
});
+1 -1
View File
@@ -29,7 +29,7 @@ export interface Config {
authServerId?: string;
idp?: string;
callbackUrl?: string;
additionalScopes?: string;
additionalScopes?: string | string[];
};
};
};
@@ -26,25 +26,15 @@ import {
export const oktaAuthenticator = createOAuthAuthenticator({
defaultProfileTransform:
PassportOAuthAuthenticatorHelper.defaultProfileTransform,
scopes: {
required: ['openid', 'email', 'profile', 'offline_access'],
},
initialize({ callbackUrl, config }) {
const clientId = config.getString('clientId');
const clientSecret = config.getString('clientSecret');
const audience = config.getOptionalString('audience') || 'https://okta.com';
const authServerId = config.getOptionalString('authServerId');
const idp = config.getOptionalString('idp');
// default scopes are taken from
// https://developer.okta.com/docs/reference/api/oidc/#response-example-success-refresh-token
const defaultScopes = 'openid profile email';
// additional scopes can be configured in the config as a space separated string
const additionalScopes = config.getOptionalString('additionalScopes') || '';
// combine default and additional scopes and remove duplicates
const combineScopeStrings = (scopesA: string, scopesB: string) => {
const scopesAArray = scopesA.split(' ');
const scopesBArray = scopesB.split(' ');
const combinedScopes = new Set([...scopesAArray, ...scopesBArray]);
return Array.from(combinedScopes).join(' ');
};
const scope = combineScopeStrings(defaultScopes, additionalScopes);
return PassportOAuthAuthenticatorHelper.from(
new OktaStrategy(
@@ -57,7 +47,6 @@ export const oktaAuthenticator = createOAuthAuthenticator({
idp: idp,
passReqToCallback: false,
response_type: 'code',
scope,
},
(
accessToken: string,
@@ -21,7 +21,6 @@ import { decodeOAuthState } from '@backstage/plugin-auth-node';
describe('authModuleOktaProvider', () => {
it('should start', async () => {
const additionalScopes = 'groups phone';
const { server } = await startTestBackend({
features: [
import('@backstage/plugin-auth-backend'),
@@ -37,7 +36,7 @@ describe('authModuleOktaProvider', () => {
development: {
clientId: 'my-client-id',
clientSecret: 'my-client-secret',
additionalScopes,
additionalScopes: 'groups phone',
},
},
},
@@ -66,7 +65,7 @@ describe('authModuleOktaProvider', () => {
expect(startUrl.pathname).toBe('/oauth2/v1/authorize');
expect(Object.fromEntries(startUrl.searchParams)).toEqual({
response_type: 'code',
scope: additionalScopes,
scope: 'openid email profile offline_access groups phone',
client_id: 'my-client-id',
redirect_uri: `http://localhost:${server.port()}/api/auth/okta/handler/frame`,
state: expect.any(String),
@@ -249,22 +249,14 @@ describe('pinnipedAuthenticator', () => {
expect(fakeSession['oidc:pinniped.test'].code_verifier).toBeDefined();
});
it('requests sufficient scopes for token exchange by default', async () => {
it('forwards scopes for token exchange', async () => {
const startResponse = await pinnipedAuthenticator.start(
startRequest,
{ ...startRequest, scope: 'openid username' },
authCtx,
);
const { searchParams } = new URL(startResponse.url);
const scopes = searchParams.get('scope')?.split(' ') ?? [];
expect(scopes).toEqual(
expect.arrayContaining([
'openid',
'pinniped:request-audience',
'username',
'offline_access',
]),
);
expect(searchParams.get('scope')).toBe('openid username');
});
it('encodes OAuth state in query param', async () => {
@@ -127,7 +127,6 @@ export class PinnipedStrategyCache {
client_secret: this.config.getString('clientSecret'),
redirect_uris: [this.callbackUrl],
response_types: ['code'],
scope: this.config.getOptionalString('scope') || '',
id_token_signed_response_alg: 'ES256',
});
const providerStrategy = new OidcStrategy(
@@ -154,7 +153,20 @@ export class PinnipedStrategyCache {
/** @public */
export const pinnipedAuthenticator = createOAuthAuthenticator({
defaultProfileTransform: async (_r, _c) => ({ profile: {} }),
scopes: {
required: [
'openid',
'pinniped:request-audience',
'username',
'offline_access',
],
},
initialize({ callbackUrl, config }) {
if (config.has('scope')) {
throw new Error(
'The pinniped provider no longer supports the "scope" configuration option. Please use the "additionalScopes" option instead.',
);
}
return new PinnipedStrategyCache(callbackUrl, config);
},
async start(input, ctx): Promise<{ url: string; status?: number }> {
@@ -163,9 +175,7 @@ export const pinnipedAuthenticator = createOAuthAuthenticator({
const decodedState = decodeOAuthState(input.state);
const state = { ...decodedState, audience: stringifiedAudience };
const options: Record<string, string> = {
scope:
input.scope ||
'openid pinniped:request-audience username offline_access',
scope: input.scope,
state: encodeOAuthState(state),
};
@@ -24,6 +24,7 @@ export interface Config {
organizationId: string;
scope?: string;
consoleEndpoint?: string;
additionalScopes?: string | string[];
};
};
};
@@ -163,9 +163,9 @@ describe('vmwareCloudAuthenticator', () => {
expect(searchParams.get('redirect_uri')).toBe('http://callbackUrl');
});
it('requests scopes for ID and refresh token', async () => {
it('forwards scopes for ID and refresh token', async () => {
const startResponse = await vmwareCloudAuthenticator.start(
startRequest,
{ ...startRequest, scope: 'openid offline_access' },
authenticatorCtx,
);
const { searchParams } = new URL(startResponse.url);
@@ -96,6 +96,9 @@ export const vmwareCloudAuthenticator = createOAuthAuthenticator<
},
};
},
scopes: {
required: ['openid', 'offline_access'],
},
initialize({ callbackUrl, config }) {
const consoleEndpoint =
config.getOptionalString('consoleEndpoint') ??
@@ -106,7 +109,12 @@ export const vmwareCloudAuthenticator = createOAuthAuthenticator<
const clientSecret = '';
const authorizationUrl = `${consoleEndpoint}/csp/gateway/discovery`;
const tokenUrl = `${consoleEndpoint}/csp/gateway/am/api/auth/token`;
const scope = config.getOptionalString('scope') ?? 'openid offline_access';
if (config.has('scope')) {
throw new Error(
'The vmware-cloud provider no longer supports the "scope" configuration option. Please use the "additionalScopes" option instead.',
);
}
const providerStrategy = new OAuth2Strategy(
{
@@ -118,7 +126,6 @@ export const vmwareCloudAuthenticator = createOAuthAuthenticator<
passReqToCallback: false,
pkce: true,
state: true,
scope: scope,
customHeaders: {
Authorization: `Basic ${encodeClientCredentials(
clientId,