
# Go 서버 SDK

모듈 `github.com/keenauth/keenauth-go`는 KeenAuth 관리 API의 Go 클라이언트입니다. 모든 `/v1` 리소스에 대한 타입이 지정된 메서드는 OpenAPI 문서에서 생성됩니다(패키지 `management`, `keenauth.Client`에 포함). 직접 작성한 핵심 부분은 인증, 재시도, 멱등성 키, 타입이 지정된 오류 및 지원 중단 경고를 추가합니다. 이 모듈은 표준 라이브러리만 사용합니다. 아직 게시되지 않았습니다.

`net/http` 앱에서 로그인하려면 [Go net/http 세션](/docs/sdk/go-nethttp)을 참조하십시오. 직접 만든 API를 보호하려면 [API 보호](/docs/sdk/protect-api)를 참조하십시오.

## API 키로 클라이언트 생성

```go
import (
	"context"
	"fmt"
	"os"

	keenauth "github.com/keenauth/keenauth-go"
)

c, err := keenauth.NewClient("https://api.eu.keenauth.com/v1", os.Getenv("KEENAUTH_API_KEY"))
if err != nil {
	panic(err)
}
user, _, err := c.UsersAPI.GetUser(context.Background(), "usr_01j9zzzzzzzzzzzzzzzzzzzz").Execute()
if err != nil {
	panic(err)
}
fmt.Println(user.GetEmail())
```

기본 URL은 리전의 API 주소에 `/v1`을 붙인 것입니다. 키(`idp_sk_…` 또는 `idp_rk_…`)는 `Authorization` 헤더에만 담기며, 해당 주소로만 전송됩니다. 다른 호스트나 일반 `http`로의 리디렉션에는 키가 절대 포함되지 않습니다. 일반 `http`는 `localhost`에 대해서만 허용됩니다.

옵션:

- `keenauth.WithHTTPClient(h)`: 직접 만든 `http.Client`입니다(해당 트랜스포트는 변경되지 않고 감싸집니다).
- `keenauth.WithTimeout(d)`: 재시도를 포함한 호출 한 번의 전체 제한 시간입니다(기본값 30초).
- `keenauth.WithClientName("my-tool", "1.4.0")`: 도구 이름을 `User-Agent`와 `Idp-Client`에 추가합니다.
- `keenauth.WithLogger(l)`: SDK 경고에 사용할 `*slog.Logger`입니다(기본값 `slog.Default()`).

## private_key_jwt로 인증

정적 키 대신 관리 애플리케이션은 자체 키 쌍으로 로그인할 수 있습니다. SDK는 환경의 토큰 엔드포인트에서 관리 토큰을 받아 메모리에 보관하고, 만료되기 60초 전에 교체합니다. 동시에 들어온 호출은 하나의 토큰 요청을 공유합니다.

1. 대시보드에서 관리 애플리케이션을 만들고 공개 키를 등록합니다(JWKS URL 또는 직접 입력, 활성 키는 최대 3개).
2. 개인 키는 서버에 보관합니다(예: 비밀 파일 또는 KMS).

```go
import (
	"os"

	keenauth "github.com/keenauth/keenauth-go"
	"github.com/keenauth/keenauth-go/assertion"
)

pemBytes, err := os.ReadFile("/run/secrets/keenauth-mgmt-key.pem")
if err != nil {
	panic(err)
}
key, err := assertion.ParsePrivateKeyPEM(pemBytes)
if err != nil {
	panic(err)
}
c, err := keenauth.NewClientWithPrivateKeyJWT("https://api.eu.keenauth.com/v1", keenauth.PrivateKeyJWT{
	Issuer:   "https://acme.eu.keenauth.app", // your environment's issuer, exactly
	ClientID: "app_01j9zzzzzzzzzzzzzzzzzzzz",
	Key:      key,
	KeyID:    "mgmt-key-2026-11", // the kid you registered; optional with a single key
	Scope:    "users:read users:write", // optional; default: the grant's default scopes
})
```

SDK가 전송하는 내용:

- `POST <Issuer>/oauth2/token`, `grant_type=client_credentials`, `client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer`, 새 `client_assertion`, 그리고 API 주소(`https://api.eu.keenauth.com`)로 설정된 `resource`입니다(`Audience`로 변경 가능).
- 어서션은 `iss`와 `sub`가 클라이언트 ID로, `aud`가 발급자로 설정되고, 무작위 `jti`와 2분의 수명을 가진 JWT입니다. ES256(P-256 키), EdDSA(Ed25519), RS256 또는 PS256(RSA, 2048비트 이상; PSS를 쓰려면 `Algorithm: "PS256"` 설정)으로 서명됩니다.

`Key`는 모든 `crypto.Signer`이므로 내보내지 않고도 KMS 또는 HSM에 보관된 키를 사용할 수 있습니다. 관리 토큰의 수명은 10분입니다. API가 `401`을 응답하면 SDK는 캐시된 토큰을 버리고, 다음 호출에서 새 토큰을 받습니다.

토큰 요청이 거부되면 HTTP 상태와 OAuth `error` 코드가 담긴 `*keenauth.TokenError`가 반환됩니다. 예를 들어 알 수 없는 키나 잘못된 `KeyID`에는 `invalid_client`가 반환됩니다. 어서션이나 토큰은 절대 포함되지 않습니다.

```go
var te *keenauth.TokenError
if errors.As(err, &te) {
	log.Printf("token request refused: %d %s", te.Status, te.Code)
}
```

## 재시도와 멱등성 키

SDK는 다음 두 조건을 모두 만족할 때만 호출을 최대 총 3회까지 재시도합니다.

- 응답이 `429`, `502`, `503` 또는 `504`이거나 연결이 실패한 경우(네트워크 오류이며 인증서 오류는 제외);
- 메서드가 `GET`, `PUT`, `DELETE`이거나, `Idempotency-Key`를 가진 `POST`인 경우.

`Idempotency-Key`를 받는 모든 `POST` 작업(부작용이 없는 `users:search`와 `…:preview` 호출, `users/{id}:unlock`을 제외한 전부)은 새 UUIDv4 키를 받으며, 해당 호출의 모든 재시도는 같은 키와 같은 본문을 보내므로 작업은 최대 한 번만 실행됩니다. 자체 재시작에도 호출을 멱등하게 만들려면 직접 키(`A-Z a-z 0-9 _ -` 중 16~64자)를 전달하십시오(예: `.IdempotencyKey(orderKey)`). SDK는 이 키를 절대 바꾸지 않습니다. `If-Match`를 포함한 `PATCH`(버전이 지정된 모든 업데이트)는 `429`에서만 재시도됩니다. 속도 제한기는 실행 전에 요청을 거부하며, `If-Match`는 변경이 두 번 적용되지 않도록 막습니다. 그 밖의 `PATCH`는 재시도되지 않습니다.

시도 사이에 SDK는 `Retry-After`가 지정한 시간(초 또는 HTTP 날짜)만큼 기다립니다. 헤더가 없으면 0.5초, 이어서 1초를 기다리며, 각각 ±25%의 지터가 적용됩니다. `Retry-After`가 60초를 초과하거나 대기 시간이 컨텍스트의 마감 시간을 넘기면, SDK는 기다리지 않고 응답을 반환합니다. 컨텍스트를 취소하면 대기가 중단됩니다.

## 목록 순회

목록 메서드는 한 페이지를 반환합니다. 이때 `data`와 `before` 및 `after` 커서가 담긴 `list_metadata`가 포함됩니다. `keenauth.All`은 `after`를 따라가며 모든 항목을 생성합니다. 다음 페이지는 도달했을 때에만 가져오므로, 반복문을 빠져나오면 추가 요청이 중단됩니다. 평소처럼 요청에 페이지 크기와 필터를 설정하십시오.

```go
for u, err := range keenauth.All(c.UsersAPI.ListUsers(ctx).Limit(100).Status("active")) {
	if err != nil {
		return err // the iteration ends after an error
	}
	fmt.Println(u.GetId(), u.GetEmail())
}
```

`keenauth.AllBefore`는 대신 `before`를 따라갑니다. 예를 들어 `ListLogs`의 더 최근 항목을 향해 진행합니다. 생성된 모든 목록 메서드는 두 방식 모두에서 동작하며, `next_cursor`와 `has_more`로 페이지를 나누는 Events API의 `ListEvents`만 예외입니다. range-over-func 반복문에는 Go 1.23 이상이 필요합니다.

## 웹훅 검증

패키지 `github.com/keenauth/keenauth-go/webhook`은 웹훅 전송의 서명을 확인합니다(Standard Webhooks). 엔드포인트의 서명 비밀(`idp_whsec_…`)을 전달하고, 받은 그대로의 원본 본문을 넘기십시오.

```go
import (
	"io"
	"net/http"
	"os"

	"github.com/keenauth/keenauth-go/webhook"
)

verifier, err := webhook.NewVerifier([]string{os.Getenv("KEENAUTH_WEBHOOK_SECRET")})
if err != nil {
	panic(err) // webhook.ErrSecret: the secret is not well formed
}

http.HandleFunc("POST /webhooks/keenauth", func(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
	if err != nil {
		http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
		return
	}
	if err := verifier.Verify(r.Header, body); err != nil {
		http.Error(w, "invalid webhook", http.StatusBadRequest)
		return
	}
	// Handle the event. Store webhook-id and skip a delivery you have already processed.
	w.WriteHeader(http.StatusNoContent)
})
```

`Verify`는 `webhook-timestamp`가 현재 시각과 5분 이내(양방향)이고 `webhook-signature`의 `v1` 서명 중 하나가 일치하면 전송을 수락합니다. 서명은 상수 시간에 비교되며, 다른 서명 버전은 무시됩니다. 오류는 전송이 거부된 이유를 알려 줍니다. 각 오류는 `errors.Is`로 `webhook.ErrHeaders`(헤더 누락), `webhook.ErrTimestamp`, `webhook.ErrSignature` 또는 `webhook.ErrSecret` 중 하나와 일치합니다.

비밀을 교체할 때는 이전 비밀이 더 이상 사용되지 않을 때까지 새 비밀과 이전 비밀을 모두 전달하십시오: `webhook.NewVerifier([]string{newSecret, oldSecret})`. 교체 기간에는 KeenAuth가 하나의 헤더에 서명을 두 개 보낼 수도 있으며, 둘 중 하나만 일치해도 충분합니다.

## 타입이 지정된 이벤트와 클레임

패키지 `github.com/keenauth/keenauth-go/events`에는 공개 이벤트 스키마에서 생성된, 고객 이벤트 유형마다 하나씩의 구조체가 있습니다. `webhook.Verifier.VerifyEvent`는 전송을 검증하고 파싱하며, `events.Parse`는 이미 검증한 본문을 파싱합니다. 타입 switch를 사용하면 한 유형의 필드를 얻을 수 있습니다.

```go
ev, err := verifier.VerifyEvent(r.Header, body)
if err != nil {
	http.Error(w, "invalid webhook", http.StatusBadRequest) // webhook.Err… or events.ErrParse
	return
}
switch e := ev.(type) {
case *events.UserCreated:
	log.Printf("user %s created", e.Data.Object.ID)
case *events.EmailSendingSuspended:
	log.Printf("sending suspended: %s", e.Data.Object.Reason)
case *events.Unknown:
	// A type this SDK version does not know yet. e.Type and e.Raw() are still available.
}
```

- `Parse`는 봉투(`id`, `type`, `schema_version`, `environment_id`, `occurred_at`, `recorded_at`, `seq`, `data`, `context`)와 해당 유형의 스키마가 선언한 멤버를 확인합니다. 누락되었거나 타입이 잘못된 멤버는 `data.object.reason`과 같은 경로를 담은 `*events.ParseError`를 반환합니다.
- 알 수 없는 유형이나, 이 SDK가 모델링하지 않은 `schema_version`을 가진 알려진 유형은 `*events.Unknown`으로 반환됩니다. 이는 오류가 아닙니다.
- 스키마가 선언하지 않은 멤버는 유지됩니다. `Raw()`는 전체 이벤트를, `ObjectJSON()`은 `data.object`를 반환합니다(전체 페이로드 모드에서는 리소스의 관리 API 표현).
- 열거형 필드의 값은 알려진 값과 대조하지 않으므로, 서버의 새 값 때문에 파싱이 실패하지 않습니다.

패키지 `github.com/keenauth/keenauth-go/claims`는 검증된 토큰의 클레임에 타입을 부여합니다. ID 토큰(OpenID Connect Core)에는 `claims.ParseIDToken`을, 액세스 토큰(RFC 9068)에는 `claims.ParseAccessToken`을 사용합니다. 이 함수들은 클레임 타입만 확인하고 검증은 하지 않으므로, 패키지 `verify`가 반환한 `Claims` 맵에 대해 호출하십시오.

```go
at, err := v.VerifyAccessToken(ctx, token, nil)
if err != nil {
	return err
}
c, err := claims.ParseAccessToken(at.Claims)
if err != nil {
	return err
}
log.Printf("client %s, org %s, roles %v", c.ClientID, c.OrgID, c.OrgRoles)
_ = c.Custom["tenant_tier"] // your own claims
```

`aud`는 항상 슬라이스(`claims.Audience`)입니다. 계약에 포함되지 않은 클레임은 직접 만든 클레임이며 `Custom`에 보관됩니다.

## 오류

API 오류는 RFC 9457 문제 문서입니다. `keenauth.AsProblem(err)`은 기계가 읽을 수 있는 `Code`와 함께 이를 반환합니다. 알 수 없는 코드는 그대로 반환되므로, 서버의 새 코드가 클라이언트를 깨뜨리지 않습니다.

```go
if _, _, err := c.UsersAPI.GetUser(ctx, id).Execute(); err != nil {
	if pe, ok := keenauth.AsProblem(err); ok && pe.Code == "not_found" {
		// …
	}
}
```

## 지원 중단 경고

응답에 `Deprecation`(RFC 9745) 또는 `Sunset`(RFC 8594) 헤더가 있으면, SDK는 프로세스 전체에서 작업과 헤더 값마다 경고를 한 번씩 기록합니다. 이 경고는 `WithLogger`로 설정한 로거에 `WARN` 수준으로 기록됩니다. 경고에는 메서드, 식별자를 `{id}`로 바꾼 경로, 헤더 값, 그리고 `rel="deprecation"` 또는 `rel="sunset"`인 `Link`가 포함됩니다. ID, 토큰, 키는 절대 포함되지 않으며, 호출 자체에는 영향을 주지 않습니다.

```text
level=WARN msg="keenauth: the API operation is deprecated" operation="GET /users/{id}" deprecation=@1767225600 sunset="Wed, 01 Jul 2026 00:00:00 GMT"
```
