
# JavaScript 브라우저 SDK

`@keenauth/js`는 KeenAuth의 브라우저 핵심 패키지입니다. 단일 페이지 앱에서 사용자의 로그인을 처리하고, 토큰을 보관하며, API를 호출합니다. 런타임 종속성이 없으며, React 컴포넌트는 이 패키지를 기반으로 합니다. 이 패키지는 아직 npm에 게시되지 않았습니다.

## 리디렉션으로 로그인

SDK는 표준 OpenID Connect 공개 클라이언트입니다:

- PKCE(S256)와 DPoP 바인딩 토큰을 사용합니다.
- 콜백에서 `state`, `nonce`, `iss`를 확인합니다.
- 환경의 키를 기준으로 ID 토큰의 서명과 클레임을 검증합니다.

```ts
import { KeenAuth } from "@keenauth/js";

const auth = new KeenAuth({
  issuer: "https://acme.eu.keenauth.app", // your environment's issuer, exactly
  clientId: "app_01j9zzzzzzzzzzzzzzzzzzzz",
  redirectUri: "https://app.acme.example/callback",
  postLogoutRedirectUri: "https://app.acme.example/",
});

// On your sign-in button:
await auth.signInRedirect({ appState: "/orders" });

// On https://app.acme.example/callback:
const { appState } = await auth.handleRedirectCallback();
```

`handleRedirectCallback`은 응답을 한 번만 허용합니다. 다음의 경우 `KeenAuthError`와 함께 실패합니다:

- `state` 또는 `iss`가 일치하지 않는 경우;
- 진행 중인 로그인이 10분보다 오래된 경우;
- ID 토큰이 유효하지 않은 경우.

서버가 되돌려 보낸 오류는 OAuth `error` 코드를 담은 `KeenAuthOAuthError`로 발생합니다.

## API 호출하기

API용 액세스 토큰을 받으려면 API 식별자를 `resource`(RFC 8707)로 전달합니다. 이 값은 인가 요청, 코드 교환, 모든 갱신 요청에 전송되며, 토큰의 `aud`는 이 식별자가 됩니다:

```ts
const auth = new KeenAuth({
  issuer: "https://acme.eu.keenauth.app",
  clientId: "app_01j9zzzzzzzzzzzzzzzzzzzz",
  redirectUri: "https://app.acme.example/callback",
  resource: "https://api.acme.example", // your API's identifier
  scope: "openid profile email offline_access messages:read",
});
```

- 값은 프래그먼트(`#`)가 없는 절대 URI여야 합니다. 입력한 그대로 전송되므로, 등록된 API 식별자를 그대로 사용하십시오. 잘못된 값은 생성자에서 코드 `config_invalid`의 `KeenAuthError`를 던집니다.
- `signInRedirect({ resource })`는 한 번의 로그인에 대해 이 값을 재정의합니다.
- `resource`를 지정하지 않으면, 애플리케이션에 기본 API가 설정되어 있을 경우 서버가 그 API를 사용합니다. 그렇지 않으면 API 범위는 부여되지 않습니다.
- 이 옵션은 배열도 받을 수 있으며, RFC 8707이 허용하는 대로 반복된 `resource` 매개변수로 전송됩니다. KeenAuth 액세스 토큰은 정확히 하나의 audience를 가지므로, 서버는 둘 이상의 값을 `invalid_target`(`KeenAuthOAuthError`)으로 거부합니다.

```ts
const res = await auth.authorizedFetch("https://api.acme.example/v1/orders");

// Or take the token yourself; it is refreshed when it is about to expire.
const token = await auth.getAccessToken();
```

`authorizedFetch`는 토큰에 바인딩된 새 증명과 함께 `Authorization: DPoP`를 전송합니다. 서버가 DPoP nonce를 요구하면, 그 nonce로 한 번 재시도합니다. 동시 호출은 하나의 갱신 요청을 공유하며, 모든 갱신은 갱신 토큰을 교체합니다. API에서 이 토큰을 검증하는 방법은 [API에서 토큰 검증하기](/docs/sdk/token-verification)를 참조하십시오.

## 로그아웃

```ts
await auth.signOut();
```

`signOut`은 토큰을 삭제하고 갱신 토큰을 취소합니다. 그런 다음 브라우저를 환경의 로그아웃 엔드포인트로 보내며, 이 엔드포인트는 브라우저를 `postLogoutRedirectUri`로 되돌립니다.

## 토큰 보관 위치

- 액세스, 갱신, ID 토큰은 메모리에만 보관되며, localStorage, sessionStorage, IndexedDB, 쿠키 또는 URL에는 저장되지 않습니다. 페이지를 새로 고치면 리디렉션을 통해 사용자가 다시 로그인하며, SDK는 세션을 복원하지 않습니다.
- sessionStorage에는 진행 중인 로그인의 일회성 값만 보관됩니다.
- DPoP 키는 추출할 수 없는 WebCrypto 키이며, 기본적으로 메모리에 보관됩니다.

## 내장 로그인

같은 객체는 내장 컴포넌트에 프런트 API 클라이언트도 제공합니다:

- 로그인 단계용 `transactions`;
- 계정의 프로필과 `user_metadata`, 이메일(코드 인증 포함), 패스키, 세션용 `me`;
- 패스키 절차용 `webauthn`.

이 호출은 환경의 쿠키와 CSRF 헤더를 사용합니다. 서버는 환경 자체의 로그인 출처에서 온 요청만 허용합니다.

## 리디렉션 없이 로그인

로그인 출처 밖의 페이지에서는 `clientId`로 트랜잭션을 시작합니다. 그러면 SDK는 쿠키 대신 자체 DPoP 키에 트랜잭션을 바인딩합니다:

```ts
let tx = await auth.transactions.create({ client_id: "app_01j9...", organization: "acme" });
tx = await auth.transactions.step("identifier", { identifier: "ada@acme.example" });
tx = await auth.transactions.step("password", { password });
if (tx.state === "completed") await auth.complete(tx); // { kind: "signed_in" }
```

- 애플리케이션은 자사(first-party) 애플리케이션이어야 하며, 페이지 출처는 그 애플리케이션의 허용된 웹 출처 중 하나여야 합니다.
- 모든 호출에는 DPoP 증명이 포함되며 쿠키는 포함되지 않습니다. 서버가 DPoP nonce를 요구하면, SDK는 그 nonce로 한 번 재시도합니다.
- SDK는 PKCE verifier, `state`, `nonce`를 직접 생성하여 메모리에 보관합니다. `scope`와 `resource`는 옵션에서 가져오며, `create`에 `organization`과 `uiLocales`를 전달할 수 있습니다. 여기서는 `resource` 값을 하나만 사용하십시오.
- `complete`는 응답을 확인하고, 같은 DPoP 키로 인가 코드를 교환하며, 토큰을 메모리에 보관합니다. 페이지는 이동하지 않으며, 코드는 사용자의 코드에 반환되지 않습니다.
- `client_id` 포함 여부와 관계없이 모든 `step` 호출은 브라우저의 시간대를 `Idp-Zoneinfo` 헤더로 전송합니다. 단계가 로그인을 완료했고 사용자가 시간대를 선택하지 않은 경우, 호스티드 로그인 페이지와 마찬가지로 KeenAuth가 이를 프로필의 `zoneinfo`에 저장합니다. 브라우저가 사용 가능한 시간대를 보고하지 않으면 헤더는 생략되고 단계는 그대로 유지됩니다.
- 로그인에 사용자가 직접 확인해야 하는 페이지(동의, 법적 약관, 조직 선택)가 필요하면, 마지막 단계는 코드가 `authorization_failed`인 `KeenAuthProblemError`로 실패합니다. 이런 사용자에게는 `signInRedirect`를 사용하십시오.

## 자체 출처에서 계정 호출

사용자가 로그인하고 SDK가 갱신 토큰을 보유하면(기본 범위는 `offline_access`를 요청합니다), `me` 및 `organizations` 호출은 더 이상 로그인 호스트의 세션 쿠키를 필요로 하지 않습니다:

- SDK는 `resource=<issuer>/fapi`로 갱신하여 프런트 API용 단기 액세스 토큰을 받습니다. 이 토큰은 DPoP에 바인딩되며, 다른 토큰과 마찬가지로 메모리에만 보관됩니다.
- 각 계정 호출은 `Authorization: DPoP <token>`과 해당 호출을 위한 DPoP 증명을 전송하며, 쿠키는 포함되지 않습니다. 서버가 DPoP nonce를 요구하면 SDK는 그 nonce로 한 번 재시도합니다. 토큰이 더 이상 유효하지 않으면 새 토큰을 받고 한 번 재시도합니다.
- 갱신 토큰은 갱신할 때마다 교체되므로, SDK는 갱신을 하나씩 순서대로 실행합니다.
- 이 방식은 허용된 웹 출처에 페이지의 출처가 포함된 자사 애플리케이션에서만 동작합니다.
- 토큰이 없으면 SDK는 이전과 같이 세션 쿠키를 사용합니다.

서버 규칙은 [프런트 API 액세스 토큰](/docs/api/fapi/front-api-token)을 참조하십시오.

## 조직 전환

`organizations.switch(orgId)`는 페이지 이동 없이 로그인한 사용자를 다른 조직으로 옮깁니다. `organizations.listMine()`에서 얻은 조직의 `org_…` ID를 전달하십시오:

```js
const { data } = await auth.organizations.listMine();
await auth.organizations.switch(data[1].id);
```

- 위의 계정 호출과 같은 조건이 필요합니다: 갱신 토큰, `redirectUri`, 그리고 허용된 웹 출처에 페이지의 출처가 포함된 자사 애플리케이션.
- SDK는 프런트 API 토큰과 함께 [전환 요청](/docs/api/fapi/transactions/create)을 보내고, 같은 키의 증명으로 코드를 교환하며, `org_id`가 해당 조직인 새 토큰을 보관합니다. 이 모든 과정이 성공할 때까지 기존 토큰은 유지됩니다.
- 조직이 사용자가 직접 확인해야 하는 페이지(MFA, SSO, 동의)를 요구하면, 이 호출은 코드가 `authorization_failed`인 `KeenAuthProblemError`로 실패합니다. 이런 사용자에게는 `signInRedirect({ organization })`를 사용하십시오.

## 현재 제한 사항

- 프로필 쓰기(`me.update`, `me.updateMetadata`)는 마지막 프로필 읽기 또는 쓰기의 `ETag`를 담은 `If-Match`를 보냅니다. 그 사이에 프로필이 다른 곳에서 변경되었다면, 쓰기는 코드가 `precondition_failed`인 `KeenAuthProblemError`로 실패합니다. 다시 시도하기 전에 `me.get()`을 다시 호출하십시오.
- 전화번호는 아직 사용할 수 없습니다: `me.phones`는 `undefined`입니다.
