LambdaとALBでCognito認証をかけて失敗したらログイン画面に飛ばす

(2019-07-03)

ALBのTargetとしてLambdaが選択できるようになり、 若干の時間課金が発生する代わりに柔軟にルーティングできるAPI Gatewayのように使えるようになった。 ActionとしてCognito認証を入れて認証に失敗したらログイン画面を表示させる。

API GatewayでCognitoの認証をかけて必要ならログイン画面に飛ばす処理をGoで書く - sambaiz-net

ACMで証明書を発行する

HTTPSでListenするため証明書が必要。 AWS Certificate Manager (ACM)でAWSで使える証明書を無料で発行でき参照できる。 外部で取ったドメインでもよい。 検証方法はDNSとメールとで選ぶことができて、DNSで行う場合Route53ならワンクリックで検証用のCNAMEレコードを作成できる。 検証までやや時間がかかるのでちゃんと通ってるかnslookupで確認しといた方がよい。

Application Load Balancer (ALB)

次の要素から構成されるL7のロードバランサー。

  • Listener: 指定したプロトコルとポートでリクエストを受ける。
  • ListenerRule: パスやHeaderなどの値を条件にどのTargetGroupにルーティングするかのルール。
  • TargetGroup: ルーティングする1つ以上のTarget。Instance, IP, Lambdaが選べる。

Ruleの作成

Serverless FrameworkではALBのenentを付けるだけでLambdaに向くRuleが作成されるが、そこにはCognitoを追加できなさそうなので使っていない。OnUnauthenticatedRequestで認証失敗時の挙動を選択できる。UserPoolとSecretありのClientはあらかじめ作っておく。コールバックURLにはhttps://<domain>/oauth2/idpresponseを追加する。

API Gatewayだとタイムアウトの上限が30秒なのに対してALBはLambdaの上限まで待てる。

$ cat serverless.yml
service: alb-cognito-auth-example
frameworkVersion: ">=1.28.0 <2.0.0"

provider:
  name: aws
  runtime: go1.x
  region: ap-northeast-1

package:
 exclude:
   - ./**
 include:
   - ./bin/**

functions:
  privateapi:
    handler: bin/privateapi
    timeout: 30

resources:
  Resources:
    ALBTargetGroup:
      DependsOn: InvokeLambdaPermissionForALB
      Type: AWS::ElasticLoadBalancingV2::TargetGroup
      Properties:
        Name: "private-lambda-target-group"
        TargetType: "lambda"
        Targets:
          - Id: !GetAtt PrivateapiLambdaFunction.Arn # Reference functions.privateapi
    InvokeLambdaPermissionForALB:
      Type: AWS::Lambda::Permission
      Properties: 
        Action: "lambda:InvokeFunction"
        FunctionName: !Ref PrivateapiLambdaFunction
        Principal: "elasticloadbalancing.amazonaws.com"
    ALB: 
      Type: "AWS::ElasticLoadBalancingV2::LoadBalancer"
      Properties: 
        Name: "serverless-auth-example-alb"
        Scheme: "internet-facing"
        LoadBalancerAttributes: 
          - Key: "idle_timeout.timeout_seconds"
            Value: 30
        SecurityGroups:
          - "sg-*****"
        Subnets:
          - "subnet-*****"
          - "subnet-*****"
    ALBListener: 
      Type: "AWS::ElasticLoadBalancingV2::Listener"
      Properties: 
        DefaultActions: 
          - Type: authenticate-cognito
            Order: 1
            AuthenticateCognitoConfig: 
              OnUnauthenticatedRequest: authenticate
              UserPoolArn: "arn:aws:cognito-idp:ap-northeast-1:*****"
              UserPoolClientId: "*****"
              UserPoolDomain: "*****.auth.ap-northeast-1.amazoncognito.com"
          - Type: forward
            Order: 2
            TargetGroupArn: !Ref ALBTargetGroup
        LoadBalancerArn: !Ref ALB
        Port: 443
        Protocol: HTTPS
        Certificates: 
          - CertificateArn: "arn:aws:acm:ap-northeast-1:*****:certificate/*****"
    RecordSet:
      Type: AWS::Route53::RecordSet
      Properties: 
        Type: "A" # or AAAA (IPv6)
        HostedZoneName: "foo.example.com."
        Name: "auth-example.foo.example.com."
        AliasTarget: 
          HostedZoneId: !GetAtt ALB.CanonicalHostedZoneID
          DNSName: !GetAtt ALB.DNSName
        Comment: "Added by servereless-alb-cognito-auth-example"

x-amzn-oidc-data HeaderにALBが署名したJWTトークンが入っているので、 それがALBから来ているものかを検証しclaimを参照できる。リクエストはAPI Gatewayと同じような形式。

func ParseToken(tokenString string) (jwt.MapClaims, error) {
	token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
		// ES256
		if _, ok := token.Method.(*jwt.SigningMethodECDSA); !ok {
			return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
		}
		url := fmt.Sprintf("https://public-keys.auth.elb.ap-northeast-1.amazonaws.com/%s", token.Header["kid"])
		resp, err := http.Get(url)
		if err != nil {
			return nil, err
		}
		body, err := ioutil.ReadAll(resp.Body)
		return jwt.ParseECPublicKeyFromPEM(body)
	})
	if err != nil {
		return nil, err
	}
	if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
		return claims, nil
	} else {
		return nil, errors.New("token is invalid")
	}
}

type Response events.APIGatewayProxyResponse

func Handler(request events.APIGatewayProxyRequest) (Response, error) {
	var buf bytes.Buffer

	_, err := ParseToken(request.Headers["x-amzn-oidc-data"])
	if err != nil {
		return Response{StatusCode: http.StatusUnauthorizeds}, err
	}

	resp := Response{
		StatusCode:      http.StatusOK,
		Body:            "ok",
	}

	return resp, nil
}

参考

まだログイン認証で消耗してるの? ~ALBで簡単認証機構~ - Gunosy Tech Blog