서비스 간 인증
이 문서는 Backstage에서 서비스 간 인증이 어떻게 작동하는지 설명해요. Backstage 백엔드 플러그인 사이와, 그들에게 요청을 하고 싶어 하는 외부 호출자 모두에 대해 설명해요. 이는 다른 흐름을 사용하는 사용자 및 사용자-서비스 인증과는 대조적이에요.
출처: 문서
본문
이 문서는 Backstage에서 서비스 간 인증이 어떻게 작동하는지 설명해요. Backstage 백엔드 플러그인 사이와, 그들에게 요청을 하고 싶어 하는 외부 호출자 모두에 대해 설명해요. 이는 다른 흐름을 사용하는 사용자 및 사용자-서비스 인증과는 대조적이에요.
각 섹션은 구별되는 한 가지 유형의 인증 흐름을 설명해요.
표준 플러그인 간 인증
백엔드 시스템을 사용하고 auth 및 httpAuth 서비스 API로 자격 증명을 처리하는 Backstage 플러그인은 구성을 요구하지 않고 기본적으로 안전해요. 다른 Backstage 백엔드 플러그인에 요청하기 위해 자체 서명 토큰을 자동으로 생성하고, 수신자는 호출자의 공개 키 집합 엔드포인트를 사용해 검증을 수행해요.
다른 백엔드 플러그인에 요청하려는 백엔드 플러그인은 다음과 같이 필요한 토큰을 획득하며, 여기서 auth와 httpAuth는 각각 coreServices.auth와 coreServices.httpAuth에서 주입된다고 가정해요:
const credentials = await httpAuth.credentials(req);const { token } = await auth.getPluginRequestToken({ onBehalfOf: credentials, targetPluginId: '<plugin-id>', // e.g. 'catalog'});
이 예시에서는 Express 요청 핸들러에 있다고 가정하고, req에서 호출자 자격 증명(일반적으로 사용자 또는 서비스)을 추출해 그 주체를 대신해(on-behalf-of) 업스트림 요청을 해요. 들어오는 자격 증명 집합이 있을 때마다 이 패턴을 사용하는 것을 선호하세요.
누구도 대신하지 않고 요청을 전적으로 자신의 서비스로 시작하려면 다음과 같이 할 수 있어요:
const { token } = await auth.getPluginRequestToken({ onBehalfOf: await auth.getOwnServiceCredentials(), targetPluginId: '<plugin-id>', // e.g. 'catalog'});
호출자는 토큰을 요청과 함께 Authorization 헤더에 그대로 전달해요:
Authorization: Bearer ***
때로는 다른 시스템에 대한 클라이언트 같은 일부 코드가 토큰 대신 credentials 인자를 직접 받는 것을 볼 수도 있어요. 그런 경우 토큰을 만드는 대신 위에서 획득한 자격 증명을 그대로 전달하세요. 클라이언트 코드는 내부적으로 그 자격 증명으로 무엇을 할지 알 거예요.
이 흐름에는 app-config에 설정할 구성 옵션이 하나뿐이에요: backend.auth.dangerouslyDisableDefaultAuthPolicy이며, 어떤 이유로 백엔드 플러그인 간 토큰의 발급과 검증을 모두 완전히 비활성화해야 한다면 true로 설정할 수 있어요. 이렇게 하면 백엔드가 안전하지 않게 되어 누구나 인증 없이 호출할 수 있게 되므로, VPN 같은 보안 인그레스 뒤에 배포된 경우에만 최후의 수단으로 사용하세요.
외부 호출자는 이 흐름을 이용할 수 없어요. 이것은 다른 백엔드 플러그인을 호출하는 백엔드 플러그인에서만 내부적으로 사용돼요.
플러그인 간 인증을 위한 정적 키
읽기 전용 데이터베이스 복제본에서 워커 노드를 실행하는 것 같은 일부 특수한 상황에서는 표준 데이터베이스 기반 공개 키 체계를 선택하지 않을 수 있어요. 대안으로 토큰 서명과 검증에 사용되는 정적 키를 구성에 넣을 수 있어요.
openssl 명령줄 유틸리티로 키를 만들 수 있어요.
- 먼저 ES256 알고리즘을 사용해 개인 키를 생성하세요:
openssl ecparam -name prime256v1 -genkey -out private.ec.key
- PKCS#8 형식으로 변환하세요:
openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in private.ec.key -out private.key
- 공개 키를 추출하세요:
openssl ec -inform PEM -outform PEM -pubout -in private.key -out public.key
이제 private.key와 public.key 파일이 생겼어요. 절대 경로를 아는 곳에 두고 app-config를 그에 따라 설정하세요:
backend: auth: # This is the new section for configuring plugin-to-plugin key storage pluginKeyStore: type: static static: keys: - publicKeyFile: /absolute/path/to/public.key privateKeyFile: /absolute/path/to/private.key keyId: some-custom-id
모든 노드가 같은 키 집합을 가진 이 동일한 구성을 가지면 데이터베이스에 접촉하지 않고도 서로 성공적으로 통신할 수 있을 거예요.
keys 값이 배열이라는 것을 알 수 있을 텐데, 이는 키 회전에 유용해요. 첫 번째 항목이 항상 서명에 사용되지만, 이후 항목도 토큰 검증에는 사용돼요. 이렇게 하면 새 키 쌍을 최상위 항목으로 삽입하고 이전 항목을 그대로 두어 이전 최상위 항목이 서명한 토큰이 수신자에게 여전히 수용되는 기간을 가질 수 있어요. 다만 이전 개인 키는 제거할 수 있는데, 사용되지 않을 것이기 때문이에요.
정적 토큰
이 접근 방법은 Backstage 백엔드 플러그인에 요청하고 싶어 하는 외부 호출자에게 나눠줄 수 있는 임의의 정적 토큰으로 구성돼요. 이는 명령줄 스크립트, 웹훅 등과 같은 가장 기본적인 호출자에게 유용해요.
이 접근 방법은 backend.auth.externalAccess app-config 키에 static 타입의 항목을 하나 이상 추가해 구성해요:
in e.g. app-config.production.yaml
backend: auth: externalAccess: - type: static options: token: ${CICD_TOKEN} subject: cicd-system-completion-events # Restrictions are optional; see below accessRestrictions: - plugin: events - type: static options: token: ${ADMIN_CURL_TOKEN} subject: admin-curl-access
토큰은 공백 없는 문자열이면 되지만, 보안상 무차별 대입으로 추측하기 어렵도록 충분히 길어야 해요. 예를 들어 명령줄에서 생성할 수 있어요:
node -p 'require("crypto").randomBytes(24).toString("base64")'
subject는 공백 없는 문자열이어야 해요. 각 호출자를 식별하는 데 사용되며, 요청 수신 플러그인이 받는 자격 증명 객체의 일부가 돼요.
호출자는 Backstage 플러그인을 호출할 때 토큰을 요청과 함께 Authorization 헤더에 그대로 전달해야 해요:
Authorization: Bearer eZv5o+...nmMW
JWKS 토큰 인증
이 접근 방법은 구성된 JSON Web Key Sets(JWKS)를 사용한 외부 호출자 토큰 인증을 허용해요. Auth0 같은 타사 도구로 Backstage 인스턴스에 인증하는 호출자에게 유용해요.
이 접근 방법은 backend.auth.externalAccess app-config 키에 jwks 타입의 항목을 하나 이상 추가해 구성할 수 있어요:
in e.g. app-config.production.yaml
backend: auth: externalAccess: - type: jwks options: url: https://example.com/.well-known/jwks.json issuer: https://example.com algorithm: RS256 audience: example, other-example subjectPrefix: custom-prefix - type: jwks options: url: https://another-example.com/.well-known/jwks.json issuer: https://example.com
URL은 인증 없이 JWKS를 반환하는 엔드포인트를 가리켜야 해요.
issuer는 인증 앱이 받아들일 JWT의 발급자를 지정해요. 전달된 JWT는 지정된 발급자 중 하나와 일치하는 iss 클레임을 가져야 해요.
algorithm은 JWT 검증에 사용되는 알고리즘을 지정해요. 전달된 JWT는 나열된 알고리즘 중 하나로 서명되었어야 해요.
audience는 JWT의 의도된 대상(audience)을 지정해요. 전달된 JWT는 지정된 대상 중 하나와 일치하는 "aud" 클레임을 가지거나 대상이 지정되지 않아야 해요.
JWKS 구성에 대한 추가 세부 정보는 인증 제공자의 문서를 참고하세요.
토큰 검증에서 반환된 subject는 요청 수신 플러그인이 받는 자격 증명 객체의 일부가 돼요. 모든 subject는 external: 접두사를 가지며, 또한 JWKS 서비스가 반환한 subject 앞에 추가될 커스텀 subjectPrefix를 제공할 수도 있어요 (예: external:custom-prefix:sub).
호출자는 Backstage 플러그인을 호출할 때 토큰을 요청과 함께 Authorization 헤더에 전달해야 해요:
Authorization: Bearer ***
접근 제한
각 externalAccess 항목은 선택적으로 accessRestrictions 키를 가질 수 있으며, 이는 그 특정 접근 방법이 무엇을 할 수 있는지 제한해요. 예시를 살펴보아요:
in e.g. app-config.production.yaml
backend: auth: externalAccess: - type: static options: token: ${CICD_TOKEN} subject: cicd-system-completion-events accessRestrictions: - plugin: events
이 짧은 예시에는 항목이 하나뿐이에요. CICD 토큰으로 접근하려는 누군가가 events 백엔드 플러그인을 제외한 무엇이든 접촉하려 하면 거부된다는 뜻이에요. 더 많은 플러그인을 대상으로 하는 항목을 배열에 추가할 수도 있어요.
참고
accessRestrictions를 추가하지 않으면 접근 방법은 모든 플러그인의 모든 기능에 무제한 접근 권한을 가집니다. 위험을 줄이기 위해 가능할 때마다 접근 제한을 지정하려고 노력하는 것이 좋습니다.
각 항목에는 다음 필드 중 하나 이상이 있어요:
plugin: 필수. 문자열로 된 플러그인 ID, 예:'catalog'. 이 플러그인에 요청을 할 수 있는 접근을 허용해요. 아래와 같이 추가 필드를 설정해 더 세분화할 수 있어요.
예시:
accessRestrictions: # access to any other plugin will be rejected - plugin: my-plugin
permission: 선택. 권한 이름의 컬렉션(쉼표/공백 구분 문자열 또는 문자열 배열). 주어진 경우 이 접근 방법은 위에서 주어진 ID를 가진 플러그인에서 이 이름의 권한으로만 작업을 수행하도록 제한돼요.
이것은 권한 검사가 실제로 활성화된 곳에만 적용된다는 점에 유의하세요. 권한 시스템으로 전혀 보호되지 않는 엔드포인트는 이 설정의 영향을 받지 않아요.
예시:
accessRestrictions: - plugin: my-plugin # Any other permission check will be rejected. permission: - my-plugin.add-item - my-plugin.remove-item # Also supports the shorthand form: # permission: my-plugin.add-item, my-plugin.remove-item
permissionAttribute: 선택. 각 값이 허용된 그러한 값의 컬렉션(쉼표/공백 구분 문자열 또는 문자열 배열)인 권한 속성의 key-value 객체. 주어진 경우 이 접근 방법은 권한이 이러한 속성을 가진 작업만 수행하도록 제한돼요.
이것은 권한 검사가 실제로 활성화된 곳에만 적용된다는 점에 유의하세요. 권한 시스템으로 전혀 보호되지 않는 엔드포인트는 이 설정의 영향을 받지 않아요.
실제로는 'create', 'read', 'update', 'delete' 값에 대해 action 속성으로 제한하는 데 주로 사용돼요.
예시:
accessRestrictions: - plugin: my-plugin permissionAttribute: # Updates and deletes will be rejected. action: - create - read # Also supports the shorthand form: # action: create, read
토큰 검증 및 발급을 위한 커스텀 또는 로직 추가
pluginTokenHandlerDecoratorServiceRef와 externalTokenHandlersServiceRef를 사용해 전체 AuthService 구현을 다시 구현하지 않고도 기존 토큰 핸들러를 확장할 수 있어요. 이는 로깅, 메트릭, 커스텀 토큰 검증 같은 추가 로직을 핸들러에 추가하고 싶을 때 특히 유용해요.
PluginTokenHandler 데코레이션
pluginTokenHandlerDecoratorServiceRef를 사용해 플러그인의 토큰 생성 및 검증에 사용되는 기본 PluginTokenHandler를 데코레이션할 수 있어요.
PluginTokenHandler 인터페이스에는 두 가지 메서드가 있어요:
-
issueToken: 이 메서드는 플러그인의 토큰을 발급하는 데 사용돼요.pluginId와targetPluginId를 인자로 받고, 다른 사용자를 대신해 토큰을 발급하는 데 사용할 수 있는 선택적limitedUserToken객체도 받아요. 이 메서드는 발급된 토큰을 포함한 객체로 resolve되는 promise를 반환해요. -
verifyToken: 이 메서드는 토큰을 검증하는 데 사용돼요. 토큰을 인자로 받고 토큰의 subject와 선택적 limited user token을 포함한 객체로 resolve되는 promise를 반환해요.
import { PluginTokenHandler, pluginTokenHandlerDecoratorServiceRef,} from '@backstage/backend-defaults/auth';import { createServiceFactory } from '@backstage/backend-plugin-api';const decoratedPluginTokenHandler = createServiceFactory({ service: pluginTokenHandlerDecoratorServiceRef, deps: {}, async factory() { return (defaultImplementation: PluginTokenHandler) => new CustomTokenHandler(defaultImplementation); },});
커스텀 ExternalTokenHandler 추가
externalTokenHandlersServiceRef를 사용해 기본 구현에 커스텀 외부 토큰 핸들러를 추가할 수 있어요.
서비스 팩토리는 구성의 토큰 타입(예: 'custom', 'api-key')과 일치하는 type 속성을 가진 객체를 반환해야 해요. Backstage가 이 타입의 토큰을 만나면 이 타입과 일치하는 모든 구성 항목과 함께 initialize 메서드를 호출해요. 팩토리는 이러한 토큰을 처리하고 검증하기 위해 단일 토큰 핸들러 또는 핸들러 배열을 반환할 수 있어요.
참고
토큰 검증 중에는 모든 토큰 핸들러가 테스트됩니다. 핸들러를 많이 추가할 때는 성능에 영향을 줄 수 있으므로 이를 고려하세요.
예를 들어 custom 타입에 대한 커스텀 외부 토큰 핸들러를 추가하려면:
구성은 다음과 같을 거예요:
in e.g. app-config.production.yaml
backend: auth: externalAccess: - type: custom options: customOptions: additional-value accessRestrictions: - plugin: events - type: custom options: customOptions: another-value accessRestrictions: - plugin: events
그리고 커스텀 토큰 핸들러를 이렇게 구현할 수 있어요:
import { ExternalTokenHandler, externalTokenHandlersServiceRef, createExternalTokenHandler,} from '@backstage/backend-defaults/auth';import { createServiceFactory } from '@backstage/backend-plugin-api';const customExternalTokenHandlers = createServiceFactory({ service: externalTokenHandlersServiceRef, deps: {}, async factory() { return createExternalTokenHandler({ type: 'custom', initialize({ options }) { // Initialize your handler context from config const customOptions = options.getString('customOptions'); return { customOptions }; }, async verifyToken(token, context) { // Your custom token validation logic here // Return undefined if token is invalid // Return { subject: 'your-subject' } if token is valid if (token === 'valid-token') { return { subject: `custom:${context.customOptions}` }; } return undefined; }, }); },});
createExternalTokenHandler 헬퍼는 새 API로 외부 토큰 핸들러를 만드는 것을 간소화해요:
-
type: 구성과 일치하는 토큰 핸들러 타입의 문자열 식별자 -
initialize: 이 타입의 각 구성 항목에 대해 한 번씩 호출되며, 구성 옵션을 받고verifyToken에 전달될 컨텍스트 객체를 반환해요 -
verifyToken: 각 토큰 검증에 대해 토큰과 컨텍스트와 함께 호출되며, 유효하면 subject를, 그렇지 않으면undefined를 반환해요
// Example of a more complex handler with external API callconst apiTokenHandler = createExternalTokenHandler({ type: 'api-validation', initialize({ options }) { const apiBaseUrl = options.getString('apiBaseUrl'); const apiKey = options.getString('apiKey'); return { apiBaseUrl, apiKey }; }, async verifyToken(token, { apiBaseUrl, apiKey }) { try { const response = await fetch(`${apiBaseUrl}/validate-token`, { method: 'POST', headers: { Authorization: *** ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ token }), }); if (response.ok) { const { userId } = await response.json(); return { subject: `api:${userId}` }; } } catch (error) { // Log error but don't throw - return undefined for invalid tokens } return undefined; },});