diff --git a/.changeset/eleven-dolphins-divide.md b/.changeset/eleven-dolphins-divide.md new file mode 100644 index 0000000000..b3f815dba6 --- /dev/null +++ b/.changeset/eleven-dolphins-divide.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend-module-aws-alb-provider': patch +--- + +Added a `signer` configuration option to validate against the token claims diff --git a/docs/auth/aws-alb/provider.md b/docs/auth/aws-alb/provider.md index 8f21b7dbf0..a3974c895e 100644 --- a/docs/auth/aws-alb/provider.md +++ b/docs/auth/aws-alb/provider.md @@ -11,14 +11,18 @@ and get the user seamlessly authenticated. ## Configuration The provider configuration can be added to your `app-config.yaml` under the root -`auth` configuration: +`auth` configuration, similar to the following example: ```yaml title="app-config.yaml" auth: providers: awsalb: - issuer: 'https://example.okta.com/oauth2/default' # optional - region: 'us-west-2' # required, use your actual region here + # this is the URL of the IdP you configured + issuer: 'https://example.okta.com/oauth2/default' + # this is the ARN of your ALB instance + signer: 'arn:aws:elasticloadbalancing:us-east-2:123456789012:loadbalancer/app/my-load-balancer/1234567890123456' + # this is the region where your ALB instance resides + region: 'us-west-2' signIn: resolvers: # typically you would pick one of these @@ -26,6 +30,8 @@ auth: - resolver: emailLocalPartMatchingUserEntityName ``` +Ensure that you have set the signer correctly. It is also recommended that you restrict your target groups' security policy to only accept connections from that ALB. + ### Resolvers This provider includes several resolvers out of the box that you can use: diff --git a/plugins/auth-backend-module-aws-alb-provider/config.d.ts b/plugins/auth-backend-module-aws-alb-provider/config.d.ts index 5a3e90cf6b..d76ae5bada 100644 --- a/plugins/auth-backend-module-aws-alb-provider/config.d.ts +++ b/plugins/auth-backend-module-aws-alb-provider/config.d.ts @@ -19,7 +19,25 @@ export interface Config { providers?: { /** @visibility frontend */ awsalb?: { - issuer?: string; + /** + * The issuer IdP URL that was configured in your ALB; this corresponds + * to the `iss` claim in your tokens. + * + * @example https://example.okta.com/oauth2/default + */ + issuer: string; + /** + * The ARN of the ALB that signs the tokens; this corresponds to the + * `signer` claim in your tokens. + * + * @example arn:aws:elasticloadbalancing:us-east-2:123456789012:loadbalancer/app/my-load-balancer/1234567890123456 + */ + signer?: string; + /** + * The AWS region where the ALB is located. + * + * @example us-east-2 + */ region: string; signIn?: { resolvers: Array< diff --git a/plugins/auth-backend-module-aws-alb-provider/src/authenticator.test.ts b/plugins/auth-backend-module-aws-alb-provider/src/authenticator.test.ts index e4700d10dd..c0e32d37a3 100644 --- a/plugins/auth-backend-module-aws-alb-provider/src/authenticator.test.ts +++ b/plugins/auth-backend-module-aws-alb-provider/src/authenticator.test.ts @@ -21,7 +21,7 @@ import { ALB_JWT_HEADER, awsAlbAuthenticator, } from './authenticator'; -import { Config } from '@backstage/config'; +import { ConfigReader } from '@backstage/config'; import { AuthenticationError } from '@backstage/errors'; describe('AwsAlbProvider', () => { @@ -35,6 +35,7 @@ describe('AwsAlbProvider', () => { email: 'user.name@email.test', exp: Date.now() + 10000, iss: 'ISSUER_URL', + signer: 'SIGNER_ARN', }; const signingKey = new TextEncoder().encode('signingKey'); let mockJwt: string; @@ -87,6 +88,7 @@ describe('AwsAlbProvider', () => { { req: mockRequest }, { issuer: 'ISSUER_URL', + signer: 'SIGNER_ARN', getKey: jest.fn().mockResolvedValue(signingKey), }, ); @@ -114,12 +116,13 @@ describe('AwsAlbProvider', () => { }); }); }); + describe('should fail when', () => { it('Access token is missing', async () => { await expect( awsAlbAuthenticator.authenticate( { req: mockRequestWithoutAccessToken }, - { issuer: 'ISSUER_URL', getKey: jest.fn() }, + { issuer: 'ISSUER_URL', signer: 'SIGNER_ARN', getKey: jest.fn() }, ), ).rejects.toThrow(AuthenticationError); }); @@ -128,7 +131,7 @@ describe('AwsAlbProvider', () => { await expect( awsAlbAuthenticator.authenticate( { req: mockRequestWithoutJwt }, - { issuer: 'ISSUER_URL', getKey: jest.fn() }, + { issuer: 'ISSUER_URL', signer: 'SIGNER_ARN', getKey: jest.fn() }, ), ).rejects.toThrow(AuthenticationError); }); @@ -137,7 +140,7 @@ describe('AwsAlbProvider', () => { await expect( awsAlbAuthenticator.authenticate( { req: mockRequestWithInvalidJwt }, - { issuer: 'ISSUER_URL', getKey: jest.fn() }, + { issuer: 'ISSUER_URL', signer: 'SIGNER_ARN', getKey: jest.fn() }, ), ).rejects.toThrow( 'Exception occurred during JWT processing: JWSInvalid: Invalid Compact JWS', @@ -164,6 +167,7 @@ describe('AwsAlbProvider', () => { { req }, { issuer: 'ISSUER_URL', + signer: undefined, getKey: jest.fn().mockResolvedValue(signingKey), }, ), @@ -192,6 +196,36 @@ describe('AwsAlbProvider', () => { { req }, { issuer: 'ISSUER_URL', + signer: 'SIGNER_ARN', + getKey: jest.fn().mockResolvedValue(signingKey), + }, + ), + ).rejects.toThrow( + 'Exception occurred during JWT processing: AuthenticationError: Issuer mismatch on JWT token', + ); + }); + + it('signer is invalid', async () => { + const jwt = await new SignJWT({ signer: 'INVALID_SIGNER_ARN' }) + .setProtectedHeader({ alg: 'HS256' }) + .sign(signingKey); + const req = { + header: jest.fn(name => { + if (name === ALB_JWT_HEADER) { + return jwt; + } else if (name === ALB_ACCESS_TOKEN_HEADER) { + return mockAccessToken; + } + return undefined; + }), + } as unknown as express.Request; + + await expect( + awsAlbAuthenticator.authenticate( + { req }, + { + issuer: 'ISSUER_URL', + signer: 'SIGNER_ARN', getKey: jest.fn().mockResolvedValue(signingKey), }, ), @@ -200,15 +234,14 @@ describe('AwsAlbProvider', () => { ); }); }); + describe('should initialize', () => { it('with default options', async () => { const config = { - config: { - getString: jest - .fn() - .mockReturnValueOnce('ISSUER_URL') - .mockReturnValueOnce('TEST_REGION'), - } as unknown as Config, + config: new ConfigReader({ + issuer: 'ISSUER_URL', + region: 'TEST_REGION', + }), }; expect(awsAlbAuthenticator.initialize(config)).toEqual({ diff --git a/plugins/auth-backend-module-aws-alb-provider/src/authenticator.ts b/plugins/auth-backend-module-aws-alb-provider/src/authenticator.ts index e7e0a7d76d..f62fe4175b 100644 --- a/plugins/auth-backend-module-aws-alb-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-aws-alb-provider/src/authenticator.ts @@ -36,12 +36,13 @@ export const awsAlbAuthenticator = createProxyAuthenticator({ }, initialize({ config }) { const issuer = config.getString('issuer'); + const signer = config.getOptionalString('signer'); const region = config.getString('region'); const keyCache = new NodeCache({ stdTTL: 3600 }); const getKey = provisionKeyCache(region, keyCache); - return { issuer, getKey }; + return { issuer, signer, getKey }; }, - async authenticate({ req }, { issuer, getKey }) { + async authenticate({ req }, { issuer, signer, getKey }) { const jwt = req.header(ALB_JWT_HEADER); const accessToken = req.header(ALB_ACCESS_TOKEN_HEADER); @@ -61,8 +62,10 @@ export const awsAlbAuthenticator = createProxyAuthenticator({ const verifyResult = await jwtVerify(jwt, getKey); const claims = verifyResult.payload as AwsAlbClaims; - if (issuer && claims?.iss !== issuer) { + if (claims?.iss !== issuer) { throw new AuthenticationError('Issuer mismatch on JWT token'); + } else if (signer && claims?.signer !== signer) { + throw new AuthenticationError('Signer mismatch on JWT token'); } const fullProfile: PassportProfile = { diff --git a/plugins/auth-backend-module-aws-alb-provider/src/types.ts b/plugins/auth-backend-module-aws-alb-provider/src/types.ts index c45640851c..679535804e 100644 --- a/plugins/auth-backend-module-aws-alb-provider/src/types.ts +++ b/plugins/auth-backend-module-aws-alb-provider/src/types.ts @@ -38,4 +38,5 @@ export type AwsAlbClaims = { email: string; exp: number; iss: string; + signer: string; };