🍎

Sign in with Appleのclient secretの生成と運用

2022/12/09に公開

これは ZOZO Advent Calendar 2022 カレンダーVol.6の9日目の記事です。

概要

自前のアプリケーションで外部アプリケーションの認証情報を使いたくなる場合があります、その時に使うのがOpenID Connectと呼ばれる認証プロトコルです。
今回はAppleの認証情報を使う場合に必要なclient secretの生成方法を紹介します。

OpenID Connectとは

OAuth徹底入門」では以下の説明がされています。

OpenID Connectは2014年に2月にOpenID Foundationによって公開されたオープン・スタンダードであり、そこにはOAuth2.0を使ってユーザー認証を行うための相互運用可能な方法が定義されています。

認可プロトコルであるOAuth2.0を認証プロトコルとして用いるにはユーザー情報の伝達が欠けています。それを補い認証プロトコルとして用いることができる仕様がOpenID Connectです。
例えばGoogleYahooAppleなどが提供しているOpenID Connectに準拠したAPIを用いることで、それらのサービスの認証情報を自前のアプリケーションで使うことができます。
つまり、Googleアカウントなどを用いた自前のアプリケーションへのログインを実装することが可能になります。

OpenID Connectで用いるclient secret

OpenID Connectのフローの中にIDトークンを取得する過程があります。そこで用いられるのがclient secretです。OpenID Connectのフローについての詳細はYahoo! ID連携のドキュメントOAuth 2.0 全フローの図解と動画が詳しいです。
多くのサービスではOpenID Connect準拠のAPIを用いるために登録するコンソールからclient secretを固定値として受け取ることができます。
例えばYahoo! ID連携の場合はこちらのコンソールからclient idとclient secretを取得することができます。

Sign in with Appleとclient secret

Apple IDのユーザー情報を利用するためのOpenID Connectの実装[1]がSign in with Appleです。
Sign in with Appleの場合はclient secretを固定値として受け取ることが出来ず、Apple Developerから取得できる秘密鍵を用いて生成する必要があります。
詳しくはこちらの「Create the client secret」に記載があります。

生成と運用

上記の資料を元に、実際にclient secretを生成してみます。
ドキュメントにも記載がありますが、以下のJWT形式のヘッダーとペイロードをApple Developer Programからダウンロードした秘密鍵で署名して作ったJWTトークンがclient secretになります。

{
    "alg": "ES256",
    "kid": [KEY_ID]
}
{
    "iss": [TEAM_ID],
    "iat": 1437179036,
    "exp": 1493298100,
    "aud": "https://appleid.apple.com",
    "sub": [CLIENT_ID]
}

KEY_IDにはApple Developer Programからダウンロードした秘密鍵名、TEAM_IDにはSign in with Appleを使うアプリケーションに紐づくTeam IDが入ります。CLIENT_IDにはアプリケーションのApp IDが入りますが、WebアプリケーションからSign in with Appleを使う場合はService IDを入れる必要があります。
expは生成したJWTトークンの有効期限ですが、JWTトークン利用時点のUNIX時刻から15777000(6ヶ月程度)未満である必要があります。

これらを参考に、Sign in with Appleのclient secretを生成するサンプルコードを以下のリポジトリに公開しました。
https://github.com/ytakaya/sign-in-with-apple

以上でclient secretを生成してAppleが発行するIDトークンを取得できるようになりますが、client secretに有効期限があることから運用上の課題は残ります。
client secretを生成して更新するタイミングとしては以下の方法が考えられます。

  1. 定期的にローカルでclient secretを生成してサーバーにアップロードする
  2. ログインのリクエストが来るタイミングで毎回client secretを生成する
  3. ある程度の時間はサーバー上にclient secretをキャッシュしておき、適度なタイミングで更新する

1つ目の方法は運用コストが高く、2つ目の方法は毎リクエストでJWTの署名処理などが入るためパフォーマンスに影響が出そうです。
なので3つ目の適度なタイミングでキャッシュしたclient secretを更新する方法が良いと考えました。
具体的にはデータ競合などに注意して、以下のような方法でキャッシュの有効期限確認と更新をしてあげると良さそうです。

package main

import (
	"crypto/x509"
	"encoding/json"
	"encoding/pem"
	"errors"
	"fmt"
	"io/ioutil"
	"path"
	"sync"
	"time"

	"github.com/square/go-jose"
)

var errExpiredJwtCache = errors.New("jwt cache has been expired")

// 適度な有効期限
const clientSecretLifetime = time.Hour * 24

type ClientSecretBuilder struct {
	payload secretPayload
	signer  jose.Signer

	mu       sync.RWMutex
	jwtCache string
}

type secretPayload struct {
	Iss string `json:"iss"`
	Iat int64  `json:"iat"`
	Exp int64  `json:"exp"`
	Aud string `json:"aud"`
	Sub string `json:"sub"`
}

func NewClientSecretBuilder(issuer, teamID, clientID, keyID string, at time.Time) (*ClientSecretBuilder, error) {
	signer, err := newSigner(keyID)
	if err != nil {
		return nil, err
	}
	payload := secretPayload{
		Iss: teamID,
		Aud: issuer,
		Sub: clientID,
		Iat: 0,
		Exp: 0,
	}
	builder := &ClientSecretBuilder{
		payload: payload,
		signer:  signer,
	}
	_, err = builder.Build(at)
	return builder, err
}

func newSigner(kid string) (jose.Signer, error) {
	bytes, err := ioutil.ReadFile(path.Join("dir_of_secret_key", fmt.Sprintf("AuthKey_%s.p8", kid)))
	if err != nil {
		return nil, err
	}

	block, _ := pem.Decode(bytes)
	if block == nil {
		return nil, errors.New("invalid private key data")
	}

	key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
	if err != nil {
		return nil, err
	}

	header := &jose.SignerOptions{}
	return jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: key}, header.WithHeader("alg", jose.ES256).WithHeader("kid", kid))
}

func (c *ClientSecretBuilder) isExpired(at time.Time) bool {
	return at.Unix() > c.payload.Exp
}

func (c *ClientSecretBuilder) getJwt(at time.Time) (string, error) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	if c.isExpired(at) {
		return "", errExpiredJwtCache
	}
	return c.jwtCache, nil
}

func (c *ClientSecretBuilder) setJwt(jwt string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.jwtCache = jwt
}

func (c *ClientSecretBuilder) Build(at time.Time) (string, error) {
	if cached, err := c.getJwt(at); err != nil {
		return cached, nil
	}

	c.payload.Iat = at.Unix()
	c.payload.Exp = at.Add(clientSecretLifetime).Unix()
	payload, err := json.Marshal(c.payload)
	if err != nil {
		return "", err
	}

	signed, err := c.signer.Sign(payload)
	if err != nil {
		return "", err
	}

	jwt, err := signed.CompactSerialize()
	if err != nil {
		return "", err
	}
	c.setJwt(jwt)
	return jwt, err
}

まとめ

少し特殊なSign in with Appleのclient secretの生成と運用方法を紹介しました。

脚注
  1. ドキュメントにOpenID Connectに準拠してるとは明示されていない。 ↩︎

Discussion