diff --git a/.changeset/brown-grapes-fold.md b/.changeset/brown-grapes-fold.md new file mode 100644 index 0000000000..d8ab0a23f3 --- /dev/null +++ b/.changeset/brown-grapes-fold.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend-module-auth0-provider': patch +--- + +Add support for organizational invites in auth0 strategy diff --git a/plugins/auth-backend-module-auth0-provider/package.json b/plugins/auth-backend-module-auth0-provider/package.json index 5bd7e342ee..edb5b457b4 100644 --- a/plugins/auth-backend-module-auth0-provider/package.json +++ b/plugins/auth-backend-module-auth0-provider/package.json @@ -35,6 +35,7 @@ }, "dependencies": { "@backstage/backend-plugin-api": "workspace:^", + "@backstage/errors": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", "express": "^4.22.0", "passport": "^0.7.0", diff --git a/plugins/auth-backend-module-auth0-provider/src/module.test.ts b/plugins/auth-backend-module-auth0-provider/src/module.test.ts index 7e7f649831..28ccf90fa4 100644 --- a/plugins/auth-backend-module-auth0-provider/src/module.test.ts +++ b/plugins/auth-backend-module-auth0-provider/src/module.test.ts @@ -87,4 +87,97 @@ describe('authModuleAuth0Provider', () => { nonce: decodeURIComponent(nonceCookie.value), }); }); + + it('should pass through organization and invitation parameters to the authorization URL', async () => { + const { server } = await startTestBackend({ + features: [ + authPlugin, + authModuleAuth0Provider, + mockServices.rootConfig.factory({ + data: { + app: { + baseUrl: 'http://localhost:3000', + }, + auth: { + providers: { + auth0: { + development: { + clientId: 'clientId', + clientSecret: 'clientSecret', + domain: 'domain', + connection: 'connection', + connectionScope: 'connectionScope', + organization: 'foo-organization', + }, + }, + }, + session: { + secret: 'secret', + }, + }, + }, + }), + ], + }); + + const agent = request.agent(server); + + const res = await agent.get( + '/api/auth/auth0/start?env=development&organization=foo-organization&invitation=foo-invitation', + ); + + const startUrl = new URL(res.get('location')); + expect(startUrl.origin).toBe('https://domain'); + expect(startUrl.pathname).toBe('/authorize'); + expect(Object.fromEntries(startUrl.searchParams)).toEqual( + expect.objectContaining({ + organization: 'foo-organization', + invitation: 'foo-invitation', + }), + ); + }); + + it('should throw an error if the organization in the request does not match the organization configured in the strategy', async () => { + const { server } = await startTestBackend({ + features: [ + authPlugin, + authModuleAuth0Provider, + mockServices.rootConfig.factory({ + data: { + app: { + baseUrl: 'http://localhost:3000', + }, + auth: { + providers: { + auth0: { + development: { + clientId: 'clientId', + clientSecret: 'clientSecret', + domain: 'domain', + connection: 'connection', + connectionScope: 'connectionScope', + organization: 'bar-organization', + }, + }, + }, + session: { + secret: 'secret', + }, + }, + }, + }), + ], + }); + + const agent = request.agent(server); + + const res = await agent.get( + '/api/auth/auth0/start?env=development&organization=foo-organization&invitation=foo-invitation', + ); + + expect(res.status).toEqual(400); + expect(res.text).toContain( + 'Organization mismatch. The organization provided in the request does not match the organization configured in the strategy.', + ); + }); }); diff --git a/plugins/auth-backend-module-auth0-provider/src/strategy.ts b/plugins/auth-backend-module-auth0-provider/src/strategy.ts index a8699555a7..b6252aa7bd 100644 --- a/plugins/auth-backend-module-auth0-provider/src/strategy.ts +++ b/plugins/auth-backend-module-auth0-provider/src/strategy.ts @@ -16,6 +16,8 @@ import Auth0InternalStrategy from 'passport-auth0'; import type { StateStore } from 'passport-oauth2'; +import type express from 'express'; +import { InputError } from '@backstage/errors'; /** @public */ export interface Auth0StrategyOptionsWithRequest { @@ -47,15 +49,38 @@ export class Auth0Strategy extends Auth0InternalStrategy { this.organization = options.organization; } + authenticate(req: express.Request, options: Record): void { + const { organization, invitation } = req.query; + + // Throw an error if the organization in the request does not match the organization configured in the strategy + if ( + organization && + this.organization && + organization !== this.organization + ) { + throw new InputError( + 'Organization mismatch. The organization provided in the request does not match the organization configured in the strategy.', + ); + } + + super.authenticate(req, { + ...options, + ...(organization ? { organization } : {}), + ...(invitation ? { invitation } : {}), + }); + } + authorizationParams(options: Record): Record { const params = super.authorizationParams(options); - if (this.organization) { - return { - ...params, - organization: this.organization, - }; + if (options.organization || this.organization) { + params.organization = options.organization || this.organization; } + + if (options.invitation) { + params.invitation = options.invitation; + } + return params; } } diff --git a/yarn.lock b/yarn.lock index 8cbe70fc51..f428351f0f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4284,6 +4284,7 @@ __metadata: "@backstage/backend-plugin-api": "workspace:^" "@backstage/backend-test-utils": "workspace:^" "@backstage/cli": "workspace:^" + "@backstage/errors": "workspace:^" "@backstage/plugin-auth-backend": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" "@backstage/types": "workspace:^"