Skip to content

인증 ​

두 갈래 ​

주체방식
사람(웹 UI)glossary_session 쿠키
도구(AI-Lint, CI)Authorization: Bearer glk_<prefix>_<secret>

요청에 Authorization 헤더가 있으면 API 키 경로를, 없으면 세션 경로를 탄다. 스킴 토큰(Bearer)은 RFC 7235대로 대소문자를 구분하지 않는다 — bearer glk_...도 API 키 경로로 간다. 토큰 본문은 대소문자를 구분한다.

최초 설정 ​

사용자가 하나도 없는 상태(새 배포)에서만 열리는 창구다. 첫 관리자 계정을 만든다.

http
POST /api/v1/setup
Content-Type: application/json

{ "email": "admin@example.com", "name": "Admin", "password": "8자 이상" }

성공하면 로그인과 똑같이 Set-Cookie: glossary_session=...을 내려준다 — 만든 즉시 로그인 상태가 된다. name은 생략하면 이메일이 쓰인다.

200  생성 성공 (세션 쿠키 발급)
400  validation_failed (이메일 형식, 비밀번호 8자 미만 등)
403  forbidden — 이미 초기 설정이 끝났다

먼저 도달한 사람이 관리자다

/setup은 사용자 테이블이 비어 있을 때만 동작한다. 첫 관리자가 생기면 이후 POST /api/v1/setup은 항상 403이고, 웹의 /setup 화면은 로그인으로 리다이렉트된다. 동시 요청은 advisory lock으로 직렬화되어 관리자는 한 번만 만들어진다. 다만 설정을 끝내기 전 창구는 열려 있으므로, 배포 직후 바로 첫 관리자를 만들어야 한다.

계정 만들기 ​

로그인 화면의 "계정 만들기"가 부르는 창구다. 가입 뒤 바로 읽을 수 있고, 편집 권한은 관리자가 역할을 승격해 부여한다.

http
POST /api/v1/auth/register
Content-Type: application/json

{ "email": "kim@example.com", "name": "김개발", "password": "8자 이상" }

성공하면 로그인과 똑같이 Set-Cookie: glossary_session=...을 내려준다 — 만든 즉시 로그인 상태가 된다. name은 생략하면 이메일이 쓰이고, 이 이름이 수정 이력에 그대로 나간다.

200  생성 성공 (세션 쿠키 발급)
400  validation_failed (이메일 형식, 비밀번호 8자 미만)
403  forbidden — 계정이 하나도 없다. /setup으로 첫 관리자를 먼저 만든다
409  email_taken — 이미 가입된 이메일
  • 역할은 언제나 viewer다. 요청에 role을 실어도 무시한다. viewer는 읽기만 할 수 있고, 관리자가 사용자 관리 화면에서 editor로 승격해야 편집할 수 있다. 관리자는 최초 설정 (/setup)과 scripts/seed-admin.ts로만 생긴다.
  • 이메일은 대소문자를 구분하지 않는다. Kim@Example.com으로 가입하면 kim@example.com으로 저장되고, 로그인도 두 형태 모두 같은 계정을 찾는다 (users_email_lower_unique가 유일성을 소문자 기준으로 강제한다).
  • 로그인과 달리 계정 존재 여부를 숨기지 않는다(409). 가입 화면에서 무엇이 잘못됐는지 말해주지 않으면 사용자는 같은 이메일로 계속 다시 시도한다.

가입은 열려 있다

비밀번호 가입은 ID/비밀번호 로그인이 켜져 있는 동안 가능하며 신규 사용자는 viewer로 생성된다. 레이트 리밋은 적용하지만 이메일 도메인 제한은 없다. Google Workspace 조직 전용 사용은 Google OAuth 앱의 Internal audience 설정으로 제한한다.

에이전트 계정 만들기 ​

관리자는 사용자 관리 화면에서 Hermes, OpenCode, Codex 같은 도구용 API 전용 계정을 만들 수 있다. 이메일과 비밀번호 로그인은 사용하지 않으며, 계정 이름이 용어 변경 이력에 표시된다.

http
POST /api/v1/admin/users
Content-Type: application/json

{ "name": "Codex" }

관리자 세션으로 인증한다. 성공하면 계정과 API 키를 함께 만들고 201로 반환한다. 키의 평문은 생성 응답에서만 확인할 수 있으므로, 화면의 API 키 복사 버튼으로 에이전트 설정에 옮겨 둔다.

json
{
  "user": { "id": "…", "name": "Codex", "role": "editor", "authType": "agent" },
  "key": { "name": "Codex 에이전트 키", "scopes": ["read", "write", "validate"], "token": "glk_…" }
}

에이전트 계정은 editor 역할로 생성되며 용어 읽기·편집·검증에 필요한 read, write, validate 권한을 받는다. API 요청에는 일반 키와 같이 Authorization: Bearer glk_<prefix>_<secret> 헤더를 사용한다.

에이전트는 관리자로 승격할 수 없다. 관리자 화면의 키 폐기는 해당 에이전트의 모든 키를 폐기하고, 키 교체는 기존 키를 모두 폐기한 뒤 새 키를 한 번만 표시한다. 뷰어 에이전트의 새 키에는 read, validate만 부여한다.

http
DELETE /api/v1/admin/users/{id}/keys
POST /api/v1/admin/users/{id}/keys

두 API 모두 관리자 세션과 출처 검증을 요구한다. 폐기는 200 { revoked, key: null }, 교체는 201 { revoked, key: { id, name, prefix, scopes, token } }을 반환한다. 일반 사용자 계정의 개인 키에는 사용할 수 없다.

로그인 ​

http
POST /api/v1/auth/login
Content-Type: application/json

{ "email": "admin@example.com", "password": "..." }

관리자 패널의 로그인 방식 → ID/비밀번호 로그인 허용을 끄면 이 창구와 가입·비밀번호 최초 설정 창구는 403 password_login_disabled를 반환한다. DB 설정이 아직 없는 최초 부팅만 PASSWORD_LOGIN_ENABLED 환경변수를 초기값으로 사용한다.

내 표시 이름 변경 ​

로그인한 사용자는 SSO 계정을 포함해 자신의 표시 이름을 직접 바꿀 수 있다. 이메일과 역할은 이 창구에서 변경하지 않는다.

http
PATCH /api/v1/account
Content-Type: application/json

{ "name": "김의윤" }

성공하면 Set-Cookie: glossary_session=...을 내려준다.

200  로그인 성공
400  validation_failed
401  unauthorized

계정 탈퇴 ​

로그인한 사용자는 계정 설정에서 본인 계정을 탈퇴할 수 있다. 이메일을 확인용으로 입력하고, 비밀번호 계정은 현재 비밀번호도 다시 입력한다. SSO 계정은 로그인 세션과 이메일 확인으로 진행한다.

http
DELETE /api/v1/account
Content-Type: application/json

{ "confirmEmail": "kim@example.com", "password": "현재 비밀번호" }

탈퇴가 완료되면 모든 세션과 계정에서 발급한 API 키, 개인 대화 기록 및 계정이 삭제된다. 용어와 수정 이력은 사전 기록으로 남지만 작성자 연결은 제거된다. 이메일 주소는 다시 가입할 때 사용할 수 있으며, 새 계정은 기본 viewer 권한으로 시작한다. 마지막 관리자 계정은 탈퇴할 수 없다.

SSO 탈퇴 후에는 식별자의 SHA-256 해시를 보관해 oauth2-proxy 헤더만으로 계정이 자동 생성되지 않도록 한다. 로그인 화면에서 회사 계정으로 새 계정 만들기를 선택하거나 직접 OIDC/OAuth2 로그인에 성공하면 이 차단 기록을 제거한다. SSO 관리자 그룹·최초 관리자 정책은 재가입에도 적용된다. 프록시/IdP 자체의 세션은 앱 탈퇴로 종료되지 않으며, 프록시 인증이 남아 있어도 앱 계정은 자동 재생성되지 않는다.

200  탈퇴 완료 (현재 세션 쿠키 삭제)
400  validation_failed
401  unauthorized
403  forbidden — 확인 정보가 틀렸거나 마지막 관리자 계정
404  not_found

계정 존재 여부는 새지 않는다

계정이 없는 경우와 비밀번호가 틀린 경우를 응답으로도, 응답 시간으로도 구분하지 않는다. 다만 레이트 리밋과 계정 잠금은 아직 없다(M2). 그때까지는 사내망 접근 통제에 의존한다.

쿠키 속성은 HttpOnly; SameSite=Lax; Path=/이며 유효 기간은 14일이다. HTTPS로 판정된 요청에는 Secure가 자동으로 추가된다. 프록시 헤더 설정은 운영 안내서를 참고한다.

SSO 정보 다시 가져오기 ​

SSO로 연결된 사용자가 설정 화면에서 자신의 이름·이메일·그룹을 IdP 값으로 다시 맞출 때 사용한다. oauth2-proxy에서는 현재 요청 헤더를 즉시 반영하고, 직접 OIDC/OAuth2 연결에서는 redirectTo로 받은 인증 시작 주소로 이동해야 한다.

http
POST /api/v1/account/sso-refresh

직접 연결에서 재인증이 끝나면 /settings?ssoRefresh=success로 돌아온다. 재동기화 중 다른 회사 계정을 선택하면 identity_mismatch로 막고 어느 계정도 덮어쓰지 않는다.

로그아웃 ​

http
POST /api/v1/auth/logout

GET이 아니라 POST다. 상태 변경 요청은 Origin 또는 Referer가 허용된 출처와 일치해야 하며, SameSite=Lax 쿠키도 함께 사용한다. 상태를 바꾸는 GET은 만들지 않는다.

외부 계정 로그인 (OpenID Connect / OAuth 2.0) ​

Google 계정 로그인 또는 회사 SSO 경로다. Google 로그인은 제품 화면에서 별도 방식으로 표시하며, 두 로그인 모두 OIDC/OAuth 2.0 흐름을 공유한다. 설정과 claim 매핑 설명은 회사 SSO 연결에 있고, 여기서는 창구만 적는다.

브라우저가 오가는 두 자리는 /api/v1 바깥이다. 화면 이동으로만 답하는 자리라 JSON 에러 봉투를 쓸 수 없다 — 이 저장소의 "모든 에러는 JSON, 예외 없음" 규약을 깨지 않으려고 API 밖에 두었다.

경로하는 일
GET /auth/sso/startPKCE·state(및 OIDC nonce)를 만들어 흐름 쿠키에 담고 인증 서버로 302
GET /auth/sso/callback코드를 토큰으로 바꾸고 OIDC 검증 또는 OAuth userinfo 조회 후 세션 발급

둘 다 응답은 302뿐이다. 실패하면 /login?sso=<코드>로 돌아온다 (disabled state idp token no_subject no_email not_allowed no_accountemail_conflict identity_mismatch server — 모르는 코드는 일반 문구로 뭉갠다. 쿼리스트링은 누구나 만들 수 있어서, 그대로 보여주면 로그인 화면이 임의 문구를 띄우는 창구가 된다).

SSO 설정 ​

관리자 세션만 쓸 수 있다. Authorization 키로는 부를 수 없다(역할이 없는 자격이라 관리자 판정을 할 수 없다).

http
GET /api/v1/sso
json
{
  "sso": {
    "mode": "oidc",
    "enabled": true,
    "passwordLoginEnabled": false,
    "protocol": "oidc",
    "buttonLabel": "회사 계정으로 로그인",
    "issuer": "https://login.example.com/realms/company",
    "jwksUri": "https://login.example.com/realms/company/protocol/openid-connect/certs",
    "authorizationEndpoint": "…", "tokenEndpoint": "…", "userinfoEndpoint": "…",
    "clientId": "glossary",
    "hasClientSecret": true,
    "nameClaims": ["name", "displayName", "preferred_username"],
    "groupClaims": ["groups", "roles"],
    "allowedGroups": [], "adminGroups": ["Glossary-Admins"],
    "autoCreate": true,
    "lastClaimKeys": ["email", "groups", "name", "sub"],
    "lastLoginAt": "2026-08-29T02:11:03.000Z"
  },
  "redirectUri": "https://glossary.example.com/auth/sso/callback"
}
  • mode가 실제 로그인 방식이며 disabled, oidc, oauth2, oauth2-proxy 중 하나다. enabled와 protocol은 이전 API 클라이언트 호환을 위해 함께 반환한다.
  • clientSecret은 어떤 응답에도 실리지 않는다. 채워져 있는지만(hasClientSecret) 알려준다.
  • redirectUri는 IdP에 등록할 주소다. 인가·토큰 요청에 실제로 실리는 값과 같은 함수가 만든다 — 한 글자만 달라도 IdP가 거절한다.
  • lastClaimKeys는 마지막 SSO 로그인에서 IdP가 보낸 claim 이름이다. 값은 남기지 않는다. 매핑이 틀려 실패했을 때도 갱신된다.
http
PUT /api/v1/sso
Content-Type: application/json

{ "mode": "oauth2", "protocol": "oauth2", "userinfoEndpoint": "https://login.example.com/userinfo", "nameClaims": ["displayName", "name"], "clientSecret": "" }

부분 갱신이다. 보낸 필드만 바뀐다.

200  저장됨 (GET과 같은 형태로 돌려준다)
400  validation_failed — 값 형식이 틀렸거나, 켜는 데 필요한 값이 빠졌다(details.problems)
401  unauthorized
403  forbidden — 관리자만
  • clientSecret: ""은 "그대로 두기", null은 "지우기"다. 화면이 저장된 시크릿을 되받지 못해 언제나 빈 칸으로 열리는데, 그 빈 칸을 반영하면 다른 항목 하나 고칠 때마다 SSO가 조용히 꺼진다.
  • 서버가 채우는 값(lastClaimKeys, lastLoginAt, updatedBy)은 본문으로 받지 않는다. 보내면 400이다.
  • Issuer/JWKS/엔드포인트/외부 주소는 http(s)만 받는다. 서버가 이 중 일부 주소로 직접 요청하기 때문이다.
  • mode: "oauth2-proxy"는 배포 환경의 OAUTH2_PROXY_ENABLED=true가 확인될 때만 저장된다. 환경변수는 capability만 열고 활성 방식은 바꾸지 않는다.
  • passwordLoginEnabled는 ID/비밀번호 로그인과 새 계정 가입 허용 여부다.
  • mode: "disabled"는 passwordLoginEnabled: true일 때만 저장된다. 모든 로그인 경로를 동시에 닫는 설정은 거절한다.
http
POST /api/v1/sso/discover
Content-Type: application/json

{ "issuer": "https://login.example.com/realms/company", "protocol": "oidc" }

OIDC는 <issuer>/.well-known/openid-configuration, OAuth 2.0은 <issuer>/.well-known/oauth-authorization-server를 읽어 엔드포인트, JWKS URI와 claims_supported를 돌려준다. 저장하지는 않는다.

200  { "discovery": { "issuer", "authorizationEndpoint", "tokenEndpoint", "userinfoEndpoint", "jwksUri", "scopesSupported", "claimsSupported" } }
400  validation_failed — 그 주소에서 설정을 읽지 못했다
403  forbidden — 관리자만

관리자 전용인 이유

서버가 임의의 주소로 요청을 보내는 창구(SSRF)다. 로그인한 편집자 누구나 부를 수 있으면 사내망 스캐너가 된다.

oauth2-proxy 헤더 확인 ​

http
GET /api/v1/sso/proxy-check

관리자의 현재 요청에 실제 도착한 헤더를 읽어 ssoMode, proxyAvailable, trusted, detected, 사용한 헤더명과 복원된 사용자 정보를 반환한다. 저장된 샘플 값을 검사하는 API가 아니다. oauth2-proxy가 실제 활성 모드이면 관리자 자신도 이메일 헤더로 식별되어야 한다. OIDC/OAuth2 모드에서는 capability가 켜져 있어도 이 헤더를 인증에 사용하지 않는다. 구성과 보안 전제는 SSO 연결을 따른다.

API 키 ​

키는 glk_<prefix>_<secret> 형태다. prefix는 8자리 hex 고정폭이라 secret에 _가 들어가도 파싱이 어긋나지 않는다.

저장되는 것은 해시뿐이다. 평문 토큰은 발급 응답에서만 볼 수 있고 이후로는 복구할 방법이 없다. 검증은 timingSafeEqual로 한다 — 문자열 !==는 첫 불일치 바이트에서 조기 반환해 타이밍으로 해시를 조금씩 흘릴 수 있다.

scope ​

scope허용
readGET /terms, GET /terms/{idOrSlug}, GET .../revisions, POST /terms/lookup, GET /terms/suggest, GET /candidates
writePOST /terms, PATCH /terms/{idOrSlug}, POST /import, POST /candidates/{id}/dismiss, POST /candidates/{id}/promote
validatePOST /validate, POST /validate/batch

요구 scope가 없으면 403 forbidden이다.

키에는 역할 개념이 없다. DELETE /terms/{idOrSlug}는 admin 역할이 필요하므로 API 키로는 절대 호출할 수 없다.

키가 쓰일 때마다 last_used_at이 갱신된다. revoked_at이 찍혔거나 expires_at이 지난 키는 조회 단계에서 아예 걸러진다.

키 목록 ​

세션 로그인 상태여야 한다. 비밀값은 돌려주지 않으며, 목록에는 현재 로그인한 사용자가 발급한 키만 나온다. 다른 사용자의 키를 조회하거나 폐기할 수 없다.

http
GET /api/v1/keys
json
{
  "keys": [
    {
      "id": "…", "name": "ai-lint-ci", "prefix": "3f9a1c07",
      "scopes": ["read", "validate"],
      "createdAt": "2026-08-20T02:11:03.000Z",
      "lastUsedAt": "2026-08-28T01:40:12.000Z",
      "revokedAt": null
    }
  ]
}

키 발급 ​

http
POST /api/v1/keys
Content-Type: application/json

{ "name": "ai-lint-ci", "scopes": ["read", "validate"] }
json
{
  "key": { "id": "…", "name": "ai-lint-ci", "prefix": "3f9a1c07", "scopes": ["read", "validate"] },
  "token": "glk_3f9a1c07_…"
}

token은 이 응답에서만 나온다. 화면(/settings/api-keys)도 발급 직후 한 번만 보여준다.

키 폐기 ​

http
DELETE /api/v1/keys/{id}

204로 끝난다. 없는 id면 404.

사용 예 ​

bash
KEY="glk_3f9a1c07_…"

curl -s -H "Authorization: Bearer $KEY" \
  "http://localhost:3000/api/v1/terms?q=exposure&status=active"

curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"texts":["AE","이미지센서","AutoExposure"]}' \
  http://localhost:3000/api/v1/terms/lookup

사내망 온프레미스 배포를 전제로 만든 Apache-2.0 프로젝트입니다.