@plott/webkit-auth-provider-keycloak0.4.0
@plott/webkit-auth-oauth 제공자 — Keycloak(OIDC SSO)과 realm 롤·그룹 클레임 매퍼
설치
pnpm add @plott/webkit-auth-provider-keycloak@0.4.0진입점(exports)
| import | 타입 | 파일 |
|---|---|---|
@plott/webkit-auth-provider-keycloak | ./dist/index.d.ts | ./dist/index.js |
@plott/webkit-auth-provider-keycloak/browser | ./dist/browser/index.d.ts | ./dist/browser/index.js |
@plott/webkit-auth-provider-keycloak/react | ./dist/react/index.d.ts | ./dist/react/index.js |
@plott/webkit-auth-oauth 용 Keycloak(OIDC SSO) 정의와 클레임 매퍼입니다. 사내 SSO 처럼 Keycloak 토큰을 그대로 세션 토큰으로 쓸 때는 providerIssuer("keycloak") 와 함께 씁니다.
import { oauth, providerIssuer } from "@plott/webkit-auth-oauth";import { keycloak } from "@plott/webkit-auth-provider-keycloak";
declare const clientId: string;declare const clientSecret: string;
export const backend = oauth({ issuer: providerIssuer("keycloak"), providers: [keycloak({ issuer: "https://sso.example.com/realms/<realm>", clientId, clientSecret, kind: "staff" })],});| 옵션 | 기본값 / 뜻 |
|---|---|
issuer | realm issuer |
clientSecret | 없으면 퍼블릭 클라이언트(Keycloak 클라이언트의 Client authentication 을 끈 경우 — PKCE 만으로 로그인) |
kind | user. 세션 주체 종류(requireSession({ kind }) 로 거른다) |
scope | openid profile email |
clientRoles | false. 켜면 이 클라이언트의 롤(resource_access[clientId].roles)도 roles 에 더합니다 |
amrFallback | []. 토큰에 amr 이 없을 때 세션에 적을 값(예: ["sso"]) |
nativeToken | false. 네이티브 SDK 토큰을 받을 종류(id_token·access_token) |
- 회사 Keycloak 에서는 서비스마다 클라이언트를 따로 씁니다.
clientId는 서비스의 저장소 이름(조직 이름 제외)이고, 다른 서비스의 클라이언트를 빌려 쓰지 않습니다. 새 클라이언트는 인증 서버 담당자만 만듭니다. 리다이렉트<origin>/auth/callback/keycloak은 등록된https://<host>/*안에 이미 들어 있습니다. - 롤은
realm_access.roles, 그룹은 Keycloak 그룹 매퍼가 넣은groups클레임에서 읽습니다(keycloakClaims). - 로그아웃은 Keycloak 세션까지 끊습니다(RP-initiated logout,
id_token_hint). - 그룹이 많으면 토큰이 커져 쿠키 조각 상한(약 11KB)을 넘을 수 있습니다. 그룹 매퍼 범위를 좁힙니다.
진입점
| import | 어디서 | export |
|---|---|---|
@plott/webkit-auth-provider-keycloak | 서버 | keycloak, keycloakClaims, keycloakPrincipal |
@plott/webkit-auth-provider-keycloak/browser | 브라우저(서버에서 불러도 안전, 아무 일도 하지 않음) | createKeycloakSpa, authorizationHeader, KeycloakConfigError |
@plott/webkit-auth-provider-keycloak/react | 클라이언트 컴포넌트("use client") | KeycloakProvider, useKeycloak, authorizationHeader |
붙이기: 서버가 받은 토큰의 주체
import { createBearerVerifier } from "@plott/webkit-auth-oauth/bearer";import { keycloakPrincipal } from "@plott/webkit-auth-provider-keycloak";
declare const issuer: string;declare const clientId: string;
export const bearer = createBearerVerifier({ issuer, authorizedParty: clientId, mapPrincipal: (claims) => keycloakPrincipal(claims, { clientId }),});위 예의 authorizedParty: clientId 는 «내 서비스의 로그인으로 받은 토큰만» 받습니다. 다른 서비스가 사람의 토큰을 들고 내 서버를 부르게 하려면 audience 에 내 서비스를 가리키는 값을 적고(호출하는 쪽 클라이언트에 내 서비스의 클라이언트 스코프가 붙어 있어야 토큰의 aud 에 들어갑니다), 호출을 허용할 클라이언트를 가려야 하면 authorizedParty 에 목록을 줍니다. match 를 aud 로 두면 azp 는 보지 않습니다(옵션 표).
keycloakPrincipal 은 { sub, email, name, roles, realmRoles, clientRoles } 를 돌려줍니다.
roles 는 realm_access.roles 와 resource_access[clientId].roles 를 합친 것이고, 출처가 필요하면 realmRoles(realm 롤)·clientRoles(이 클라이언트 롤)를 따로 읽습니다. name 은 keycloakClaims 와 같이 name → preferred_username 순서입니다.
붙이기: 브라우저 SPA
keycloak-js(^26.2.4)를 앱이 설치합니다. 서버 번들에는 들어가지 않습니다(동적 import).
lib/spa.ts 에서 모듈 하나가 한 번 만듭니다. init 은 처음 읽을 때 시작합니다.
import { createKeycloakSpa } from "@plott/webkit-auth-provider-keycloak/browser";
export const spa = createKeycloakSpa({ url: process.env.NEXT_PUBLIC_KC_URL!, realm: "r", clientId: "web" });"use client" 파일(app/providers.tsx)에서 Provider 를 두르고, 컴포넌트에서 훅을 씁니다. KeycloakProvider 는 init 이 끝날 때까지 fallback 을 그리고, init 이 실패하면 error(노드 또는 ({ error, retry }) => 노드)를 그립니다. error 를 주지 않으면 fallback 이 남습니다.
import type { ReactNode } from "react";import { KeycloakProvider, useKeycloak } from "@plott/webkit-auth-provider-keycloak/react";import { createKeycloakSpa } from "@plott/webkit-auth-provider-keycloak/browser";
declare const spa: ReturnType<typeof createKeycloakSpa>;declare function Loading(): ReactNode;
export function Providers({ children }: { children: ReactNode }) { return ( <KeycloakProvider spa={spa} fallback={<Loading />} error={({ retry }) => <button onClick={retry}>다시 시도</button>}> {children} </KeycloakProvider> );}
export function Me() { const { ready, authenticated, claims, error, retry, token, login, logout } = useKeycloak(); return <pre>{JSON.stringify({ ready, authenticated, claims, error, retry, token, login, logout })}</pre>;}useKeycloak 은 KeycloakProvider 안에서만 쓸 수 있고, 밖에서 부르면 오류를 던집니다.
API 호출에는 토큰을 헤더로 붙입니다. authorizationHeader(spa) 는 필요하면 갱신한 Bearer <토큰> 이고, 로그인 전이면 undefined 입니다.
import { authorizationHeader, type createKeycloakSpa } from "@plott/webkit-auth-provider-keycloak/browser";
declare const spa: ReturnType<typeof createKeycloakSpa>;
const auth = await authorizationHeader(spa);await fetch("/api/me", { headers: auth ? { authorization: auth } : {} });| 옵션 | 기본값 / 뜻 |
|---|---|
url · realm · clientId | (필수) 비었거나 문자열 "undefined" 면 KeycloakConfigError |
onLoad | login-required. 조용히 확인만 하려면 check-sso(+ silentCheckSsoRedirectUri) |
refreshSkewSec | 60. 만료 몇 초 전부터 새로 받나 |
refreshIntervalMs | 30000 |
refreshOnFocus | true. 창이 포커스를 되찾을 때 확인 |
onRefreshFail | login(refresh token 이 정말 끝났을 때만 한 번 로그인 화면으로) · ignore · 함수. 오프라인이거나 refresh token 이 아직 유효하면 이동하지 않고 1초부터 30초까지 늘려 가며 다시 시도하고, online·화면 복귀에서도 바로 시도합니다 |
initOptions | init 에 더 넘길 옵션(기본값을 덮어씁니다) |
keycloak | 인스턴스 공급 함수(테스트용) |
spa.onChange(cb) 는 로그인 상태나 토큰이 바뀔 때만 부릅니다. 토큰을 서버로 보내는 일이 있다면 이 콜백에서 하세요(갱신 주기마다 보내지 마세요).
기본으로 지키는 것
| 기본 | 막는 사고 |
|---|---|
| 서버(window 없음)에서는 아무것도 하지 않는 핸들 | SSR 에서 keycloak-js 가 window 를 건드려 죽는 것 |
같은 (url, realm, clientId) 는 페이지에서 init 한 번, 읽을 때까지 시작하지 않음 | StrictMode·재마운트에서 init 이 두 번 불려 keycloak-js 가 예외를 던지는 것 |
설정이 비면 KeycloakConfigError | 환경 변수가 빠졌는데 로그인 화면으로 계속 되돌아가는 무한 리다이렉트 |
checkLoginIframe: false, pkceMethod: "S256" | 모바일 Chrome 에서 로그인 확인 iframe 이 멈추거나 반복되는 것 |
| 화면이 숨겨지면 갱신 타이머 정지, 다시 보일 때 한 번 갱신 | 백그라운드 탭의 불필요한 갱신, 복귀 직후 만료된 토큰 |
| 갱신 동시 호출을 하나로 합침 | 토큰 요청이 겹쳐 서버에 중복 요청이 가는 것 |
| 갱신 실패 시 로그인은 한 번만, 세션이 정말 끝났을 때만 | 실패한 갱신이 타이머마다 로그인 이동을 반복하는 것, 와이파이 순단·절전 복귀에 입력 중이던 화면을 잃는 것 |
init 실패는 state.error + retry() (한 번에 하나) | 로드 시 네트워크 장애로 실패한 init 이 영원히 캐시되어 새로고침 말고는 복구되지 않는 것 |
같은 (url, realm, clientId) 에 다른 옵션을 주면 개발에서 던지고 운영에서 경고 | 두 번째 createKeycloakSpa 의 옵션이 조용히 무시되는 것 |
| 모든 핸들을 놓아도 init 이 진행 중이면 등록을 유지 | 인증 code 가 붙은 페이지에서 destroy → 재생성으로 init 이 두 번 돌아 code 를 이중 교환하는 것 |
토큰이 바뀐 때만 onChange | 토큰을 주기마다 서버로 보내던 것 |
그 밖
keycloak()은id: "keycloak", 스코프openid profile email,embeddedWebView: "allowed",nativeToken: false인 OIDC 제공자를 만듭니다.clientRoles: true면 이 클라이언트의 클라이언트 롤(resource_access.<clientId>.roles)도roles에 더하고, 기본은 렐름 롤만입니다.id·extraAuthParams도 받습니다.resource_access에서는 클라이언트 ID 와 같은 자기 키만 읽습니다(__proto__·constructor같은 상속 키는 믿지 않습니다).amr클레임이 없으면["sso"]입니다.KeycloakSpa의ready(init 결과 Promise),token(),claims(),login(),logout(),retry(),getState(),destroy()도 쓸 수 있습니다.destroy()는 같은 설정을 쓰는 핸들이 모두 놓였을 때 타이머·이벤트 등록을 풉니다.- 서버(window 없음)에서 만든 핸들의
ready는false로 바로 끝납니다. init 이 실패하면retry()전까지ready가 같은 거절을 돌려줍니다.
peer: @plott/webkit-auth, @plott/webkit-auth-oauth. 선택 peer: keycloak-js(/browser·/react), react(/react).