aws / aws-sdk-js-v3

Modularized AWS SDK for JavaScript.
Apache License 2.0
3.12k stars 578 forks source link

Redis Signer for connecting to Redis 7 using IAM authentication #6611

Open meenar-se opened 1 year ago

meenar-se commented 1 year ago

Describe the feature

Need an implementation for Redis signer so that its useful to connect to Elastic cache redis version 7 or above using IAM Authentication.

Use Case

Recently AWS introduced an option to connect to Redis 7 using IAM authentication. But its not directly supported by the library. So we have slightly tweaked the RDS signer to support Redis as well. It will be useful if we add the Redis Signer as well in library itself.

Proposed Solution

No response

Other Information

No response

Acknowledgements

SDK version used

3.350.0

Environment details (OS name and version, etc.)

Macos 12.6.3

RanVaknin commented 1 year ago

Hi @meenar-se ,

Thanks for opening this feature request.

I think your request is reasonable, but like RDS signer, this would need to be a handwritten utility (whereas most of the SDK is code generated from the API models of the each service), this means that it will have to be properly designed and implemented in a uniform fashion across all SDKs.

Additionally, feature requests are accepted and implemented based on community engagement (comments, upvotes, or duplicate FRs). This helps us prioritize features in the most impactful way, and use the teams resources wisely.

I will transfer this FR to the cross SDK repo for it to gain traction there. This unfortunately means that it will not get prioritized right away, but it doesn't mean it wont in the future.

Since you marked the "I may be able to implement this feature request" checkbox, I'd encourage you to write your implementation here on this ticket for two reasons:

  1. It will allow other community members that are facing the same issue to use your solution as a workaround.
  2. When time comes to execute on this feature request, it will be a good starting points for one of the devs to refer to and test against.

Thanks again 😄 Ran

meenar-se commented 1 year ago

Implementation

Configuration file: runtimeConfig.ts

import { fromNodeProviderChain } from '@aws-sdk/credential-providers';
import { Hash } from '@aws-sdk/hash-node';
import { loadConfig } from '@aws-sdk/node-config-provider';
import { SignerConfig } from './Signer';

/**
 * @internal
 */
export const getRuntimeConfig = (config: SignerConfig) : SignerConfig => ({
  runtime: 'node',
  sha256: config?.sha256 ?? Hash.bind(null, 'sha256'),
  credentials: config?.credentials ?? fromNodeProviderChain(),
  region: config?.region ?? loadConfig(NODE_REGION_CONFIG_OPTIONS, NODE_REGION_CONFIG_FILE_OPTIONS),
  expiresIn: 900,
  ...config,
} as SignerConfig);

Signer Implementation: Signer.ts

import { SignatureV4 } from '@aws-sdk/signature-v4';
import {
  AwsCredentialIdentity,
  AwsCredentialIdentityProvider,
  ChecksumConstructor,
} from '@aws-sdk/types';
import { formatUrl } from '@aws-sdk/util-format-url';

import { getRuntimeConfig as __getRuntimeConfig } from './runtimeConfig';
import { defaultProvider } from '@aws-sdk/credential-provider-node';

export interface SignerConfig {
  /**
   * The AWS credentials to sign requests with. Uses the default credential provider chain if not specified.
   */
  credentials?: AwsCredentialIdentity | AwsCredentialIdentityProvider
  /**
   * The hostname of the database to connect to.
   */
  hostname: string
  /**
   * The port number the database is listening on.
   */
  port?: number
  /**
   * The region the database is located in. Uses the region inferred from the runtime if omitted.
   */
  region?: string
  /**
   * The SHA256 hasher constructor to sign the request.
   */
  sha256?: ChecksumConstructor
  /**
   * The username to login as.
   */
  username: string
  runtime?: string
  expiresIn?: number
}

/**
 * The signer class that generates an auth token to a database.
 */
export class Signer {

  private readonly protocol: string = 'http:';
  private readonly service: string = 'elasticache';

  public constructor(public configuration: SignerConfig) {
    this.configuration = __getRuntimeConfig(configuration);

  }

  public async getAuthToken(): Promise<string> {
    const signer = new SignatureV4({
      service: this.service,
      region: this.configuration.region!,
      credentials: this.configuration.credentials ?? defaultProvider(),
      sha256: this.configuration.sha256!,
    });

    const request = new HttpRequest({
      method: 'GET',
      protocol: this.protocol,
      hostname: this.configuration.hostname,
      port: this.configuration.port,
      query: {
        Action: 'connect',
        User: this.configuration.username,   
      },
      headers: {
        host: `${this.configuration.hostname}`,
      },
    });

    const presigned = await signer.presign(request, {
      expiresIn: this.configuration.expiresIn,
    });
    const format = formatUrl(presigned).replace(`${this.protocol}//`, '');
    console.log(format);

    return format;
  }
}

Usage example


import { createClient } from "redis";
import { Signer } from "./Signer";

export const redisConnect = async () => {
  console.log("calling redis connect");
  const credentials = await generateAssumeRoleCreds();
  const sign = new Signer({
    region: region,
    hostname: `${replicationGroupId}`,
    username: userId,
    credentials: credentials,
  });
  const presignedUrl = await sign.getAuthToken();
  const redisConfig = {
    url: `redis://master.xxx.xxx.xxxx.use1.cache.amazonaws.com:6379`,
    password: presignedUrl,
    username: userId,
    socket: {
      tls: true,
      rejectUnauthorized: false,
    },
  };
  const redisClient = await createClient(redisConfig);
  try {
    await redisClient.connect();
    console.log(await redisClient.get("key"));
  } catch (error) {
    console.log("Error catched " + error);
  }
};
meenar-se commented 1 year ago

I can also work on the implementing this feature in SDK. We can have the signer as common utility and use it for REDIS and RDS. Please suggest.

X-Guardian commented 1 week ago

HI @meenar-se, did you ever get your sample code to work? I've tried it on Elasticache redis/valkey serverless and non-severless and always get ErrorReply: WRONGPASS invalid username-password pair or user is disabled.

For serverless, it looks like an additional ResourceType=ServerlessCache query parameter is needed, but I've tried it with and without. Ref: https://docs.aws.amazon.com/AmazonElastiCache/latest/dg/auth-iam.html#auth-iam-Connecting

X-Guardian commented 1 week ago

I got it working using https://github.com/aws-samples/elasticache-iam-auth-demo-app as reference. Here is my updated code including serverless support:

Configuration file: runtimeConfig.ts

import { fromNodeProviderChain } from '@aws-sdk/credential-providers';
import { NODE_REGION_CONFIG_FILE_OPTIONS, NODE_REGION_CONFIG_OPTIONS } from '@smithy/config-resolver';
import { Hash } from '@smithy/hash-node';
import { loadConfig } from '@smithy/node-config-provider';
import { SignerConfig } from './signer';

/**
 * @internal
 */
export const getRuntimeConfig = (config: SignerConfig) => {
  return {
    runtime: 'node',
    sha256: config?.sha256 ?? Hash.bind(null, 'sha256'),
    credentials: config?.credentials ?? fromNodeProviderChain(),
    region: config?.region ?? loadConfig(NODE_REGION_CONFIG_OPTIONS, NODE_REGION_CONFIG_FILE_OPTIONS),
    ...config,
  };
};

Signer Implementation: signer.ts

import { formatUrl } from '@aws-sdk/util-format-url';
import { HttpRequest } from '@smithy/protocol-http';
import { SignatureV4 } from '@smithy/signature-v4';
import {
  AwsCredentialIdentity,
  AwsCredentialIdentityProvider,
  ChecksumConstructor,
  HashConstructor,
  Provider,
} from '@smithy/types';

import { getRuntimeConfig as __getRuntimeConfig } from './runtimeConfig';

export enum ClusterType {
  normal = 'normal',
  serverless = 'serverless',
}

export interface SignerConfig {
  /**
   * The AWS credentials to sign requests with. Uses the default credential provider chain if not specified.
   */
  credentials?: AwsCredentialIdentity | AwsCredentialIdentityProvider;
  /**
   * The hostname of the cache to connect to.
   */
  cacheName: string;
  /**
   * The resource type of the cluster.
   */
  resourceType: string;
  /**
   * The region the database is located in. Uses the region inferred from the runtime if omitted.
   */
  region?: string;
  /**
   * The SHA256 hasher constructor to sign the request.
   */
  sha256?: ChecksumConstructor | HashConstructor;
  /**
   * The username to login as.
   */
  username: string;
}

/**
 * The signer class that generates an auth token to a database.
 */
export class Signer {
  private readonly credentials: AwsCredentialIdentity | AwsCredentialIdentityProvider;
  private readonly cacheName: string;
  private readonly resourceType: string;
  private readonly protocol: string = 'https:';
  private readonly region: string | Provider<string>;
  private readonly service: string = 'elasticache';
  private readonly sha256: ChecksumConstructor | HashConstructor;
  private readonly username: string;

  constructor(configuration: SignerConfig) {
    const runtimeConfiguration = __getRuntimeConfig(configuration);

    this.credentials = runtimeConfiguration.credentials;
    this.cacheName = runtimeConfiguration.cacheName;
    this.resourceType = runtimeConfiguration.resourceType;
    this.region = runtimeConfiguration.region;
    this.sha256 = runtimeConfiguration.sha256;
    this.username = runtimeConfiguration.username;
  }

  public async getAuthToken(): Promise<string> {
    const signer = new SignatureV4({
      service: this.service,
      region: this.region,
      credentials: this.credentials,
      sha256: this.sha256,
    });

    const request = new HttpRequest({
      method: 'GET',
      protocol: this.protocol,
      hostname: this.cacheName,
      query: {
        Action: 'connect',
        User: this.username,
        // add ResourceType property if serverless
        ...(this.resourceType === 'serverless' ? { ResourceType: 'ServerlessCache' } : {}),
      },
      headers: {
        host: this.cacheName,
      },
    });

    const presigned = await signer.presign(request, {
      expiresIn: 900,
    });

    return formatUrl(presigned).replace(`${this.protocol}//`, '');
  }
}

Usage example

import { fromNodeProviderChain } from '@aws-sdk/credential-providers';
import { createClient } from 'redis';
import { Signer, ClusterType } from './signer';

const options = {
  hostname: 'valkey-serverless.id.serverless.euw2.cache.amazonaws.com',
  cacheName: 'valkey-serverless',
  region: 'eu-west-2',
  clusterType: ClusterType.serverless,
  username: 'valkey-serverless-user',
};

export const redisConnect = async () => {
  let credentials = fromNodeProviderChain();

  console.log('calling redis connect');
  const sign = new Signer({
    credentials: credentials,
    cacheName: options.cacheName,
    region: options.region,
    resourceType: options.clusterType,
    username: options.username,
  });
  const presignedUrl = await sign.getAuthToken();
  const redisConfig = {
    url: options.hostname,
    password: presignedUrl,
    username: options.username,
    socket: {
      tls: true,
      rejectUnauthorized: false,
    },
  };
  const redisClient = createClient(redisConfig);
  try {
    await redisClient.connect();
    console.log(await redisClient.ping());
  } catch (error) {
    console.log('Error catched ' + error);
  }
};