[php]애플 아이디로 로그인 refresh token 구하기?

프로그래밍/서버2020. 4. 25. 16:35

애플 공식홈의 자료가.. 좋지 않습니다.

일단.. iOS 앱에서 로그인 후 받는 정보 중에서 JWT 형식의 identityToken을 검증했습니다. 그리고 그 다음 과정으로는 

 

https://appleid.apple.com/auth/token

위의 주소에 POST로.. 특정 정보들을 넘겨서 refresh token을 발급받거나 갱신을 해야한다고 하네요..

(참고: https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens)

 

 

 

넘겨야 하는 정보중에 client_secret을 먼저 만들어 보겠습니다. 애플은 왜 이렇게.. 복잡하게 만들었는지 모르겠지만.. 개발자가 여러 값들을 구해야 합니다.

 

1. client_secret 구하기

client_secret역시 JWT 형식으로.. 사인(sign)을 해야합니다.

 

{
    "alg": "ES256",
    "kid": "ABC123DEFG"
}
{
    "iss": "DEF123GHIJ",
    "iat": 1437179036,
    "exp": 1493298100,
    "aud": "https://appleid.apple.com",
    "sub": "com.mytest.app"
}

애플 문서에 의하면 decoded된 client_secret은 위의 모습이라고 하네요.

 

alg는 ES256을 사용하면 됩니다.

여기서 kid는 아래 화면에서 보이는 Key ID입니다.

여기서 iss는 애플 개발자 계정에서 보이는 team id인데 아래 화면에서 우측 상단에 있습니다.

iatexp는 코드상에서 현재 시간값을 기반으로 주면 되므로 간단합니다.

aud는 그냥 "https://appleid.apple.com" 값을 주면 되는 것 같습니다.

sub는 자신이 개발 중인 앱의 bundle id?를 입력하면 됩니다. com.mytest.app과 같은 형식입니다.

 

위 화면은 https://developer.apple.com/account/resources/authkeys 에서 생성한 Sign in with Apple 키를 누르고 들어가면 보이는 화면입니다.

 

 

https://stackoverflow.com/questions/56459075/how-to-verify-code-from-sign-in-with-apple나와있는 질문자의 코드를 보는게 애플 문서보다 더 도움이 되네요.

 

function encode($data) {
    $encoded = strtr(base64_encode($data), '+/', '-_');
    return rtrim($encoded, '=');
}

function generateJWT($kid, $iss, $sub, $key) {
    $header = [
        'alg' => 'ES256',
        'kid' => $kid
    ];
    $body = [
        'iss' => $iss,
        'iat' => time(),
        'exp' => time() + 3600,
        'aud' => 'https://appleid.apple.com',
        'sub' => $sub
    ];

    $privKey = openssl_pkey_get_private($key);
    if (!$privKey) return false;

    $payload = encode(json_encode($header)).'.'.encode(json_encode($body));
    $signature = '';
    $success = openssl_sign($payloads, $signature, $privKey, OPENSSL_ALGO_SHA256);
    if (!$success) return false;

    return $payload.'.'.encode($signature);
}

php로 작성된 client_secret을 생성하는 함수입니다.

 

//$jwt = generateJWT($kid, $iss, $sub, $key);
$jwt_client_secret = generateJWT($keyId, $teamId, $clientId, $privateKey);

저는 이름을 약간 바꿔서 사용했습니다.

jwt_client_secret을 만들어서 https://jwt.io/에 넣어서 디코딩된 값을 보니 잘 만들어진 것 같아요.

 

2. JWT 개선하기..

위에서 만든 JWT 함수에 문제가 있습니다.

 

openssl_sign()으로 생성한 JWT(client_secret)에 문제가 있다고 하네요.

 

function encode($data) {	
    $encoded = strtr(base64_encode($data), '+/', '-_');
    return rtrim($encoded, '=');
}

function generateJWT($kid, $iss, $sub) {
	$header = [
		'alg' => 'ES256',
		'kid' => $kid
	];
	$body = [
		'iss' => $iss,
		'iat' => time(),
		'exp' => time() + 3600,
		'aud' => 'https://appleid.apple.com',
		'sub' => $sub
	];

	$privKey = openssl_pkey_get_private(file_get_contents('AuthKey_XX12345KPW.pem'));
		
	if (!$privKey){
	   return false;
	}

	$payload = encode(json_encode($header)).'.'.encode(json_encode($body));

	$signature = '';
	$success = openssl_sign($payload, $signature, $privKey, OPENSSL_ALGO_SHA256);
	if (!$success) return false;

	$raw_signature = fromDER($signature, 64);

	return $payload.'.'.encode($raw_signature);
}

 /**
 * @param string $der
 * @param int    $partLength
 *
 * @return string
 */
function fromDER(string $der, int $partLength)
{
	$hex = unpack('H*', $der)[1];
	if ('30' !== mb_substr($hex, 0, 2, '8bit')) { // SEQUENCE
		throw new \RuntimeException();
	}
	if ('81' === mb_substr($hex, 2, 2, '8bit')) { // LENGTH > 128
		$hex = mb_substr($hex, 6, null, '8bit');
	} else {
		$hex = mb_substr($hex, 4, null, '8bit');
	}
	if ('02' !== mb_substr($hex, 0, 2, '8bit')) { // INTEGER
		throw new \RuntimeException();
	}
	$Rl = hexdec(mb_substr($hex, 2, 2, '8bit'));
	$R = retrievePositiveInteger(mb_substr($hex, 4, $Rl * 2, '8bit'));
	$R = str_pad($R, $partLength, '0', STR_PAD_LEFT);
	$hex = mb_substr($hex, 4 + $Rl * 2, null, '8bit');
	if ('02' !== mb_substr($hex, 0, 2, '8bit')) { // INTEGER
		throw new \RuntimeException();
	}
	$Sl = hexdec(mb_substr($hex, 2, 2, '8bit'));
	$S = retrievePositiveInteger(mb_substr($hex, 4, $Sl * 2, '8bit'));
	$S = str_pad($S, $partLength, '0', STR_PAD_LEFT);
	return pack('H*', $R.$S);
}
/**
 * @param string $data
 *
 * @return string
 */
function preparePositiveInteger(string $data)
{
	if (mb_substr($data, 0, 2, '8bit') > '7f') {
		return '00'.$data;
	}
	while ('00' === mb_substr($data, 0, 2, '8bit') && mb_substr($data, 2, 2, '8bit') <= '7f') {
		$data = mb_substr($data, 2, null, '8bit');
	}
	return $data;
}
/**
 * @param string $data
 *
 * @return string
 */
function retrievePositiveInteger(string $data)
{
	while ('00' === mb_substr($data, 0, 2, '8bit') && mb_substr($data, 2, 2, '8bit') > '7f') {
		$data = mb_substr($data, 2, null, '8bit');
	}
	return $data;
}

위의 개선된 코드를 사용해야 합니다. 

 

추가로 애플 개발자 홈페이지에서 받았던 .p8 형식의 파일을 .pem으로 변경해야 합니다.

openssl pkcs8 -in AuthKey_KEY_ID.p8 -nocrypt -out AuthKey_KEY_ID.pem

서버에 .p8파일을 올리고.. 뿌띠와 같은 환경에서 위의 명령어를 입력해주면 됩니다. 파일 이름은 각자에 맞게 사용하면 됩니다. 

(자세한 설명은 https://stackoverflow.com/a/59842760/7225691 답변(영어)을 보시길 바람)

 

https://stackoverflow.com/q/59737488/7225691

위의 질문자 코드를 참고하여.. redirected_uri 없이 한번 테스트를 해봤습니다.

 

{"error":"invalid_client"}"

 

가 발생하네요. 디벨로퍼 사이트에서 service id?를 생성해야 한다는 말도 있고 혼동됩니다..

몇 시간동안 삽질 결과.. 답변자의 코드에 예상치 못한 치명적인 문제가 있네요..

$data = [
            'client_id' => $clientId,
            'client_secret' => $jwt_client_secret,
            'code' => $authorizationCode,
            'grant_type' => 'authorization_code'
			//,'redirect_uri' => 'https://example-app.com/redirect'
        ];

$ch = curl_init();

curl_setopt($ch, CURLOPT_URL, 'https://appleid.apple.com/auth/token');
curl_setopt($ch, CURLOPT_POST, 1);
//curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$serverOutput = curl_exec($ch);

위의 코드가 애플 서버로 데이터를 보내는 코드인데 여기서 문제되던 코드를 주석으로 막고 올바른 코드를 넣었습니다.

제 서버가 php 7.x라서 그런 것인지는 모르겠으나.. 인자로 $data를 바로주면 안되고 http_build_query()함수의 리턴값을 줘야하네요...

 

이렇게 사소한 문제를 해결한.. 드디어 원하는 응답을 받네요.

 

"access_token": "xxxxxxxxxxxxxxx",

"token_type""Bearer",

"expires_in"3600,

"refresh_token""yyyyyyyyyyyy",

"id_token": "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"

 

위와 같은 JSON 형식으로 응답이 옵니다.

 

access_token의 값과.. 이 토큰의 타입(token_type), 만료 시간(expires_in)이 있네요. 만료시간은 초 단위 같습니다. 3600초니까.. 생성 후 1시간 뒤에 만료되는 것 같습니다.

refresh_token도 알려주네요.

id_token은 보냈던 client_secret을 다시 그대로 넘겨주네요.

 

이에 대한 정보는 https://developer.apple.com/documentation/sign_in_with_apple/tokenresponse 에 있습니다.

신기한 점은.. 아직 access_token이.. 쓰이지 않는다고 하네요....

 

3. 생각해보기

iOS앱에서 애플 로그인을 수행하면 identityToken과 authorizationCode 를 얻습니다.

 

identityToken

애플 개발자 홈에는 A JSON Web Token (JWT) that securely communicates information about the user to your app. 라고 되어있는데.. 정말 쓸모없는 설명같습니다. ㅡㅡ;

JWT형식이라서 바로 알아볼 수는 없지만.. base64 decode를 하면 금방 그 내용을 볼 수 있습니다. https://jwt.io/ 에서도 편하게 확인 가능합니다.

 

바로 위의 사진이 https://jwt.io/ 에서 간단하게 decode한 결과입니다. 생성 후 10분간 유효합니다.

sub가 사용자를 구별할 수 있는 id인 듯 합니다.

 

authorizationCode 

A short-lived token used by your app for proof of authorization when interacting with the app’s server counterpart.

authorizationCode는 1번만 사용될 수 있으며 5분간 유효합니다.

애플 서버 https://appleid.apple.com/auth/token 에서 인증?받을 때 쓰입니다.

http://p.agnihotry.com/post/validating_sign_in_with_apple_authorization_code/ 에 설명이 좀 잘 되어있습니다.

 

refresh_token과 access_token

앞서 말했듯이.. access_token은 현재 쓰이지 않습니다.. 값을 받아도 이 값은 검증해줄 애플 서버 end point가 없습니다.

그래서 대신 expiry하지 않는 refresh_token을.. 사용자 검증용으로 쓰는 것 같네요..

https://stackoverflow.com/questions/58479651/how-to-verify-the-access-token-of-sign-in-with-apple 에서 댓글을 보

 

Apple says they may throttle your server if you attempt to validate the refresh token more than once per day. – Chris Prince Jan 12 at 3:12

위의 댓글이 있습니다.  서버에 무리가 가니? 하루에 1번 넘게 refresh_token 을 검증하지 말라고 하네요.

애플 공식 문서에서 관련 내용을 찾아보니 You may verify the refresh token up to once a day to confirm that the user’s Apple ID on that device is still in good standing with Apple’s servers. Apple’s servers may throttle your call if you attempt to verify a user’s Apple ID more than once a day. 라고 되어있습니다.  하루에 1번 넘게.. 검증을 하면 잘 안된다는 뜻 같습니다.

 

In terms of "Why should we verify the refresh token instead of [identity] token to confirm the user's Apple ID is still in good standing with Apple's server?". The identity token is short lived. Its expiry time is a few minutes. The refresh token (AFAIK) doesn't expire-- at least until the user revokes the apps credentials. – Chris Prince Jan 12 at 3:16

라는 댓글도 있습니다.

 

identityToken은 금방 만료되므로 만료되지 않는 refresh_token을 검증에 사용된다는 내용입니다.

 

애플 로그인은 참.. 이상하네요.

access token이.. 쓰이지도 않고, 스스로 갱신되지도 않으며.. 어쩔 수 없이 refresh_token을 개발자 코드에 의해 억지로 갱신하며 써야합니다...

 

애플은 무슨 생각일까요....?

 

4.  token 갱신하기

먼저 위 사진은.. Postman 프로그램을 통해 https://appleid.apple.com/auth/token에 authorizationCode를 인증받고.. refreshToken을 최초 받을 때 사용한 값입니다.

 

accessToken을 갱신할 때는 아래와 같이 하면 됩니다.

grant_type의 값이 달라지고, code가 빠지고 refresh_token 이 추가됐습니다.

리턴값도 좀 다르죠?

 

그런데 acces_token을 갱신할 때 사용되는 (우리가 만든) client_secret은 1시간 후에 만료됩니다. 

 

만료된 client_secret을 이용해서 access_token 갱신을 시도하면 위의 결과를 얻네요.

그런데 client_secret을은 다시 만들기 쉬우므로 access_token 갱신을 시도하기 전에 새로 만드는게 좋을 것 같습니다.

 

기타

requeredirect_uri니,, 애플 개발자 홈페이지에서 Service ID니 하는 것은

웹에서 로그인 처리를 할 때 필요한 것 같습니다.

 

웹에서 로그인 할때는.. secret_client를 만들 때 필요한 sub와 https://appleid.apple.com/auth/token에 보낼 POST 값 중 client_id가 Service ID와 동일한 값이면 되는 것으로 추정합니다.

 

 

 

참고

https://developer.okta.com/blog/2019/06/04/what-the-heck-is-sign-in-with-apple

 

애플 문서 애플 로그인 시 사용자 검증하기?:

https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/authenticating_users_with_sign_in_with_apple

애플 문서 사용자 검증하기?:

https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/verifying_a_user

작성자

Posted by 드리머즈

관련 글

댓글 영역