# 외부 로그인(SSO)

> 신뢰할 수 있는 ID 공급자가 서명된 JWT 어서션이나 OIDC, SAML 옵션으로 사용자를 Nodaro 설치에 로그인시키게 하고, 계정 연결 방식도 제어하세요.

Source: https://nodaro.ai/ko/docs/developers/sso

**외부 로그인**(SSO)을 사용하면 신뢰할 수 있는 ID 공급자(IdP)가 사용자를 Nodaro 설치에 로그인시킬 수 있습니다. 주요 연동 방식에서는 IdP가 수명이 짧은 JWT 어서션에 서명하고, Nodaro가 이를 검증하며, 브라우저는 일반 Nodaro 세션을 받습니다. 설치 운영자가 공급자를 하나 이상 구성하기 전까지 SSO는 꺼져 있습니다.

사용자가 최종적으로 들어가는 계정은 일반 계정입니다. SSO는 특별한 자격 증명을 추가하지 않습니다. 세션을 시작하는 방법일 뿐입니다.

## 두 가지 연동 방식
| 종류 | 대상 | 작동 방식 |
| --- | --- | --- |
| `assertion` | OIDC나 SAML을 지원하지 않는 발급자. 예를 들어 수명이 짧은 서명 토큰만 발급할 수 있는 임베딩 호스트 | IdP가 JWT에 서명하면, Nodaro가 이를 검증하고 세션으로 교환합니다. 이 방식은 처음부터 끝까지 작동합니다. |
| `oidc`, `saml` | 표준 OpenID Connect나 SAML을 지원하는 IdP | 로그인 페이지가 설치 자체의 Supabase Auth로 표준 로그인을 시작하고, Supabase Auth가 IdP의 응답을 검증합니다. 이 방식에는 제한이 있습니다. [OIDC와 SAML 공급자](#oidc-and-saml-providers)를 참조하세요. |

## 공급자 구성
서버의 `EXTERNAL_SSO_PROVIDERS`를 공급자의 JSON 배열로 설정하세요. 값을 직접 쓰거나 `@/path/to/providers.json` 형식으로 지정합니다(맨 앞의 `@`는 그 경로의 파일을 읽으라는 뜻입니다). 값의 형식이 잘못되면 서버가 시작할 때 멈추므로, 오타 때문에 공급자가 조용히 빠지거나 로그인이 절반만 구성되는 일은 없습니다.

```json
[
{
"id": "acme-chat",
"label": "Acme Chat",
"kind": "assertion",
"secret": "a-dedicated-32-character-hmac-key-for-nodaro-only",
"audience": "nodaro",
"claimMap": { "email": "email", "emailVerified": "email_verified", "subject": "sub" },
"initiateUrl": "https://chat.example.com/oauth/nodaro",
"maxLifetimeSeconds": 300
},
{
"id": "keycloak",
"label": "Acme (Keycloak)",
"kind": "oidc",
"supabaseProvider": "keycloak"
}
]
```

| 필드 | 적용 대상 | 의미 |
| --- | --- | --- |
| `id` | 모두 | 공급자의 슬러그로, 경로 `/v1/sso/:id`에 사용되며 연결된 각 계정에 저장됩니다. 1~63자이며, 첫 글자는 영문 소문자나 숫자이고, 그 뒤로는 영문 소문자, 숫자, `_`, `-`를 쓸 수 있습니다. 점은 쓸 수 없습니다. 고유해야 합니다. |
| `label` | 모두 | 공급자의 표시 이름입니다. |
| `kind` | 모두 | `assertion`, `oidc`, `saml` 중 하나입니다. |
| `secret` | `assertion`, 필수 | 어서션의 서명을 검증하는 HS256 키로, 16자 이상입니다. 전용 시크릿을 사용하고, IdP 자체의 세션 서명 시크릿은 절대 사용하지 마세요. 그래야 Nodaro가 침해되더라도 IdP 세션을 위조할 수 없습니다. |
| `audience` | `assertion`, 필수 | 어서션의 `aud` 클레임과 같아야 하는 값입니다. |
| `claimMap` | `assertion`, 선택 | 이메일, 인증 여부 플래그, 서브젝트를 각각 어떤 클레임이 담는지 지정합니다. 기본값은 `email`, `email_verified`, `sub`입니다. |
| `initiateUrl` | `assertion`, 선택 | SSO 버튼이 사용자를 보내는 주소입니다. 이 값이 없으면 버튼을 클릭했을 때 `400 no_assertion`으로 응답합니다. |
| `initiateUrlByHost` | `assertion`, 선택 | 포트가 없는 소문자 호스트 이름을, 그 호스트에서 `initiateUrl` 대신 쓸 주소에 대응시키는 맵입니다. 여러 호스트 이름으로 응답하는 배포 환경을 위한 것입니다. |
| `maxLifetimeSeconds` | `assertion`, 선택 | Nodaro가 허용하는 최대 수명(`exp`에서 `iat`를 뺀 값)입니다. 기본값은 300초이고, 최댓값은 3,600초입니다. |
| `domain` | `oidc`, `saml` | 시작할 때 검증되지만 사용되지는 않습니다. [OIDC와 SAML 공급자](#oidc-and-saml-providers)를 참조하세요. |
| `supabaseProvider` | `oidc` | 시작할 때 검증되지만 사용되지는 않습니다. |

- `initiateUrl`은 IdP에 아무것도 전달하지 않습니다. 로그인 후 사용자가 특정 페이지에 도착하게 하려면, IdP가 다시 리디렉션할 때 `&next=<path>`를 추가하게 하세요.
- `initiateUrlByHost`의 키는 시작할 때 검증됩니다. 대문자나 포트가 들어 있는 키가 있으면 서버가 멈춥니다. 이 맵은 공개되지 않으며, 계정 연결은 사용자가 어느 호스트로 들어왔는지와 관계가 없습니다.

## 어서션 규약
IdP가 JWT를 발급하고 브라우저를 `GET /v1/sso/:provider?assertion=<jwt>`로 보냅니다. Nodaro는 다음 규칙을 모두 충족할 때만 어서션을 허용합니다.

- **알고리즘.** JWT는 `HS256`과 공급자의 `secret`으로 서명됩니다.
- **대상.** `aud`가 공급자의 `audience`와 같습니다.
- **만료.** `exp`가 있고 과거 시각이 아닙니다. 시계 차이는 5초까지 허용됩니다.
- **수명.** `exp`에서 `iat`를 뺀 값이 `maxLifetimeSeconds` 이하입니다. 이 값은 Nodaro의 시계로 측정합니다. 5초 넘게 미래를 가리키는 `iat`는 보정되고, `iat`가 없으면 현재 시각으로 간주합니다.
- **일회용.** `jti`가 있고 고유합니다. Nodaro는 어서션 자체의 유효 기간이 지날 때까지 각 `jti`를 기억하고, 두 번째에는 거부합니다.
- **이메일.** 이메일 클레임이 있습니다.
- **인증된 이메일.** 계정을 만들거나 연결하려면 인증 여부 플래그가 `true`여야 합니다. 플래그가 없으면 인증되지 않은 것으로 간주합니다.

어서션이 규칙을 어기면 `401`과 함께 `invalid_signature`, `invalid_claims`, `expired`, `too_long_lived`, `missing_jti`, `missing_email`, `assertion_replayed` 중 하나로 응답합니다. 이 엔드포인트는 IP 주소당 60초에 20개의 요청을 받습니다. 이를 넘으면 `Retry-After` 헤더와 함께 `429 rate_limit_exceeded`로 응답합니다.

다음은 IdP 쪽 예제로, Node.js용 `jose` 라이브러리로 유효한 어서션을 발급합니다.

```ts

const secret = new TextEncoder().encode(process.env.NODARO_SSO_SECRET)

const assertion = await new SignJWT({ email: user.email, email_verified: true })
.setProtectedHeader({ alg: "HS256" })
.setSubject(user.id)
.setAudience("nodaro")
.setIssuedAt()
.setExpirationTime("2m")
.setJti(randomUUID())
.sign(secret)

const url = new URL("https://nodaro.example.com/v1/sso/acme-chat")
url.searchParams.set("assertion", assertion)
url.searchParams.set("next", "/projects")
res.redirect(url.toString())
```

## 로그인 흐름
1. 사용자가 Nodaro 로그인 페이지에서 SSO 버튼을 클릭하면, 버튼이 어서션 없이 `GET /v1/sso/:provider`를 호출합니다.
2. Nodaro가 공급자의 `initiateUrl`로 리디렉션(`302`)합니다.
3. IdP가 사용자를 인증하고 브라우저를 `GET /v1/sso/:provider?assertion=<jwt>`로 돌려보냅니다. 선택적으로 `&next=<path>`를 붙입니다.
4. Nodaro가 어서션을 검증하고, 재사용을 거부하고, [계정 연결 규칙](#how-accounts-are-linked)을 적용합니다.
5. Nodaro가 `/sso?sso_token=<one-time token>`으로 리디렉션합니다. 이 페이지가 일회용 토큰을 세션으로 교환합니다.
6. 사용자는 `/projects` 또는 `next` 경로에 도착합니다.

임베딩 호스트 같은 IdP는 3단계부터 시작할 수도 있습니다. 새 어서션을 붙여 브라우저를 교환 엔드포인트로 리디렉션하면 됩니다. 로그인 페이지 자체의 `?redirect=` 파라미터는 SSO 리디렉션을 거치며 전달되지 않습니다. 전달되는 것은 IdP가 추가한 `next`뿐입니다.

## 로그인 페이지에 SSO 버튼 표시
로그인 페이지는 다음 두 조건을 모두 충족할 때 SSO 버튼을 표시합니다.

- 배포 환경의 서피스 프로필이 `auth.methods`에 `sso`를 포함하고 `auth.ssoLabel`을 설정합니다. `ssoLabel`이 없는 프로필에서는 메서드에서 `sso`가 빠집니다. 버튼 텍스트는 `ssoLabel`입니다.
- 공급자가 하나 이상 구성되어 있습니다. 페이지는 `GET /v1/sso/providers`로 이를 확인합니다.

서피스 프로필은 Business 에디션과 Cloud 에디션의 기능입니다. [에디션과 서피스 프로필](https://nodaro.ai/docs/self-hosting/editions-and-profiles)을 참조하세요.

`auth.methods`에 `sso`**만** 나열한 프로필은 서버 측 제한도 켭니다. 로그인한 모든 계정은 SSO를 통해 만들어지거나 연결된 계정이어야 하며, 그 밖의 세션은 첫 API 호출에서 `403 sso_required`로 거부됩니다. 유일한 예외는 아래에서 설명하는 배포 환경의 결제 계정입니다. 메서드에 `email`을 추가하면 설치 전체에서 이 제한이 꺼집니다.

## 계정 연결 방식
어서션이 유효하면 Nodaro는 규칙에 따라 계정을 찾거나 만듭니다. 이 규칙은 **이메일 주소가 같을 뿐인 기존 계정을 어서션이 절대 가로챌 수 없도록** 설계되었습니다.

| 상황 | 결과 |
| --- | --- |
| 그 이메일을 가진 계정이 없고, 이메일이 인증됨 | 새 계정이 만들어지고 공급자에 연결됩니다. |
| 그 이메일을 가진 계정이 없고, 이메일이 인증되지 않음 | `403 email_unverified`로 거부됩니다. 따라서 인증되지 않은 클레임으로 실제 주소를 선점할 수 없습니다. |
| 계정이 이미 이 공급자에 연결됨 | 사용자가 로그인됩니다. |
| 계정이 다른 공급자에 연결됨 | `EXTERNAL_SSO_LINK_EXISTING`이 켜져 있어도 `403 account_linked_other_provider`로 거부됩니다. |
| SSO 없는 로컬 계정이 있고, `EXTERNAL_SSO_LINK_EXISTING`이 켜져 있으며, 이메일이 인증됨 | 계정이 공급자에 연결되고 사용자가 로그인됩니다. |
| SSO 없는 로컬 계정이 있고, 플래그가 꺼져 있거나 이메일이 인증되지 않음 | `403 account_exists`로 거부됩니다. |

`EXTERNAL_SSO_LINK_EXISTING`은 기본적으로 꺼져 있으며, 이것이 계정 탈취에 안전한 설정입니다. 대소문자와 관계없이 `true` 또는 `1`만 이 설정을 켭니다. IdP의 인증된 이메일 클레임을 이미 있는 계정에 연결해도 될 만큼 신뢰할 때만 켜세요.

`account_exists`와 `email_unverified` 거부는 모든 계정에 같은 문구를 사용하므로, 로그인 양식으로 어떤 주소가 특별한 역할을 가졌는지 알아낼 수 없습니다. `account_exists`는 주소가 둘 이상의 계정과 일치할 때, 조회가 실패할 때, 두 로그인이 같은 계정을 동시에 만들려고 경쟁할 때도 반환됩니다.

### 결제 계정이 있는 배포 환경
배포 환경은 설치의 모든 사용자 비용을 내는 **결제 계정** 하나를 지정할 수 있습니다. [외부 지갑](https://nodaro.ai/docs/developers/external-wallet)을 참조하세요. 이 계정은 배포 환경의 크레딧을 보유하므로 규칙이 더 엄격합니다.

- **처음으로 인증된 로그인을 할 때 연결됩니다.** `EXTERNAL_SSO_LINK_EXISTING` 설정과는 관계없습니다. 이 계정에 대한 인증되지 않은 어서션은 `email_unverified`로, 다른 공급자의 어서션은 `account_linked_other_provider`로 거부됩니다.
- **이후의 모든 로그인을 다시 확인합니다.** 이메일이 인증되어 있어야 하고, 서브젝트가 처음 계정을 연결한 서브젝트와 같아야 합니다. 그렇지 않으면 로그인이 `403 account_linked_other_subject`로 거부됩니다.
- **비상용 수단으로 비밀번호를 유지합니다.** IdP가 이 계정을 어서트할 수 없게 되는 날을 위한 것입니다. SSO 전용 배포 환경에서는 그 양식이 `/login?billing=1`에 있으며, 결제 계정만 들어갈 수 있습니다.
- **설치 관리자가 차단하거나 삭제할 수 없습니다**(`403 payer_account_protected`). 따라서 배포 환경 자체의 IdP가 나눠 준 관리자 역할로 설치를 크레딧에서 차단할 수 없습니다.

IdP에서 결제 계정의 서브젝트가 바뀌면, 그 계정의 SSO 로그인은 계속 `account_linked_other_subject`로 실패합니다. 이 계정은 여전히 비밀번호로 로그인할 수 있습니다.

플랫폼 운영자의 주소(`PLATFORM_OPERATOR_EMAILS`, 이 값이 비어 있으면 `PLATFORM_OWNER_EMAIL`)는 결제 계정이 있는 배포 환경에서 새로 연결되지 않습니다. 이 주소들은 `account_exists`로 거부되므로, 운영자 계정은 SSO 밖에 머뭅니다. 이미 연결된 운영자 계정은 계속 로그인할 수 있습니다.

## OIDC와 SAML 공급자
`oidc`와 `saml` 종류는 로그인을 설치 자체의 Supabase Auth에 넘기고, Supabase Auth가 IdP의 응답을 검증합니다. 둘 다 제한이 있습니다.

- **`oidc`:** 로그인 페이지는 공급자의 `id`를 OAuth 공급자 이름으로 전달합니다. 따라서 `id` 자체가 설치의 Supabase Auth에 구성된 OAuth 공급자의 이름(예: `keycloak`, `azure`)이어야 합니다.
- **`saml`:** 로그인 페이지는 공급자의 `id`를 SAML 도메인으로 전달합니다. `id`에는 점을 넣을 수 없으므로 `acme.com` 같은 도메인을 표현할 수 없고, `saml`은 처음부터 끝까지 작동하지 않습니다.
- `oidc` 또는 `saml` 공급자로 보낸 어서션은 `400 not_assertion_provider`로 거부됩니다.

## 엔드포인트
두 엔드포인트 모두 공개되어 있으며 토큰이 필요 없습니다. `/v1/sso/` 아래의 경로는 이 두 개뿐입니다.

| 메서드 | 경로 | 하는 일 |
| --- | --- | --- |
| `GET` | `/v1/sso/providers` | 로그인 페이지에 쓸 공급자 목록을 반환합니다. `id`, `label`, `kind`만 포함하며, 시크릿은 절대 포함하지 않습니다. SSO가 꺼져 있으면 빈 목록을 반환합니다. |
| `GET` | `/v1/sso/:provider` | 교환 엔드포인트입니다. JSON API가 아니라 브라우저 리디렉션 엔드포인트입니다. |

| 상태 | 코드 | 발생 시점 |
| --- | --- | --- |
| `400` | `no_assertion` | 버튼을 클릭했는데, 공급자에 `initiateUrl`이 없습니다. |
| `400` | `not_assertion_provider` | `oidc` 또는 `saml` 공급자로 어서션을 보냈습니다. |
| `401` | `invalid_signature`, `invalid_claims`, `expired`, `too_long_lived`, `missing_jti`, `missing_email`, `assertion_replayed` | 어서션이 규약의 규칙을 어겼습니다. |
| `403` | `email_unverified`, `account_exists`, `account_linked_other_provider`, `account_linked_other_subject` | 계정 연결 규칙이 로그인을 거부했습니다. |
| `403` | `sso_required` | SSO 전용 배포 환경이 SSO를 거치지 않은 세션을 거부했습니다. |
| `404` | `unknown_provider` | 그 `id`를 가진 공급자가 없거나, SSO가 꺼져 있습니다. |
| `429` | `rate_limit_exceeded` | 한 IP 주소에서 요청이 너무 많습니다. |

## 보안 참고 사항
- **전용 시크릿을 사용하세요.** `secret`은 Nodaro 전용 검증 키이며, IdP 자체의 세션 시크릿과 분리되어 있습니다.
- **어서션은 한 번만 작동합니다.** `jti` 검사가 제출된 어서션을 두 번째에는 거부하고, 수명 상한이 유효 시간을 짧게 유지합니다.
- **리디렉션은 설치 안에 머뭅니다.** `next`는 같은 설치의 상대 경로이고, `/`로 시작하되 `//`로 시작하지 않을 때만 적용됩니다. 그 밖의 경우에는 `/projects`에 도착합니다.
- **시크릿은 서버 밖으로 나가지 않습니다.** `GET /v1/sso/providers`는 `id`, `label`, `kind`만 반환합니다. 어서션과 일회용 토큰은 요청 로그에서 제거됩니다.
- **교환에는 요청 한도가 있습니다.** IP 주소별로 적용됩니다.

## 자주 묻는 질문

### Nodaro에서 SSO는 기본적으로 켜져 있나요?

아니요. EXTERNAL_SSO_PROVIDERS에 공급자를 하나 이상 지정하기 전까지는 로그인 페이지에 SSO 버튼이 표시되지 않고, GET /v1/sso/providers는 빈 목록을 반환하며, 모든 공급자 경로는 404로 응답합니다.

### SSO 어서션은 어떻게 서명해야 하나요?

HS256과, 해당 공급자용으로 구성하고 다른 용도로는 쓰지 않는 시크릿으로 서명합니다. 어서션에는 aud, exp, 고유한 jti, 이메일도 들어 있어야 하며, Nodaro는 각 어서션을 한 번만 허용합니다.

### SSO 어서션으로 이메일이 같은 기존 계정을 가로챌 수 있나요?

기본적으로는 불가능합니다. 기존 로컬 계정은 EXTERNAL_SSO_LINK_EXISTING이 true이고, 어서션이 이메일이 인증되었다고 밝힐 때만 연결됩니다. 이미 다른 공급자에 연결된 계정은 다시 연결되지 않습니다.

### SSO 사용자는 특별한 종류의 Nodaro 계정인가요?

아니요. 교환이 끝나면 사용자는 일반 세션을 갖습니다. SSO는 그 세션을 시작하는 방법일 뿐입니다.

### Nodaro는 OIDC와 SAML ID 공급자를 지원하나요?

일부 지원합니다. oidc와 saml 종류는 로그인을 설치 자체의 인증 서비스에 넘기며, 이 페이지에서 설명하는 제한이 있습니다. 처음부터 끝까지 작동하는 방식은 어서션 교환입니다.
