서비스 계정으로 인증(JWT)

NAVER WORKS API 2.0를 이용하려면 OAuth를 기반으로 하는 사용자의 승인이 필요하다. 하지만 이 방법으로는 조직 연동이나 안부 확인처럼 구성원이 작성한 데이터에 접근하는 클라이언트 앱을 작성할 때 현실적으로 어려움이 따른다. 이런 경우 서비스 계정(service account)라는 가상 계정을 이용할 수 있다.

참고

  • 서비스 계정을 이용하면 클라이언트 앱에서 구성원이 작성한 데이터에 해당 구성원의 승인 없이 접근할 수 있다.
  • 관리자는 클라이언트 앱에서 서비스 계정을 생성한 후, 구성원 또는 관리자의 사용 권한을 위임받아 대신 접근하는 형태로 사용할 수 있다.

발급한 서비스 계정은 아래와 같은 특징을 가지고 있다.

  • 성: [SERVICE]
  • 이름: 앱 이름
  • ID: "serviceaccount"로 끝남

서비스 계정으로 API를 사용할 때, JWT(JSON Web Token)를 사용하여 구성원 계정과 같은 수준으로 인증할 수 있다. 서비스 계정은 발급받은 클라이언트 앱에서만 사용할 수 있으며, 각 API 요청 헤더에 Bearer 토큰으로 포함해야 한다.

  • 서비스 계정은 일종의 가상 계정으로 과금되지 않는다.
  • 서비스 계정은 API 사용을 제외하고 구성원 목록, 조직도, 검색 결과 등 서비스 화면에서 사용될 수 없다.
  • 하나의 클라이언트 앱에서 하나의 서비스 계정을 발급받을 수 있다.
  • 서비스 계정 발급과 동시에 권한 위임이 이루어진다.
  • 서비스 계정으로 사용할 수 없는 API가 있다.
  • Path 파라미터의 userId에 me키워드를 사용할 수 없다.

Developer Console에서 JWT로 인증하는 데 필요한 서비스 계정과 개인 키(private key)를 제공한다. 클라이언트 앱의 기본 정보를 입력한 후 서비스 계정과 개인 키를 발급받을 수 있다. 서비스 계정을 발급하면 관리자에게 알림이 전송된다.

서비스 계정을 사용하여 요청을 승인하는 중 오류가 발생하면 JWT 오류 코드를 참조한다.

서비스 계정으로 사용할 수 없는 API {#prohibited-api}

아래는 매우 민감한 사용자 정보를 다루므로 서비스 계정으로 사용할 수 없다.

ScopesAPIs
group.note
group.note.read
/groups/{groupId}/note*
mail
mail.read
/users/{userId}/mail*
단, 아래 API는 예외적으로 사용할 수 있다.
• /users/{userId}/mail/migration*
• /users/{userId}/mail/settings/forwarding
file
file.read
/users/{userId}/drive*
/sharedrives*
/groups/{groupId}/folder*
group.folder
group.folder.read
/groups/{groupId}/folder*
task
task.read
/tasks*
/users/{userId}/tasks*
/users/{userId}/task-categories*
form
form.read
/forms*

Developer Console에서 권한 위임 앱으로는 API를 호출할 수 있다.

인증 흐름 {#jwt-flow}

서비스 계정을 사용한 인증 과정은 다음과 같다.

auth_service_account

서비스 계정을 사용한 토큰 발급 과정

1. 준비 {#preparation}

먼저 Developer Console에 클라이언트 OAuth 앱을 등록하고 아래와 같은 정보를 발급한다.

  • Client ID
  • Client Secret
  • Service Account
  • Private Key

토큰을 발급받으려면 아래와 같은 절차를 실행해야 한다.

  • JWT 생성(RFC-7519)
  • JWT 전자 서명(RFC-7515)
  • NAVER WORKS 인증 서버로 토큰 요청(RFC-7523)

2. JWT 생성 {#generate-jwt}

JWT는 다음과 같은 형식을 만족해야 한다.

{header BASE64 URL 인코딩}.{JSON Claim set BASE64 URL 인코딩}.{signature BASE64 URL 인코딩}
eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiI0NmM0ZjI4MWY4MTE0OGM5Yjg0NmM1OTI2MmFlNTg4OCIsImlhdCI6MTQ5MjUwNDY3MiwiZXhwIjoxNDkyNTA2NDcyfQ.aICZ8qvYFKSJT6VdrmEcs6siCHaCgFkqpVs5VALKhf98sZjguppp-bOy9MpNlNepfSF0IyrdG3JavHLUYBz1NEVVZJwEm39f7gODmnt-_kGfDo1YtOqnclP1gM8oiObF2AH2Eneh3XuyeVeZbKAZmp6I_ZOf8AGayVVui61CsDPbUIPZSKUnbW1-vlXboTlojxJhvHznpYSNanHSrg5Nht2VO5sOeclEgPqg3J8Y6XOT60u8Morv5wHUy8a0QyO0yWCT5OJdXeVj94qfDAM15a1Puw9PfQOPm7QhOarvCJ8cOSqo9PHluq9-KZ1WXmfxSo-_itTb8y2YRT3kd21maQ

헤더에 RSA SHA-256 알고리즘을 명시해야 한다.

{"alg":"RS256","typ":"JWT"}

이 값을 BASE64 URL 인코딩하면 다음과 같다.

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9

JSON 클레임 세트의 정보는 다음과 같다.

파라미터필수 여부설명
issYDeveloper Console에서 발급받은 앱의 client ID
subY서비스 계정명(메일 주소 형식)
iatYJWT 생성 시간.
Unix time으로 나타내며, 단위는 초(sec)이다.
자세한 설정 방법은 JWT iat/exp 설정 가이드를 참고한다.
expYJWT 만료 시간.
최장 60분 후까지 지정 가능
Unix time으로 나타내며, 단위는 초(sec)이다.
자세한 설정 방법은 JWT iat/exp 설정 가이드를 참고한다.
delegated_user권한을 위임받을 구성원 계정 (email)
지정 가능한 최대 수: 1 (고정값)

예를 들어, 다음과 같은 상황을 가정해 보자.

  • Client ID: abcd
  • 서비스 계정: 46c4f281f81148c9b846c59262ae5888@example.com
  • JWT 생성 시간: 2021-10-20 15:29:18
  • JWT 만료 시간: 2021-10-20 16:29:18(1시간 후 만료)

JSON 형식으로 나타내면 다음과 같다.

{  "iss":"abcd",  "sub":"46c4f281f81148c9b846c59262ae5888@example.com",  "iat":1634711358,  "exp":1634714958}

이 값을 BASE64 인코딩하면 다음과 같다.

eyJpc3MiOiJhYmNkIiwic3ViIjoiNDZjNGYyODFmODExNDhjOWI4NDZjNTkyNjJhZTU4ODhAZXhhbXBsZS5jb20iLCJpYXQiOjE2MzQ3MTEzNTgsImV4cCI6MTYzNDcxNDk1OH0

헤더와 클레임 세트를 점(.)으로 조합한 **{header BASE64 URL 인코딩}.{JSON Claim set BASE64 URL 인코딩}**은 다음과 같다.

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhYmNkIiwic3ViIjoiNDZjNGYyODFmODExNDhjOWI4NDZjNTkyNjJhZTU4ODhAZXhhbXBsZS5jb20iLCJpYXQiOjE2MzQ3MTEzNTgsImV4cCI6MTYzNDcxNDk1OH0

3. JWT 전자 서명(RFC-7515) {#jwt-signature}

전자 서명은 JWS(JSON Web Signature RFC-7515) 규약을 따른다.
앞서 생성했던 JWT 헤더와 본문의 바이트 배열(byte array)을 Developer Console에서 다운로드한 비밀 키로 RSA SHA-256 알고리즘(header에서 정의한 RS256)을 사용하여 암호화하고 BASE64 URL 인코딩한다.

위에서 만들어진 {header BASE64 URL 인코딩}.{JSON Claim set BASE64 URL 인코딩}을 전자 서명하여 BASE64 URL 인코딩한 {signature BASE64 URL 인코딩}은 다음과 같다.

RqOaErJWZc_ZGijL5r0a892NnQL_zbkgchThW3j4pzG_qMqtOgex2odEs8JFsPfQ2c8_2BkaUMckNIN3C27t2RsbppJYl3nQr9w2Jb6x9LJR1Ym3pJVlpRvarracRwa00OgVc0mZ5dkn3B4I55GpKjZ3oOLfW7Xw0OAj8fEYCmWJmma3xQQrScJAUqN-jTZ7T3C6-ieVo3IhTRopzS5cru3ilQWekQ6-fRTPr8W4EV9B0u8wXhCxT90mlAYtebPvyovpPTNhi8Oq8rO_gVnpSMNkDtZ6p6OpC7_XG7ZcjUo7KRCxyPLe2-TmeWtV0jL5vqsjnlAznKtw5mPGOwpjVQ

최종적으로 완성된 JWT {header BASE64 URL 인코딩}.{JSON Claim set BASE64 URL 인코딩}.{signature BASE64 URL 인코딩}은 다음과 같다.

eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJhYmNkIiwic3ViIjoiNDZjNGYyODFmODExNDhjOWI4NDZjNTkyNjJhZTU4ODhAZXhhbXBsZS5jb20iLCJpYXQiOjE2MzQ3MTEzNTgsImV4cCI6MTYzNDcxNDk1OH0.RqOaErJWZc_ZGijL5r0a892NnQL_zbkgchThW3j4pzG_qMqtOgex2odEs8JFsPfQ2c8_2BkaUMckNIN3C27t2RsbppJYl3nQr9w2Jb6x9LJR1Ym3pJVlpRvarracRwa00OgVc0mZ5dkn3B4I55GpKjZ3oOLfW7Xw0OAj8fEYCmWJmma3xQQrScJAUqN-jTZ7T3C6-ieVo3IhTRopzS5cru3ilQWekQ6-fRTPr8W4EV9B0u8wXhCxT90mlAYtebPvyovpPTNhi8Oq8rO_gVnpSMNkDtZ6p6OpC7_XG7ZcjUo7KRCxyPLe2-TmeWtV0jL5vqsjnlAznKtw5mPGOwpjVQ

이렇게 만들어진 JWT를 아래 토큰 요청에서 assertion 파라미터로 전달한다.

참고

  • JWT를 만들 때 사용할 수 있는 라이브러리가 있으므로 직접 만들지 말고 라이브러리를 사용하는 것을 권장한다.
  • JAVA의 경우 다음과 같은 라이브러리를 사용할 수 있다.
    https://github.com/jwtk/jjwt
    https://github.com/auth0/java-jwt
    https://bitbucket.org/b_c/jose4j/wiki/Home
    https://bitbucket.org/connect2id/nimbus-jose-jwt/wiki/Home

4. 인증 서버로 토큰 요청 {#issue-access-token}

Request URL {#issue-access-token-request-url}

https://auth.worksmobile.com/oauth2/v2.0/token

HTTP Method {#issue-access-token-request-method}

POST

Request Header {#issue-access-token-request-header}

content-Type: application/x-www-form-urlencoded; charset=UTF-8

Request Body {#issue-access-token-request-body}

파라미터타입필수 여부설명
assertionStringY생성한 JWT값
grant_typeStringY"urn:ietf:params:oauth:grant-type:jwt-bearer"으로 고정.
client_idStringYDeveloper Console에서 발급받은 앱의 client ID
client_secretStringYDeveloper Console에서 발급받은 앱의 client secret
scopeStringY사용할 API의 요청 범위 정보. 여러 개의 요청 범위를 사용할 때는 공백( )으로 연결한다

Request Example {#issue-access-token-request-example}

curl --location --request POST 'https://auth.worksmobile.com/oauth2/v2.0/token' \--header 'Content-Type: application/x-www-form-urlencoded' \--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer' \--data-urlencode 'client_id=U8KLIXz8W62ADwLteJxp' \--data-urlencode 'client_secret=oRm3M_nBg6' \--data-urlencode 'assertion=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJhYmNkIiwic3ViIjoiNDZjNGYyODFmODExNDhjOWI4NDZjNTkyNjJhZTU4ODhAZXhhbXBsZS5jb20iLCJpYXQiOjE2MzQ3MTEzNTgsImV4cCI6MTYzNDcxNDk1OH0.RqOaErJWZc_ZGijL5r0a892NnQL_zbkgchThW3j4pzG_qMqtOgex2odEs8JFsPfQ2c8_2BkaUMckNIN3C27t2RsbppJYl3nQr9w2Jb6x9LJR1Ym3pJVlpRvarracRwa00OgVc0mZ5dkn3B4I55GpKjZ3oOLfW7Xw0OAj8fEYCmWJmma3xQQrScJAUqN-jTZ7T3C6-ieVo3IhTRopzS5cru3ilQWekQ6-fRTPr8W4EV9B0u8wXhCxT90mlAYtebPvyovpPTNhi8Oq8rO_gVnpSMNkDtZ6p6OpC7_XG7ZcjUo7KRCxyPLe2-TmeWtV0jL5vqsjnlAznKtw5mPGOwpjVQ' \--data-urlencode 'scope=bot user.read'

Response {#issue-accvess-token-response}

속성타입설명
access_tokenStringAccess Token
refresh_tokenStringRefresh Token
token_typeStringBearer
expires_inStringAccess Token의 유효 기간.
유효 기간은 Developer Console의 Token 설정 > Access Token 유효 기간의 설정에 따라 다르다.
• 1 hour(3600초)
• 24 hours(86400초)
설정된 유효 기간이 지나면 자동으로 만료된다.
scopeString토큰이 사용할 수 있는 API의 요청 범위.

Response Example {#issue-access-token-response-example}

{    "access_token":"kr1AAABFNKyxc7xsVRQVKrTNFchiiMkQrfJMDM6whobYxfbO4fsF23mvuxRvSuMY57DG4uPI/NI4eNMSt8sroqpqFhe3HemLI3OvCar5FFfOQdqUBgqFA/MaHZVXHqsNJgoX7KaGwDTum+zhEyfwjGSrrJZfSoRpTHHrwny4F4UDEA1Lep3dVUUUKAIQHcq0TwCjiWkMnJAXMEFFfbdVzH3FCv+kpb2OH1NbYzL376fXLh3vMUlyRBXPTf3Lv0bK5NsvjR3BNMR3GSvVzjM59lR5ctBK8PvtTdmaHbVGXzJBHv+S3mp1UuD0szSuxCsWUrdCS7/PiWbQwM4++k+WM/bta5EB9v9s9YQGlyklE3fqhnYLGx/9jWanFgrvptCambOW8lv5A==",    "refresh_token":"kr1AAAAVq8kTeVPKkD11iLMP1mTqzYOd2T/r2x6QoBM2P3D8X6FfDi9wG5Hepsmh/LVpo3n3d/jcP/rnhtEw1VOpU4MJnxHVzu1x5VhKRmG/o63HERu2bnMtFHQVsjhljcf5fpm+Q==",    "scope": "bot",    "token_type": "Bearer",    "expires_in": "86400"}

Access Token 갱신 {#refresh-access-token}

Access Token이 만료될 경우, Refresh Token으로 재발급할 수 있다. 자세한 방법은 구성원 계정으로 인증(OAuth)을 참조한다.

주의

  • access_token을 발급받을 때 발급받은 시점과 토큰 정보를 같이 저장하고, API를 사용할 때 이 토큰의 유효성을 확인(발급 후 설정한 유효 기간이 경과하였는지 확인)하는 것을 권장한다.

토큰 만료 {#revoke-token}

Access Token 또는 Refresh Token을 만료시킨다. 자세한 방법은 구성원 계정으로 인증(OAuth)을 참조한다.

토큰의 사용 {#how-to-use-access-token}

발급받은 토큰은 API를 요청할 때 다음과 같이 사용한다.

주의

  • 요청 헤더에 Authorization 값으로 'Bearer'를 반드시 명시해야 한다. 'Bearer'와 'Token' 사이에 공백(space)을 빠트리지 않도록 주의한다.
PostMethod method = new PostMethod(url);method.setRequestHeader("Authorization", "Bearer AAAA5IdUiCj5emZowcf49VRu7qbb548g6aGE");

JWT iat/exp 설정 가이드 {#jwt-iat-exp-guide}

서비스 계정(Service Account) 토큰 발급용 JWT를 생성할 때, iat(Issued At)와 exp(Expiration Time) 클레임을 올바르게 설정하는 방법을 안내한다.

검증 규칙

서버는 다음 조건을 모두 검증한다.

조건설명
exp - iat <= 3600iat와 exp의 차이가 3600초(1시간) 이하
now < exp서버 검증 시점이 exp 이전이어야 함(만료된 JWT 거부)
iat <= now서버 검증 시점이 iat 이후여야 함

⚠️ 흔한 실수 케이스

iat/exp를 고정값으로 사용

// 잘못된 예 — 고정값은 시간이 지나면 만료됨iat: 1696921200  // 2023-10-10 10:00:00 (고정)exp: 1696924800  // 2023-10-10 11:00:00 (고정)

해결: 토큰 발급을 요청할 때마다 현재 시각 기준으로 iat/exp를 매번 새로 생성해야 한다.

서버 시간대와 다른 기준으로 시간을 설정

서버는 서버가 실행되는 환경의 시간대를 기준으로 JWT를 검증한다(KR 서버 → KST, JP 서버 → JST). 클라이언트가 시간대를 잘못 적용하여 iat/exp를 생성하면, 서버 시간과 최대 9시간의 차이가 발생하여 JWT가 거부될 수 있다.

// 잘못된 예 — JP 서버 환경, 현재 시각이 JST 13:00:00인 상황//// 클라이언트가 "현재 13시"를 UTC 기준 epoch으로 만든 경우://   UTC 13:00 = JST 22:00이므로//   서버(JST 13:00) 입장에서는 iat가 9시간 후인 "미래 시각"이 됨//// 반대로, UTC 04:00 (= JST 13:00) 기준 epoch을 사용했어야 정상

해결: Unix Timestamp(epoch seconds)는 시간대와 무관하게 전 세계적으로 동일한 값이다. 문제는 "현재 시각"을 epoch으로 변환할 때 발생한다.

가장 안전한 방법은 Instant.now()(Java), Math.floor(Date.now() / 1000)(JavaScript), time.time()(Python) 등 시간대 변환 없이 직접 epoch seconds를 반환하는 API를 사용하는 것이다. 이 API들은 로컬 시간대와 관계없이 항상 올바른 epoch 값을 반환한다.

⚠️ LocalDateTime(Java)이나 new Date().toLocaleString()(JavaScript) 등 로컬 시간 기반 API로 시각을 구한 뒤 수동으로 epoch 변환하는 방식은 타임존 오류의 원인이 되므로 사용할 수 없다.

밀리초 단위 사용

// 잘못된 예 — 밀리초 단위 사용iat: 1696921200000  // 13자리(밀리초)exp: 1696924800000

해결: RFC 7519 NumericDate는 **초 단위(epoch seconds, 10자리)**다. 밀리초(13자리)가 아닌 초 단위를 사용한다.


언어별 구현 예시

다음의 구현 예시는 사용자의 개발 환경이나 언어 버전에 따라 호환되지 않을 수 있으므로 참고 용도로만 사용한다.

Java

import io.jsonwebtoken.Jwts;import io.jsonwebtoken.SignatureAlgorithm;import java.security.PrivateKey;import java.time.Instant;import java.util.Date;public class JwtGenerator {    public static String createJwt(String clientId, String serviceAccountId, PrivateKey privateKey) {        Instant now = Instant.now();        long iat = now.getEpochSecond();        long exp = iat + 3600;        return Jwts.builder()                .setHeaderParam("alg", "RS256")                .setHeaderParam("typ", "JWT")                .setIssuer(clientId)                          // iss: Client ID                .setSubject(serviceAccountId)                 // sub: Service Account ID                .setIssuedAt(Date.from(Instant.ofEpochSecond(iat)))   // iat                .setExpiration(Date.from(Instant.ofEpochSecond(exp))) // exp                .signWith(privateKey, SignatureAlgorithm.RS256)                .compact();    }}

JavaScript(Node.js)

const jwt = require('jsonwebtoken');const fs = require('fs');function createJwt(clientId, serviceAccountId, privateKeyPath) {    const now = Math.floor(Date.now() / 1000);    const iat = now;    const exp = iat + 3600;       const privateKey = fs.readFileSync(privateKeyPath, 'utf8');    const payload = {        iss: clientId,              // Client ID        sub: serviceAccountId,      // Service Account ID        iat: iat,          exp: exp       };    return jwt.sign(payload, privateKey, { algorithm: 'RS256' });}

Python

import timeimport jwt  # PyJWT 라이브러리def create_jwt(client_id: str, service_account_id: str, private_key_path: str) -> str:    now = int(time.time())   # 초 단위(10자리) ← 밀리초 아님!    iat = now    exp = iat + 3600          # 1시간 후    with open(private_key_path, 'r') as f:        private_key = f.read()    payload = {        "iss": client_id,              # Client ID        "sub": service_account_id,     # Service Account ID        "iat": iat,        "exp": exp    }    return jwt.encode(payload, private_key, algorithm="RS256")

PHP

<?phpuse Firebase\JWT\JWT;function createJwt(string $clientId, string $serviceAccountId, string $privateKeyPath): string {    $iat = time();    $exp = $iat + 3600;    $privateKey = file_get_contents($privateKeyPath);    $payload = [        'iss' => $clientId,              // Client ID        'sub' => $serviceAccountId,      // Service Account ID        'iat' => $iat,        'exp' => $exp    ];    return JWT::encode($payload, $privateKey, 'RS256');}

C#(.NET)

using System;using System.IdentityModel.Tokens.Jwt;using System.Security.Claims;using System.Security.Cryptography;using Microsoft.IdentityModel.Tokens;public static string CreateJwt(string clientId, string serviceAccountId, RSA privateKey){    var now = DateTimeOffset.UtcNow;    long iat = now.ToUnixTimeSeconds();    long exp = iat + 3600;    var credentials = new SigningCredentials(        new RsaSecurityKey(privateKey), SecurityAlgorithms.RsaSha256);    var descriptor = new SecurityTokenDescriptor    {        Issuer = clientId,        Subject = new ClaimsIdentity(new[]        {            new Claim("sub", serviceAccountId)        }),        IssuedAt = DateTimeOffset.FromUnixTimeSeconds(iat).UtcDateTime,        Expires = DateTimeOffset.FromUnixTimeSeconds(exp).UtcDateTime,        SigningCredentials = credentials    };    var handler = new JwtSecurityTokenHandler();    return handler.CreateEncodedJwt(descriptor);}

체크리스트

JWT 생성 시 다음의 체크리스트 항목을 확인한다.

#항목
1iat/exp를 고정값이 아니라 요청할 때마다 현재 시각 기준으로 새로 생성하고 있는가?
2iat/exp를 Unix Timestamp(epoch seconds)로 입력했는가?
3Unix Timestamp는 **초 단위(10자리)**로 입력했는가?
4exp - iat가 3600(1시간) 이하인가?
5토큰 발급 요청 시점이 exp 이전인가?