OpenID Connect
회원 SSO 개발 안내
LinkSpot 회원 계정을 여러 독립 웹 서비스에서 안전하게 연결하는 OpenID Connect 연동 안내입니다. 각 서비스는 별도 client와 secret, callback, 세션 저장소를 사용합니다.
설정 순서
- 1관리자에게 SSO 설정을 ready 및 enabled 상태로 준비하도록 요청합니다.
- 2서비스를 생성합니다. 새 서비스는 기본적으로 비활성 상태입니다.
- 3정확한 HTTPS callback·logout·backchannel URL을 등록하고 client ID와 secret을 발급합니다. secret은 한 번만 보이므로 즉시 비밀 저장소에 보관합니다.
- 4서비스마다 별도 환경 변수, DB, 암호화 키, 도메인으로 배포하고 공개 HTTPS를 설정합니다.
- 5redirect URI와 logout 수신 URL을 다시 확인한 뒤 관리자에서 해당 서비스만 활성화합니다.
기존 SITE_IMPORT API 키와 별도인 로그인용 client입니다. OIDC Authorization Code + PKCE(S256), state, nonce를 사용하세요. Node.js 22와 openid-client 6.8.8을 기준으로 제공 예제를 사용할 수 있습니다. 중앙 issuer는 HTTPS 공개 origin을 요구하므로 localhost callback 예외를 두지 마세요.
환경 변수에는 실제 값 대신 secret manager 참조를 저장하세요. client secret, access token, refresh token을 브라우저·URL·로그에 노출하거나 localStorage에 저장하지 마세요. 표준 중앙 로그아웃 redirect에서만 id_token_hint를 일회 전달하고, no-store·no-referrer와 URL 로그 마스킹을 적용하세요. 예제 A와 B는 각각 다른 ID·secret·port·origin·DB·session encryption key를 사용합니다.
사용자가 LinkSpot 로그인을 마치면 중앙 continue가 원래 인증 요청을 이어갑니다. 서비스는 등록된 HTTPS callback에서 반환된 authorization code만 교환합니다.
필수 보안 동작
- identity는 (issuer, sub)로 원자적으로 생성·갱신합니다. 이메일이 같다는 이유로 기존 계정에 합치지 않습니다. 이메일은 verified 여부를 확인하고, 역할은 각 서비스가 별도로 관리합니다.
- returnTo는 서비스 내부 상대 경로만 허용합니다. //, 역슬래시, 다른 origin, callback/login/logout 경로를 거부하고 서버에 5분짜리 일회용 state와 함께 저장합니다.
- 세션 cookie는 HttpOnly·Secure·SameSite=Lax·host-only로 설정합니다. refresh token은 DB에 암호화해 보관하고 회전할 때 행 잠금으로 동시 갱신을 직렬화합니다.
- 보호된 요청은 중앙 introspection 결과를 최대 60초 캐시합니다. 쓰기 요청은 매번 확인하고, 중앙 서버가 실패하면 캐시만으로 유효하다고 판단하지 않습니다.
- 서비스 비활성화·세션 철회가 확인되면 로컬 세션을 지웁니다. 서비스 계정의 비활성·삭제 상태를 로그인 성공으로 자동 복원하지 않습니다. UserInfo와 introspection의 session_expires_at(Unix 초)을 적용하고 로컬 만료는 원 세션 만료와 현재부터 30일 중 더 이른 시각으로 제한합니다.
발급자 및 endpoint
Issuer: https://linkspot.net/api/v1/sso
Discovery: https://linkspot.net/api/v1/sso/.well-known/openid-configuration
Authorize: https://linkspot.net/api/v1/sso/authorize
Token: https://linkspot.net/api/v1/sso/token
UserInfo: https://linkspot.net/api/v1/sso/userinfo
JWKS: https://linkspot.net/api/v1/sso/jwks
Introspection: https://linkspot.net/api/v1/sso/introspect
Revocation: https://linkspot.net/api/v1/sso/revoke
Logout: https://linkspot.net/api/v1/sso/logout
Default scopes: openid profile email
Authorization code: 60s
Access token: 300s
ID token: 300s
Refresh token maximum: 30 daysNode 예제와 서비스 환경 파일
npm install --save-exact openid-client@6.8.8 pg@8.16.3 jose@6.1.0
node --version # Node.js 22.env.service-a.example
# Service A (make a separate file and separate values for Service B)
NODE_ENV=production
PORT=4101
PUBLIC_ORIGIN=https://service-a.example.com
DATABASE_URL=postgres://<service-a-db-credentials>@db.example.com/service_a
SESSION_ENCRYPTION_KEY=<base64url-encoded-random-32-byte-key-from-secret-manager>
OIDC_ISSUER=https://linkspot.net/api/v1/sso
OIDC_CLIENT_ID=<service-a-client-id>
OIDC_CLIENT_SECRET=<load-from-secret-manager>
OIDC_REDIRECT_URI=https://service-a.example.com/auth/callback
OIDC_POST_LOGOUT_REDIRECT_URI=https://service-a.example.com/signed-out
OIDC_BACKCHANNEL_LOGOUT_URI=https://service-a.example.com/auth/backchannel-logout
SESSION_COOKIE_NAME=__Host-service_a_sessionNode.js service example
# Download member-sso-example.zip, then extract it on your server.
unzip member-sso-example.zip -d /srv/member-sso-example
mkdir -p /srv/service-a /srv/service-b
cp -a /srv/member-sso-example/member-sso-service/. /srv/service-a/
cp -a /srv/member-sso-example/member-sso-service/. /srv/service-b/
# Keep the two deployments, databases, cookies, and keys separate.
cp /srv/service-a/.env.service-a.example /srv/service-a/.env
cp /srv/service-b/.env.service-b.example /srv/service-b/.env
# Fill each .env from its own secret manager. Generate a base64url-encoded random 32-byte key.
# Configure both HTTPS reverse proxies and make DATABASE_URL match the corresponding database variable below.
psql "$SERVICE_A_DATABASE_URL" -v ON_ERROR_STOP=1 -f /srv/service-a/schema.sql
psql "$SERVICE_B_DATABASE_URL" -v ON_ERROR_STOP=1 -f /srv/service-b/schema.sql
# Run each service in a separate terminal.
(cd /srv/service-a && npm install && npm start)
(cd /srv/service-b && npm install && npm start)UserInfo 예시
{
"sub": "member-subject-from-issuer",
"nickname": "Example nickname",
"locale": "ko",
"email": "member@example.com",
"email_verified": true,
"session_expires_at": 1790000000
}curl · UserInfo
curl 'https://linkspot.net/api/v1/sso/userinfo' \
-H "Authorization: Bearer $ACCESS_TOKEN"로그아웃과 개인정보
- 브라우저 로그아웃은 CSRF를 확인하는 POST로 시작하고, 현재 세션의 id_token_hint 및 등록된 post_logout_redirect_uri로 중앙 로그아웃에 이동합니다. 이후 자동 로그인 loop가 발생하지 않게 로컬 세션을 먼저 정리합니다.
- backchannel logout은 브라우저와 무관하게 이 서비스의 sid에 연결된 세션만 제거합니다. 다른 기기·다른 서비스의 세션은 유지됩니다. 서명 RS256/JWKS, iss, aud, iat, exp, events, sid, jti 및 replay를 검증하고 nonce가 있으면 거부합니다.
- 중앙 계정으로 제공되는 회원 ID, 닉네임, 언어, 인증 이메일을 서비스 정책에 필요한 범위에서만 저장하세요. 비밀번호나 LinkSpot 역할은 전달되지 않습니다.
발급자는 backchannel logout JWT의 보호된 JOSE 헤더에 typ=logout+jwt을 넣습니다. 수신기는 jose jwtVerify에 typ: 'logout+jwt'을 지정하고, 값이 없거나 일반 JWT·다른 typ이면 DB 변경 전에 HTTP 400으로 거절해야 합니다. 이 typ은 backchannel logout 전용이며 ID token과 access token에는 적용하지 않습니다.
로그아웃과 개인정보
1. POST /auth/logout with the service session cookie and CSRF token.
2. Clear this service's browser cookie and invalidate its local session.
3. Redirect to https://linkspot.net/api/v1/sso/logout?id_token_hint=<current-session-id-token>&post_logout_redirect_uri=<registered-HTTPS-URL>.
4. The issuer sends a signed backchannel logout JWT to the exact registered backchannel URL with protected header typ: logout+jwt.
5. Verify it before opening a database transaction, for example: jwtVerify(token, jwks, { issuer, audience: env.OIDC_CLIENT_ID, algorithms: ['RS256'], typ: 'logout+jwt', requiredClaims: ['iss', 'aud', 'iat', 'exp', 'jti', 'sid', 'sub'] }). Reject a missing, generic, or different typ with HTTP 400.
6. Then transactionally remove sessions for this service and sid only. A second device stays signed in. This typ is for backchannel logout only; do not require it for ID or access tokens.오류 해결
- invalid_client: client ID/secret과 secret이 발급된 서비스가 맞는지 확인합니다.
- invalid_redirect_uri: callback 전체 URL의 scheme, host, path, port가 등록값과 정확히 일치해야 합니다.
- login_required: 사용자가 LinkSpot에 로그인한 뒤 서비스를 다시 시도합니다.
- invalid_grant: 만료·재사용된 code나 refresh token입니다. authorization code를 재사용하지 말고 회전된 refresh token만 저장하세요.
- 세션이 바로 종료됨: introspection 응답, 서비스 active 상태, callback/backchannel 공개 HTTPS 연결과 시간 동기화를 점검합니다.