# Relyt One 관리형 인증, 미리 보기 공개

앱 사용자를 위한 가입, 로그인, 세션, 조직 기능이 브랜치 바로 옆에서 실행되고 사용자는 여러분의 데이터베이스에 저장됩니다. 토큰은 JWKS로 검증하는 수명이 짧은 EdDSA JWT이며, 나머지는 행 수준 보안이 처리합니다.

**이제 Relyt One에서 관리형 인증을 미리 보기로 사용할 수 있습니다. 브랜치에서 관리형 인증을 켜면 그 브랜치에 앱 사용자를 위한 전용 로그인 서비스가 생깁니다. 이메일과 비밀번호, GitHub와 Google, 세션, 역할과 권한을 갖춘 조직, 교체할 수 있는 서명 키가 포함됩니다. 사용자는 브랜치 자체 데이터베이스 안의 `relyt_auth` 스키마에 저장되고, 백엔드는 수명이 짧은 JWT를 JWKS URL로 검증하며, Postgres 행 수준 보안이 한 사용자의 행을 다른 사용자로부터 지켜 줍니다.**

활성화하는 데는 약 1분이 걸리며, 오픈 소스 [`@relyt/auth`](https://github.com/relytcloud/relyt-auth-js) SDK와 완전한 [다중 사용자 예제](https://github.com/relytcloud/relyt-auth-examples/tree/main/todo)가 GitHub에 있습니다. 바로 시작하고 싶다면 단계별 가이드 [Relyt One 관리형 인증 시작하기](/blog/ko/managed-auth-getting-started)를 보세요.

이 글에서는 왜 이런 방식으로 만들었는지를 이야기합니다.

## 인증은 모든 앱에 가장 먼저 필요하고, 가장 먼저 어긋납니다

앱이 개인적인 정보를 저장하는 순간 세 가지가 필요해집니다. 사용자가 로그인할 방법, 백엔드가 누가 호출하는지 알 방법, 그리고 한 사용자가 다른 사용자의 데이터를 절대 볼 수 없다는 보장입니다.

보통은 인증 제공자를 고르고 앱에 연결한 다음, 앱의 수명이 다할 때까지 두 시스템을 일치시키며 지냅니다. 바로 여기서 어려워집니다.

- **사용자가 다른 곳에 있습니다.** 인증 제공자는 자체 데이터베이스에 자체 ID를 가진 자체 사용자 테이블을 갖고 있습니다. 여러분의 데이터에서 사용자를 가리키는 모든 외래 키가 시스템 경계를 넘어갑니다. "주문"을 "사용자"와 조인하려면 API 호출이나 동기화 작업이 필요합니다.
- **환경이 맞지 않습니다.** 데이터베이스에는 프로덕션 사본, 스테이징 사본, 풀 리퀘스트마다 하나씩 브랜치가 있습니다. 반면 인증 제공자에는 보통 프로덕션 테넌트 하나와 많아야 테스트 테넌트 하나가 있을 뿐입니다. 결국 미리 보기 환경은 프로덕션 사용자로 로그인하거나, 어떤 테스트 픽스처와도 맞지 않는 빈 테넌트로 로그인하게 됩니다.
- **권한 부여가 한곳에서만 적용됩니다.** 제공자는 API에 호출자가 누구인지 알려 줍니다. 그 호출자가 특정 행을 볼 수 있는지는 애플리케이션 코드에서 쿼리마다 확인합니다. `WHERE user_id = ...`를 하나만 빠뜨려도 데이터 유출이 일어납니다.

코딩 에이전트는 이 세 가지를 모두 더 날카롭게 만듭니다. 새 앱을 설정하는 에이전트는 구성된 테넌트가 무엇이든 그것을 상대로 서둘러 인증을 연결하고, 기억나는 곳에서만 권한을 확인합니다.

## 브랜치에 있는 인증

관리형 인증은 Relyt One의 다른 부분과 같은 생각에서 출발합니다. 데이터에 속한 것은 데이터와 함께 있어야 한다는 것입니다.

![브라우저는 브랜치의 인증 URL에서 로그인해 수명이 짧은 JWT를 받고 자체 API를 호출합니다. API는 JWKS로 JWT를 검증하고, relyt_auth가 있는 같은 데이터베이스에서 행 수준 보안을 위해 app.user_id를 설정합니다](/blog/assets/3d0199b08b840540.svg)

브랜치에서 관리형 인증을 활성화하면 다음과 같이 됩니다.

- **브랜치에 전용 인증 URL이 생깁니다.** 사용자는 그곳에서 가입하고 로그인합니다. 이 URL은 앱과는 다른 출처입니다. 로그인 양식은 이 URL로 직접 전송되므로 백엔드는 비밀번호를 받지도, 저장하지도 않습니다.
- **사용자, 세션, 조직은 여러분의 데이터베이스에 저장됩니다.** 해당 브랜치의 `relyt_auth` 스키마에 있으며, 이들이 소유한 행 바로 옆에서 SQL로 살펴볼 수 있는 행입니다.
- **브랜치에 JWKS URL이 생깁니다.** 이 URL은 브랜치 토큰의 공개 키를 게시합니다. 백엔드에는 공개 키만 있으면 됩니다.
- **인증 설정은 브랜치에 속합니다.** 각 브랜치는 자체 로그인 방법, 출처, 키를 가지며, 브랜치나 프로젝트를 삭제하면 함께 제거됩니다. 인증이 활성화된 브랜치의 하위 브랜치는 사본으로 시작합니다. 신뢰할 수 있는 출처와 이메일 설정을 이어받고, 데이터베이스에는 상위 브랜치 최종 사용자의 사본이 들어 있지만, 세션과 서명 키는 이어받지 않습니다.

이 서비스는 [Better Auth](https://www.better-auth.com/)를 기반으로 하므로, Better Auth를 써 본 적이 있다면 로그인 엔드포인트와 세션 모델이 익숙할 것입니다.

## 요청이 인증되는 방식

흐름은 어떤 프레임워크를 쓰든 모든 앱에서 같습니다.

1. **인증 URL에서 로그인합니다.** 브라우저가 이메일과 비밀번호를 브랜치의 인증 URL로 전송하고(또는 GitHub나 Google을 거치고) 세션 토큰을 돌려받습니다.
2. **세션을 JWT로 교환합니다.** SDK가 세션을 EdDSA로 서명된, 15분간 유효한 JSON Web Token으로 교환합니다. SDK는 토큰을 캐시하고 만료되기 전에 갱신합니다.
3. **JWT로 API를 호출합니다.** 브라우저는 `Authorization: Bearer <jwt>`를 보냅니다. 브라우저가 직접 토큰을 검증하지는 않습니다.
4. **서버에서 검증합니다.** API는 브랜치의 JWKS로 서명을 확인하고 발급자, 대상, 만료 시간도 확인한 뒤 `sub`에서 사용자 ID를 가져옵니다.
5. **Postgres가 적용하도록 합니다.** API가 트랜잭션에 사용자 ID를 설정하면, 행 수준 보안 정책이 그 사용자가 볼 수 있는 행을 결정합니다.

코드로 보면 4단계와 5단계는 몇 줄이면 됩니다.

```ts
import { getClaims, RelytAuthUnavailableError } from "@relyt/auth/next";

export async function GET(req: Request) {
  let claims;
  try {
    claims = await getClaims(req, { issuer: process.env.RELYT_AUTH_ISSUER! });
  } catch (e) {
    // 키를 가져오지 못함: 503을 반환해 일시적인 네트워크 문제로 모두가 로그아웃되지 않게 함
    if (e instanceof RelytAuthUnavailableError) return new Response(null, { status: 503 });
    throw e;
  }
  if (!claims) return new Response(null, { status: 401 });

  const rows = await db.transaction(async (tx) => {
    await tx.execute(sql`select set_config('app.user_id', ${claims.sub}, true)`);
    return tx.select().from(notes); // 이 사용자의 노트만
  });
  return Response.json(rows);
}
```

```sql
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
ALTER TABLE notes FORCE ROW LEVEL SECURITY;

CREATE POLICY own_notes ON notes FOR ALL
  USING (owner_id = current_setting('app.user_id', true))
  WITH CHECK (owner_id = current_setting('app.user_id', true));
```

이 설정은 트랜잭션 범위이므로 풀링된 연결이 다음 요청으로 설정을 넘기는 일은 없습니다. 코드에서 설정을 잊어버리면 `current_setting`은 아무것도 반환하지 않고 정책은 어떤 행과도 일치하지 않습니다. 확인이 빠지면 접근이 막힐 뿐, 유출되지는 않습니다.

## 미리 보기에 포함된 기능

### 로그인 방법

- **이메일과 비밀번호.**
- **GitHub와 Google.** OAuth 앱의 Client ID와 Secret을 콘솔에 붙여 넣으면 됩니다. Secret은 쓰기 전용이며 다시 표시되지 않습니다. 출처를 넘나드는 소셜 로그인은 PKCE와 함께 일회용 전달 코드를 사용하므로 서드 파티 쿠키 없이도 작동합니다.

### 리디렉션과 출처

인증 URL은 여러분이 등록한 **신뢰할 수 있는 출처**(예: `https://app.example.com`, `http://localhost:3000`, 와일드카드 불가)의 브라우저에만 응답합니다. 소셜 로그인은 정확히 등록된 **콜백 URL**로만 리디렉션합니다. 두 목록 모두 브랜치마다 따로 있으므로, 미리 보기 브랜치는 프로덕션까지 신뢰하게 만들지 않고도 `https://pr-128.example.dev`를 신뢰할 수 있습니다.

### 토큰과 키

- **JWT는 15분 동안 유효**하며 EdDSA로 서명됩니다. SDK는 EdDSA만 허용하므로 다른 알고리즘으로 서명된 토큰은 거부됩니다.
- **서명 키는 필요할 때 교체할 수 있습니다.** 교체하면 다음 토큰부터 새 키로 서명하고, 이전 키는 960초(약 16분) 동안 JWKS에 남아 있어 이미 발급된 토큰이 계속 검증되며, 에지 노드는 약 30초 안에 새 키 세트를 반영합니다. 이 유예 기간 동안 이전 키는 최대 3개까지만 보관되므로, 그 안에 추가로 교체하려 하면 이전 키 하나가 만료될 때까지 거부됩니다. 검증 측은 알 수 없는 키 ID를 만나면 JWKS를 다시 가져와야 하며, `@relyt/auth`는 그렇게 합니다.
- **세션은 폐기할 수 있습니다.** 한 사용자의 세션만, 또는 브랜치의 모든 사용자 세션을 폐기할 수 있습니다. 폐기된 세션은 더 이상 갱신하거나 새 토큰으로 교환할 수 없습니다. 이미 발급된 JWT는 만료될 때까지(최대 15분) 유효하므로, 접근을 즉시 막아야 한다면 앱에서도 확인하세요.

### 조직, 역할, 권한

사용자는 조직을 만들 수 있고, 소유자와 관리자는 인증 URL의 조직 엔드포인트로 멤버를 초대할 수 있습니다(미리 보기에서는 초대 이메일이나 인증 이메일을 보내지 않으므로, 초대받은 사람은 인증된 이메일로 로그인해야 하며 현재로서는 GitHub나 Google을 뜻합니다). 각 멤버에게는 역할이 있으며, 콘솔에서 자체 **리소스와 작업**을 정의해 역할에 부여할 수 있습니다. 예를 들어 `create`, `read`, `update`, `delete` 작업을 가진 `todo` 리소스를 정의할 수 있습니다. 기본 제공 역할은 `owner`, `admin`, `member`이며, 사용자 지정 역할은 최대 16개, 사용자 지정 리소스는 최대 32개까지 추가할 수 있습니다.

사용자에게 활성 조직이 있으면 JWT에 그 정보가 담깁니다.

```json
"o": {
  "id": "…",
  "slug": "acme",
  "role": "member",
  "perms": ["todo:create", "todo:read", "todo:update"]
}
```

API는 작업에 대해 `o.perms`를 확인하고, `org_id`에 대한 RLS 정책이 어떤 팀 행을 볼 수 있는지 결정합니다. 권한 목록은 토큰에 들어가야 하므로 콘솔은 `resource:action` 쌍을 합계 64개, 1 KiB로 제한합니다. 만약 들어가지 않으면 `perms`는 `null`이 되며, API는 추측하지 말고 거부해야 합니다.

### 운영

- **Users**(사용자)에는 브랜치의 최종 사용자와 이메일 인증 여부, 역할, 활성 세션, 마지막 활동이 표시되며, 로드된 목록 안에서 검색할 수 있습니다.
- **Integration code**(연동 코드)에는 환경 변수와 함께 Next.js 미들웨어, 라우트 핸들러, 브라우저 클라이언트, 소셜 로그인용 코드 조각이 브랜치의 URL로 채워져 표시됩니다.
- **Disable auth**(인증 비활성화)는 사용자, 설정, 키를 삭제하지 않고 로그인만 중단합니다. 다시 활성화하면 모든 것이 그대로 남아 있습니다.

## 주의할 점: BYPASSRLS

행 수준 보안은 앱이 정책이 적용되는 역할로 연결할 때만 보호 효과가 있습니다. Relyt One 콘솔에서 만든 데이터베이스 사용자에게는 `BYPASSRLS`가 있으며, Postgres는 이런 사용자에 대해 모든 정책을 조용히 건너뜁니다. RLS에 의존하는 앱이라면 SQL로 전용 애플리케이션 역할을 만드세요.

```sql
CREATE ROLE app_user LOGIN NOBYPASSRLS NOSUPERUSER NOCREATEROLE NOCREATEDB;
\password app_user
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON notes TO app_user;
```

`\password`는 비밀번호를 입력받는 psql 명령이므로 비밀번호가 셸 기록에 남지 않습니다. 그런 다음 `SELECT rolbypassrls FROM pg_roles WHERE rolname = 'app_user';`로 역할을 확인하세요. 결과는 `f`여야 합니다. 예제 앱의 테스트 모음에는 이 설정을 잘못했을 때 어떤 일이 생기는지 보여 주는 테스트 케이스가 있습니다.

## 미리 보기 제한 사항

관리형 인증은 미리 보기 단계이며, 사람들이 어떻게 사용하는지 알아 가는 동안 일부 기능은 의도적으로 작게 유지하거나 제외했습니다.

- 조직 소유자와 관리자만 인증을 활성화하고 설정을 변경할 수 있습니다.
- 하위 브랜치는 소셜 로그인 자격 증명을 복사하지 않으며, GitHub OAuth 앱에는 콜백 URL을 하나만 등록할 수 있으므로 브랜치마다 OAuth 앱을 하나씩 사용하세요.
- 아직 이메일을 보내지 않습니다. 인증, 비밀번호 재설정, 초대 이메일이 없습니다.
- 정식 출시 전에 이름, 한도, 콘솔 레이아웃이 바뀔 수 있습니다.

## 사용해 보기

1. 콘솔에서 브랜치를 열고 **Auth**(인증)로 이동한 뒤 **Enable auth**(인증 활성화)를 선택하세요.
2. [Relyt One 관리형 인증 시작하기](/blog/ko/managed-auth-getting-started)를 따라 앱에 로그인, 토큰 검증, RLS를 추가하세요.
3. 또는 [TODO 예제](https://github.com/relytcloud/relyt-auth-examples/tree/main/todo)를 클론하세요. 개인 TODO와 팀 TODO, 테스트 모음으로 이 모든 것을 보여 줍니다.

무엇이 깨지는지 알려 주세요. 지금이 바로 무엇이든 바꿀 수 있는 미리 보기 기간입니다.

[관리형 인증 사용해 보기](/auth/signin?ref=blog-managed-auth-preview)
