@plott/webkit-auth-oauth0.4.0
@plott/webkit-auth 발급자 연동 — OAuth 2.0/OIDC 클라이언트(PKCE)와 Authorization Server 토큰 교환(RFC 8693)·인증 코드 그랜트·갱신·폐기
설치
pnpm add @plott/webkit-auth-oauth@0.4.0진입점(exports)
| import | 타입 | 파일 |
|---|---|---|
@plott/webkit-auth-oauth | ./dist/index.d.ts | ./dist/index.js |
@plott/webkit-auth-oauth/bearer | ./dist/bearer.d.ts | ./dist/bearer.js |
@plott/webkit-auth 의 AuthBackend 구현입니다. OAuth 2.0/OIDC 클라이언트(Authorization Code + PKCE)로 제공자(IdP)에 로그인하고, 발급자에 따라 토큰을 세션으로 만듭니다. 서버에서 받은 Bearer 토큰을 검증하는 /bearer 진입점도 있습니다.
모든 로그인 수단(브라우저 로그인, 인증 코드, 매직 링크, 비밀번호, 네이티브 소셜 토큰, WebView 핸드오프)은 «토큰 응답 → 세션» 하나로 모입니다. 업스트림 호출은 언제나 Authorization: Bearer <세션 액세스 토큰> 하나입니다. redirect_uri·state·트랜잭션 쿠키·동시 갱신 합치기는 코어(@plott/webkit-auth)가 하고, 이 패키지는 다시 만들지 않습니다.
진입점
| import | 어디서 | 주요 export |
|---|---|---|
@plott/webkit-auth-oauth | 서버(createAuth 설정) | oauth, authorizationServer, providerIssuer, oidcProvider, oauth2Provider, OAuthBackend, authMethodOf, 표준 상수(GRANT_TOKEN_EXCHANGE·TOKEN_TYPE_ACCESS·TOKEN_TYPE_ID), 타입 OAuthHandle |
@plott/webkit-auth-oauth/bearer | 서버 전용(API 라우트·미들웨어·백엔드) | createBearerVerifier, BearerError, 타입 BearerOptions·BearerPrincipal·BearerVerifier·BearerInput·BearerFailure |
붙이기
서비스의 Authorization Server(AS)가 세션 토큰을 줍니다. 소셜 토큰·인증 코드·WebView 토큰을 모두 AS 토큰으로 바꿉니다.
import { authorizationServer, oauth, type OAuthProvider } from "@plott/webkit-auth-oauth";
declare const issuer: string;declare const tokenEndpoint: string;declare const revocationEndpoint: string;declare const verificationEndpoint: string;declare const verificationGrantType: string;declare const clientId: string;declare const clientSecret: string;declare const providers: OAuthProvider[];
export const backend = oauth({ issuer: authorizationServer({ issuer, tokenEndpoint, revocationEndpoint, verificationEndpoint, verificationGrantType, clientId, clientSecret }), providers,});IdP 가 준 토큰을 그대로 세션 토큰으로 쓰려면(사내 SSO) 제공자가 발급자입니다.
import { oauth, providerIssuer } from "@plott/webkit-auth-oauth";import { keycloak } from "@plott/webkit-auth-provider-keycloak";
declare const clientId: string;
export const backend = oauth({ issuer: providerIssuer("keycloak"), providers: [keycloak({ issuer: "https://sso.example.com/realms/<realm>", clientId })],});oauth() 는 발급자가 할 수 있는 기능만 싣습니다. 교환(exchange)은 AuthBackend 에, 인증 코드는 verification 포트(verificationEndpoint 가 있을 때), 비밀번호는 password 포트(passwordEndpoint 가 있을 때)에 실립니다. 앱은 defaultMethods(backend) 로 포트가 있는 수단만 켭니다. 없는 기능은 라우트(404)와 proxy 단계가 꺼집니다.
oauth(opts) 의 defaultExpiresIn(기본 300)은 토큰 응답에 expires_in 이 없을 때 쓰는 수명(초)입니다. 양의 유한한 숫자가 아니면 시작할 때 멈춥니다.
- 교환(
exchange): 우리 AS 가 발급자일 때만 - 인증 코드 확인·발송(
verification포트의verificationComplete·verificationStart):verificationEndpoint가 있을 때 - 비밀번호(
password포트의passwordLogin):passwordEndpoint가 있을 때(비밀번호 로그인은 인증 코드 그랜트로 마칩니다)
발급자
| 함수 | 켜지는 기능 |
|---|---|
authorizationServer(opts) | 토큰 교환(RFC 8693: 네이티브 소셜 토큰·WebView 핸드오프), 인증 코드, 비밀번호 인증, 다중 주체(merge_token), 폐기. AS 쪽 계약은 AS 계약 |
providerIssuer(id, { principal? }) | 그 제공자의 갱신·RP 로그아웃(end_session) |
authorizationServer 옵션:
| 항목 | 기본값 | 뜻 |
|---|---|---|
id | "as" | 세션 쿠키에 적는 발급자 ID |
issuer | (필수) | AS 의 issuer 식별자. 메타데이터를 읽지 않고 아래 엔드포인트를 직접 씁니다 |
tokenEndpoint | (필수) | 토큰 엔드포인트 |
revocationEndpoint | 없음 | 리프레시 토큰 폐기. 없으면 로그아웃이 폐기를 건너뜁니다 |
verificationEndpoint | 없음 | 인증 코드 발송(이메일·SMS·메신저·TOTP). 없으면 인증 코드 라우트가 꺼집니다 |
passwordEndpoint | 없음 | 비밀번호 확인. 없으면 비밀번호 로그인 라우트가 꺼집니다 |
clientId | (필수) | |
clientSecret | 없음 | 없으면 퍼블릭 클라이언트: 토큰 엔드포인트(token_endpoint_auth_method=none)에 client_id 만 보내고, 발송·비밀번호 엔드포인트에는 본문의 client_id 로 자기를 밝힙니다. 있으면 clientAuth 방식으로 자기를 밝힙니다. AS 가 그 클라이언트에 허용한 그랜트만 쓸 수 있습니다 |
clientAuth | basic | 시크릿이 있을 때의 방식. basic 은 Authorization: Basic(RFC 6749 §2.3.1), post 는 본문의 client_id·client_secret |
verificationGrantType | 없음 | 인증 코드를 토큰으로 바꾸는 확장 그랜트 이름(AS 가 정한 값). verificationEndpoint 나 passwordEndpoint 를 쓰면 필수이고, 없으면 시작할 때 멈춥니다 |
clientIpHeader | 없음 | 사용자 IP 를 AS 에 넘기는 헤더 이름(발송 횟수 제한용). 적으면 인증 코드 완료 그랜트도 같은 헤더로 보냅니다. 적지 않으면 IP 를 보내지 않습니다 |
principal | 표준 클레임 + principal_type 클레임을 주체 종류로 | 클레임 → 화면 요약 |
제공자
정해진 IdP 는 @plott/webkit-auth-provider-* 패키지를 씁니다. 목록에 없는 IdP 는 직접 정의합니다.
import { oauth2Provider, oidcProvider } from "@plott/webkit-auth-oauth";
declare const clientId: string;declare const clientSecret: string;
export const line = oidcProvider({ id: "line", issuer: "https://access.line.me", clientId, clientSecret, scope: "openid profile" });OAuthProvider 항목 | 뜻 |
|---|---|
id | 제공자 ID. 목록 안에서 겹치면 만들 때 오류 |
kind | oidc(디스커버리 + id_token 검증) 또는 oauth2(엔드포인트를 직접 적는다 — oauth2Provider 가 정합니다) |
issuer | oidc 의 issuer. /.well-known/openid-configuration 을 읽습니다. oidc 에서 비면 만들 때 오류 |
authorizationEndpoint·tokenEndpoint·endSessionEndpoint | oauth2 의 엔드포인트. oauth2 에서 앞 둘이 비면 만들 때 오류 |
clientId · scope | |
clientSecret | 문자열 또는 함수(Apple 처럼 주기적으로 새로 만드는 값 — 호출마다 부르니 안에서 캐시합니다). 없으면 퍼블릭 클라이언트: 시크릿 없이 client_id + PKCE |
clientAuth | basic(시크릿이 있을 때 기본) · post(Kakao·Meta·Apple) · none(퍼블릭 클라이언트 — 시크릿이 있어도 보내지 않습니다). 시크릿이 없으면 언제나 none |
responseMode | form_post 면 IdP 가 콜백을 크로스사이트 POST 로 보냅니다(Apple 이 name·email 을 요청하면 강제). 코어가 같은 사이트 POST 로 한 번 더 보내 쿠키가 실리게 합니다 |
extraAuthParams · popupAuthParams | 인가 요청에 더 붙일 파라미터(팝업일 때는 popupAuthParams 도). state·PKCE·nonce·redirect_uri 는 덮어쓰지 못합니다 |
exchangeToken | 웹 로그인 뒤 AS 로 교환할 때 내미는 토큰. 기본: oidc 면 id_token, oauth2 면 access_token |
nativeToken | 네이티브 SDK 토큰을 /exchange 로 받는가(받는다면 기본 토큰 종류). false 면 받지 않습니다. 우리 AS 가 교환할 때만 알립니다 |
embeddedWebView | 앱 내 WebView 에서 웹 로그인 화면을 막는 IdP(Google·Meta)면 blocked. 기본 allowed |
principal | 이 제공자가 세션 토큰의 발급자일 때(providerIssuer) 클레임 → 화면 요약 |
백엔드 옵션
| 항목 | 기본값 | 뜻 |
|---|---|---|
issuer | (필수) | authorizationServer(...) 또는 providerIssuer(...) |
providers | (필수) | 제공자 목록 |
timeoutSeconds | 10 | 디스커버리를 포함한 모든 IdP·AS 호출의 제한 시간(초). proxy 가 기다리는 호출이라 짧게 둡니다 |
allowInsecureRequests | false | http IdP/AS 허용. 로컬 목 서버 전용 |
받은 Bearer 토큰 검증(/bearer)
내 서버가 Authorization: Bearer <JWT> 를 받아 «이 토큰이 우리 발급자가 이 API 용으로 준 것인가»를 확인합니다. 서버 전용입니다.
import { createBearerVerifier } from "@plott/webkit-auth-oauth/bearer";
const bearer = createBearerVerifier({ issuer: "https://sso.example.com/realms/<realm>", authorizedParty: "my-api",});
export async function GET(req: Request) { const who = await bearer.verify(req); if (!who) return new Response(null, { status: 401 }); return Response.json({ sub: who.sub });}verify 는 토큰 문자열·Authorization 헤더 값·Headers·headers 를 가진 요청을 받아 주체 또는 null 을 돌려줍니다. verifyOrThrow 는 거절 때 BearerError(코어 AuthError, 상태 401, 코드 invalid_token)를 던지며, error.reason 에 이유 코드(missing·malformed·algorithm·type·invalid_signature·expired·not_yet_valid·issuer·audience·authorized_party·scope·email_unverified·email_domain·claims·jwks_unavailable)가 있고 토큰은 어디에도 없습니다.
| 옵션 | 기본값 / 뜻 |
|---|---|
issuer | (필수) 토큰 iss 와 글자 그대로 같아야 합니다(끝 슬래시 포함). 디스커버리 주소의 바탕이기도 합니다 |
authorizedParty | 받을 azp(토큰을 받아 간 클라이언트). 문자열 또는 목록 |
audience | 받을 aud. 하나라도 들어 있으면 통과. 문자열 또는 목록 |
match | 둘 다 주면 both(둘 다 맞아야 통과) · authorizedParty 만 있으면 azp · 아니면 aud. azp·aud·either(둘 중 하나)는 직접 적을 때만 씁니다(azp 는 audience 를 보지 않습니다). match 에 맞는 값이 없으면 만들 때 오류 |
rejectIdTokens | true. 페이로드 typ: "ID" 이거나 nonce·at_hash 가 있는 토큰(ID 토큰)을 거절 |
requireAccessTokenType | false. 켜면 헤더 typ 가 at+jwt(RFC 9068)인 토큰만 받습니다 |
failureCacheMs | 5000. 디스커버리·JWKS 가 실패하면 이 시간 동안은 다시 받지 않고 바로 jwks_unavailable 로 거절. 0 이면 끔 |
algorithms | ["RS256"]. none·HS* 는 넣을 수 없습니다 |
allowedTypes | ["JWT", "at+jwt"]. 헤더 typ 가 없거나 목록에 없으면 거절(대소문자·application/ 접두 무시) |
clockToleranceSec | 30. exp·nbf 시계 오차(초) |
jwksUri | 디스커버리(<issuer>/.well-known/openid-configuration)의 jwks_uri. 한 번 받아 기억합니다 |
jwksCooldownMs | 30000. 모르는 kid 때문에 JWKS 를 다시 받는 최소 간격. 키 교체 직후 새 키로 서명한 토큰은 마지막 재요청 뒤 이 시간이 지나기 전까지 모르는 kid 로 거절될 수 있습니다. 키를 자주 돌리는 발급자면 줄이고, 가짜 kid 로 JWKS 서버를 두들기는 것을 막으려면 늘립니다 |
timeoutMs | 5000. 디스커버리·JWKS 요청 제한 시간 |
requiredScopes | 없음. scope(공백 구분) 또는 scp 에 모두 있어야 함 |
requireEmailVerified | false(allowedEmailDomains 가 있으면 true). 켜면 email_verified === true 필요. 도메인 목록과 false 를 같이 주면 만들 때 오류 |
allowedEmailDomains | 없음. 주면 email_verified === true 이고, 이메일이 있고 @ 가 하나이며 도메인이 목록에 있어야 합니다(대소문자 무시) |
mapPrincipal(claims) | 기본은 { sub, email, emailVerified, name, scopes, claims }. null·예외는 거절. Keycloak 롤은 keycloakPrincipal(provider-keycloak) |
jwks | 테스트용으로 키를 직접 넣습니다({ keys } 또는 jose 의 키 조회 함수) |
기본으로 지키는 것
| 기본 | 막는 사고 |
|---|---|
PKCE S256 은 언제나, nonce 는 oidc 만 | 코드 가로채기, id_token 재생 |
제공자의 추가 인가 파라미터가 state·PKCE·nonce·redirect_uri 를 덮어쓰지 못함 | 설정 한 줄로 PKCE 가 plain 으로 내려가거나 redirect_uri 가 바뀌는 것 |
콜백의 redirect_uri 는 들어온 요청 URL 이 아니라 시작할 때 만든 값 | 프록시 뒤 내부 주소 때문에 코드 교환이 어긋나는 것 |
| 제공자 정의를 시작할 때 검사(겹치는 id, 빠진 issuer·엔드포인트, 발급자 제공자 없음) | 첫 로그인에서야 터지는 설정 오류 |
설정과 IdP 응답이 어긋나 교환할 토큰이 없으면 AS 를 부르지 않고 exchange_failed | AS 에 문자열 "undefined" 를 보내는 것 |
주체(sub)를 읽지 못한 토큰으로는 세션을 만들지 않음(exchange_failed, 502). 갱신 응답에 주체가 안 보이면 같은 세션의 주체 요약을 잇고, 합치기 대상의 주체는 빌리지 않음 | 모든 사용자가 한 주체로 섞이는 것 |
expires_in 이 없는 응답의 수명은 300초 | 죽은 토큰을 오래 싣는 것 |
갱신 응답에 없는 refresh_token·id_token·scope 는 이전 값을 씀 | 회전하지 않는 발급자에서 리프레시 토큰을 잃는 것(RFC 6749 §6) |
갱신 실패 중 invalid_grant 만 AuthRequiredError(세션의 끝). 5xx·연결 실패·클라이언트 설정 오류는 일반 오류 | 발급자 장애로 사용자가 로그아웃되는 것 |
| 폐기(revoke) 실패는 경고만 남기고 던지지 않음 | 로그아웃이 폐기 실패로 막히는 것 |
merge_token 은 같은 발급자의 세션일 때만 | 다른 발급자의 토큰을 AS 에 내미는 것 |
토큰 엔드포인트 오류 본문의 error_code 가 우리 코드(AuthErrorCode)일 때만 그 코드로 옮김. 발급자 5xx·연결 실패는 502, 그 밖은 400 | AS 의 임의 문자열이 브라우저 오류 코드가 되는 것 |
시크릿이 없으면 clientAuth: "post" 여도 none | 빈 client_secret 을 거절하는 IdP(Kakao)에서 로그인이 실패하는 것 |
발송·완료 요청에 사용자 IP 헤더(clientIpHeader)를 실음 | 모든 사용자가 Next 서버 IP 하나로 보여 한 사람이 모두를 잠그거나 제한이 무의미해지는 것 |
연결 실패·시간 초과는 502 exchange_failed | 일시 장애가 입력 오류(400)로 보여 사용자가 입력을 의심하는 것 |
발송 응답은 계약 필드(verificationId·expiresIn·maskedTarget·resendAfter)만, 오류는 코드(소문자 snake_case 64자)만 브라우저로 | AS 의 설명·내부 정보가 새는 것 |
verificationId 없는 200 은 502 | 프록시 오류 화면을 성공으로 보고 코드 입력 단계로 넘어가는 것 |
발송 엔드포인트의 401·403 은 502(exchange_failed) | 이 클라이언트의 설정 오류(invalid_client)를 «로그인 필요»로 읽는 것 |
비밀번호 확인 실패는 아이디/비밀번호 어느 쪽인지 구분하지 않음(invalid_credentials). 429 는 too_many_attempts. 5xx·invalid_client 는 502 | 계정 탐색, 설정 오류가 «비밀번호가 틀렸다»로 보여 사용자가 계속 다시 치는 것 |
비밀번호 확인 응답은 계약의 두 모양(verification_id+code / verificationId+channel)만 인정 | 모르는 응답을 성공으로 처리하는 것 |
모든 IdP·AS 호출에 제한 시간(timeoutSeconds, 기본 10초)을 걸고, 디스커버리 실패는 기억하지 않아 다음 로그인이 다시 시도 | proxy 가 30초 기본값을 기다리는 것, 한 번의 IdP 장애가 영구 실패가 되는 것 |
Bearer 검증: 모든 오류는 거절(null/BearerError) | 검증 도중 예외가 «통과»로 새는 것 |
Bearer 검증: authorizedParty/audience 없이는 만들 수 없음 | 발급자만 검사해 같은 realm 의 다른 클라이언트 토큰이 모두 통과하는 것 |
Bearer 검증: RS256 고정, none·HS* 설정 거부 | 알고리즘을 토큰이 정하게 두어 none 이나 «공개 키를 HMAC 비밀로 쓴» 위조 토큰이 통과하는 것 |
Bearer 검증: 헤더 typ 확인, ID 토큰 거절 | ID 토큰이나 다른 용도의 JWT 를 액세스 토큰으로 받는 것 |
Bearer 검증: 발급자 글자 그대로 비교, 디스커버리 문서의 issuer 도 대조 | 비슷한 주소의 다른 발급자·바꿔치기된 디스커버리 |
Bearer 검증: exp·sub 필수, nbf 확인 | 만료 없는 토큰·주체 없는 토큰 |
Bearer 검증: 모르는 kid 에만 JWKS 재요청, 쿨다운 | 키 교체 때 전부 거절되는 것과 가짜 kid 로 JWKS 서버를 두들기는 것 |
Bearer 검증: 디스커버리·JWKS 실패는 failureCacheMs 동안만 기억 | 한 번의 장애가 프로세스가 죽을 때까지 모든 요청을 막는 것, 장애 중 토큰마다 요청하는 것 |
Bearer 검증: 토큰·원인을 로그·메시지에 싣지 않음(console 미사용) | 로그로 토큰이 새는 것 |
Bearer 검증: 숫자 옵션(clockToleranceSec 등)은 만들 때 검사 | NaN 이 JWKS 장애로 오진되어 원인을 못 찾는 것 |
Bearer 검증: 헤더는 Bearer + 공백 하나 + 토큰만(스킴 없는 JWT 는 Authorization 에서 거절), 16KB 상한 | Basic·빈 토큰·공백 변형·거대 입력 |
그 밖
의존성: openid-client, jose. peer: @plott/webkit-auth. 각 항목의 동작은 test/ 의 양성·음성 테스트가 기록합니다.