OAuth 2.0 リソースサーバーマルチテナンシー

JWT と Opaque トークンの両方をサポート

場合によっては、両方の種類のトークンにアクセスする必要があります。例: 1 つのテナントが JWT を発行し、他のテナントが Opaque トークンを発行する複数のテナントをサポートできます。

リクエスト時にこの決定を行う必要がある場合は、AuthenticationManagerResolver を使用して次のように実現できます。

  • Java

  • Kotlin

@Bean
AuthenticationManagerResolver<HttpServletRequest> tokenAuthenticationManagerResolver
        (JwtDecoder jwtDecoder, OpaqueTokenIntrospector opaqueTokenIntrospector) {
    AuthenticationManager jwt = new ProviderManager(new JwtAuthenticationProvider(jwtDecoder));
    AuthenticationManager opaqueToken = new ProviderManager(
            new OpaqueTokenAuthenticationProvider(opaqueTokenIntrospector));
    return (request) -> useJwt(request) ? jwt : opaqueToken;
}
@Bean
fun tokenAuthenticationManagerResolver
        (jwtDecoder: JwtDecoder, opaqueTokenIntrospector: OpaqueTokenIntrospector):
        AuthenticationManagerResolver<HttpServletRequest> {
    val jwt = ProviderManager(JwtAuthenticationProvider(jwtDecoder))
    val opaqueToken = ProviderManager(OpaqueTokenAuthenticationProvider(opaqueTokenIntrospector));

    return AuthenticationManagerResolver { request ->
        if (useJwt(request)) {
            jwt
        } else {
            opaqueToken
        }
    }
}
useJwt(HttpServletRequest) の実装は、パスなどのカスタムリクエストマテリアルに依存する可能性があります。

そして、DSL でこの AuthenticationManagerResolver を指定します。

認証マネージャーリゾルバー
  • Java

  • Kotlin

  • XML

http
    .authorizeHttpRequests(authorize -> authorize
        .anyRequest().authenticated()
    )
    .oauth2ResourceServer(oauth2 -> oauth2
        .authenticationManagerResolver(this.tokenAuthenticationManagerResolver)
    );
http {
    authorizeRequests {
        authorize(anyRequest, authenticated)
    }
    oauth2ResourceServer {
        authenticationManagerResolver = tokenAuthenticationManagerResolver()
    }
}
<http>
    <oauth2-resource-server authentication-manager-resolver-ref="tokenAuthenticationManagerResolver"/>
</http>

マルチテナンシー

リソースサーバーは、何らかのテナント ID をキーとするベアラートークンを検証するための複数の戦略がある場合、マルチテナントと見なされます。

例: リソースサーバーは、2 つの異なる認可サーバーからベアラートークンを受け入れる場合があります。または、認可サーバーが複数の発行者を表している場合があります。

いずれの場合も、実行する必要がある 2 つの事柄と、それらの選択方法に関連するトレードオフがあります。

  1. テナントを解決する

  2. テナントを伝播する

クレームによるテナントの解決

テナントを区別する 1 つの方法は、発行者の主張によるものです。発行者の主張には署名された JWT が伴うため、これは次のように JwtIssuerAuthenticationManagerResolver で実行できます。

JWT クレームによるマルチテナントテナント
  • Java

  • Kotlin

  • XML

JwtIssuerAuthenticationManagerResolver authenticationManagerResolver = JwtIssuerAuthenticationManagerResolver
    .fromTrustedIssuers("https://idp.example.org/issuerOne", "https://idp.example.org/issuerTwo");

http
    .authorizeHttpRequests(authorize -> authorize
        .anyRequest().authenticated()
    )
    .oauth2ResourceServer(oauth2 -> oauth2
        .authenticationManagerResolver(authenticationManagerResolver)
    );
val customAuthenticationManagerResolver = JwtIssuerAuthenticationManagerResolver
    .fromTrustedIssuers("https://idp.example.org/issuerOne", "https://idp.example.org/issuerTwo")
http {
    authorizeRequests {
        authorize(anyRequest, authenticated)
    }
    oauth2ResourceServer {
        authenticationManagerResolver = customAuthenticationManagerResolver
    }
}
<http>
    <oauth2-resource-server authentication-manager-resolver-ref="authenticationManagerResolver"/>
</http>

<bean id="authenticationManagerResolver"
        class="org.springframework.security.oauth2.server.resource.authentication.JwtIssuerAuthenticationManagerResolver">
    <constructor-arg>
        <list>
            <value>https://idp.example.org/issuerOne</value>
            <value>https://idp.example.org/issuerTwo</value>
        </list>
    </constructor-arg>
</bean>

発行者エンドポイントが遅延ロードされるため、これは素晴らしいことです。実際、対応する JwtAuthenticationProvider は、対応する発行者との最初のリクエストが送信されたときにのみインスタンス化されます。これにより、アプリケーションが起動し、使用可能になっている認可サーバーとは無関係に起動できるようになります。

ダイナミックテナント

もちろん、新しいテナントが追加されるたびにアプリケーションを再起動したくない場合があります。この場合、AuthenticationManager インスタンスのリポジトリを使用して JwtIssuerAuthenticationManagerResolver を構成できます。これは、次のように実行時に編集できます。

  • Java

  • Kotlin

private void addManager(Map<String, AuthenticationManager> authenticationManagers, String issuer) {
	JwtAuthenticationProvider authenticationProvider = new JwtAuthenticationProvider
	        (JwtDecoders.fromIssuerLocation(issuer));
	authenticationManagers.put(issuer, authenticationProvider::authenticate);
}

// ...

JwtIssuerAuthenticationManagerResolver authenticationManagerResolver =
        new JwtIssuerAuthenticationManagerResolver(authenticationManagers::get);

http
    .authorizeHttpRequests(authorize -> authorize
        .anyRequest().authenticated()
    )
    .oauth2ResourceServer(oauth2 -> oauth2
        .authenticationManagerResolver(authenticationManagerResolver)
    );
private fun addManager(authenticationManagers: MutableMap<String, AuthenticationManager>, issuer: String) {
    val authenticationProvider = JwtAuthenticationProvider(JwtDecoders.fromIssuerLocation(issuer))
    authenticationManagers[issuer] = AuthenticationManager {
        authentication: Authentication? -> authenticationProvider.authenticate(authentication)
    }
}

// ...

val customAuthenticationManagerResolver: JwtIssuerAuthenticationManagerResolver =
    JwtIssuerAuthenticationManagerResolver(authenticationManagers::get)
http {
    authorizeRequests {
        authorize(anyRequest, authenticated)
    }
    oauth2ResourceServer {
        authenticationManagerResolver = customAuthenticationManagerResolver
    }
}

この場合、発行者から AuthenticationManager を取得する方法で JwtIssuerAuthenticationManagerResolver を作成します。このアプローチにより、実行時にリポジトリ(スニペットで Map として表示)に要素を追加および削除できます。

発行者を単純に取り、それから AuthenticationManager を構築するのは危険です。発行者は、許可された発行者のリストのように、コードが信頼できるソースから検証できるものである必要があります。

クレームの解析は 1 回のみ

この戦略は単純ですが、JWT が AuthenticationManagerResolver によって一度解析され、その後リクエストの後半で JwtDecoder によって再度解析されるというトレードオフを伴うことに気付いたかもしれません。

この余分な解析は、Nimbus の JWTClaimsSetAwareJWSKeySelector を使用して JwtDecoder を直接構成することで軽減できます。

  • Java

  • Kotlin

@Component
public class TenantJWSKeySelector
    implements JWTClaimsSetAwareJWSKeySelector<SecurityContext> {

	private final TenantRepository tenants; (1)
	private final Map<String, JWSKeySelector<SecurityContext>> selectors = new ConcurrentHashMap<>(); (2)

	public TenantJWSKeySelector(TenantRepository tenants) {
		this.tenants = tenants;
	}

	@Override
	public List<? extends Key> selectKeys(JWSHeader jwsHeader, JWTClaimsSet jwtClaimsSet, SecurityContext securityContext)
			throws KeySourceException {
		return this.selectors.computeIfAbsent(toTenant(jwtClaimsSet), this::fromTenant)
				.selectJWSKeys(jwsHeader, securityContext);
	}

	private String toTenant(JWTClaimsSet claimSet) {
		return (String) claimSet.getClaim("iss");
	}

	private JWSKeySelector<SecurityContext> fromTenant(String tenant) {
		return Optional.ofNullable(this.tenants.findById(tenant)) (3)
		        .map(t -> t.getAttrbute("jwks_uri"))
				.map(this::fromUri)
				.orElseThrow(() -> new IllegalArgumentException("unknown tenant"));
	}

	private JWSKeySelector<SecurityContext> fromUri(String uri) {
		try {
			return JWSAlgorithmFamilyJWSKeySelector.fromJWKSetURL(new URL(uri)); (4)
		} catch (Exception ex) {
			throw new IllegalArgumentException(ex);
		}
	}
}
@Component
class TenantJWSKeySelector(tenants: TenantRepository) : JWTClaimsSetAwareJWSKeySelector<SecurityContext> {
    private val tenants: TenantRepository (1)
    private val selectors: MutableMap<String, JWSKeySelector<SecurityContext>> = ConcurrentHashMap() (2)

    init {
        this.tenants = tenants
    }

    fun selectKeys(jwsHeader: JWSHeader?, jwtClaimsSet: JWTClaimsSet, securityContext: SecurityContext): List<Key?> {
        return selectors.computeIfAbsent(toTenant(jwtClaimsSet)) { tenant: String -> fromTenant(tenant) }
                .selectJWSKeys(jwsHeader, securityContext)
    }

    private fun toTenant(claimSet: JWTClaimsSet): String {
        return claimSet.getClaim("iss") as String
    }

    private fun fromTenant(tenant: String): JWSKeySelector<SecurityContext> {
        return Optional.ofNullable(this.tenants.findById(tenant)) (3)
                .map { t -> t.getAttrbute("jwks_uri") }
                .map { uri: String -> fromUri(uri) }
                .orElseThrow { IllegalArgumentException("unknown tenant") }
    }

    private fun fromUri(uri: String): JWSKeySelector<SecurityContext?> {
        return try {
            JWSAlgorithmFamilyJWSKeySelector.fromJWKSetURL(URL(uri)) (4)
        } catch (ex: Exception) {
            throw IllegalArgumentException(ex)
        }
    }
}
1 テナント情報の仮想ソース
2 テナント識別子でキー設定された `JWKKeySelector` のキャッシュ
3 テナントを検索する方が、JWK セットのエンドポイントをオンザフライで単純に計算するよりも安全です - 検索は、許可されたテナントのリストとして機能します
4JWK Set エンドポイントから返されるキーの種類を介して JWSKeySelector を作成します。ここでの遅延検索は、起動時にすべてのテナントを構成する必要がないことを意味します

上記のキーセレクターは、多くのキーセレクターの構成です。JWT の iss クレームに基づいて、使用するキーセレクターを選択します。

この方法を使用するには、トークンの署名の一部としてクレームセットを含めるように認可サーバーが設定されていることを確認してください。これがなければ、発行者が悪意のある人物によって改ざんされていないという保証がありません。

次に、JWTProcessor を作成できます。

  • Java

  • Kotlin

@Bean
JWTProcessor jwtProcessor(JWTClaimsSetAwareJWSKeySelector keySelector) {
	ConfigurableJWTProcessor<SecurityContext> jwtProcessor =
            new DefaultJWTProcessor();
	jwtProcessor.setJWTClaimSetJWSKeySelector(keySelector);
	return jwtProcessor;
}
@Bean
fun jwtProcessor(keySelector: JWTClaimsSetAwareJWSKeySelector<SecurityContext>): JWTProcessor<SecurityContext> {
    val jwtProcessor = DefaultJWTProcessor<SecurityContext>()
    jwtProcessor.jwtClaimsSetAwareJWSKeySelector = keySelector
    return jwtProcessor
}

すでに見たように、テナント認識をこのレベルに下げるためのトレードオフは、より多くの構成です。もう少しあります。

次に、発行者を検証していることを確認します。ただし、発行者は JWT ごとに異なる可能性があるため、テナント対応バリデーターも必要になります。

  • Java

  • Kotlin

@Component
public class TenantJwtIssuerValidator implements OAuth2TokenValidator<Jwt> {
    private final TenantRepository tenants;

    private final OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, "The iss claim is not valid",
            "https://tools.ietf.org/html/rfc6750#section-3.1");

    public TenantJwtIssuerValidator(TenantRepository tenants) {
        this.tenants = tenants;
    }

    @Override
    public OAuth2TokenValidatorResult validate(Jwt token) {
        if(this.tenants.findById(token.getIssuer()) != null) {
            return OAuth2TokenValidatorResult.success();
        }
        return OAuth2TokenValidatorResult.failure(this.error);
    }
}
@Component
class TenantJwtIssuerValidator(private val tenants: TenantRepository) : OAuth2TokenValidator<Jwt> {
    private val error: OAuth2Error = OAuth2Error(OAuth2ErrorCodes.INVALID_TOKEN, "The iss claim is not valid",
            "https://tools.ietf.org/html/rfc6750#section-3.1")

    override fun validate(token: Jwt): OAuth2TokenValidatorResult {
        return if (tenants.findById(token.issuer) != null)
            OAuth2TokenValidatorResult.success() else OAuth2TokenValidatorResult.failure(error)
    }
}

これで、テナント対応プロセッサーとテナント対応バリデーターができたため、JwtDecoder の作成に進むことができます。

  • Java

  • Kotlin

@Bean
JwtDecoder jwtDecoder(JWTProcessor jwtProcessor, OAuth2TokenValidator<Jwt> jwtValidator) {
	NimbusJwtDecoder decoder = new NimbusJwtDecoder(jwtProcessor);
	OAuth2TokenValidator<Jwt> validator = new DelegatingOAuth2TokenValidator<>
			(JwtValidators.createDefault(), jwtValidator);
	decoder.setJwtValidator(validator);
	return decoder;
}
@Bean
fun jwtDecoder(jwtProcessor: JWTProcessor<SecurityContext>?, jwtValidator: OAuth2TokenValidator<Jwt>?): JwtDecoder {
    val decoder = NimbusJwtDecoder(jwtProcessor)
    val validator: OAuth2TokenValidator<Jwt> = DelegatingOAuth2TokenValidator(JwtValidators.createDefault(), jwtValidator)
    decoder.setJwtValidator(validator)
    return decoder
}

テナントの解決についてお話ししました。

JWT クレーム以外のメソッドでテナントを解決することを選択した場合は、同じメソッドでダウンストリームリソースサーバーに対処する必要があります。例: サブドメインで解決する場合、同じサブドメインを使用してダウンストリームリソースサーバーをアドレス指定する必要がある場合があります。

ただし、ベアラートークンの要求によって解決する場合は、Spring Security のベアラートークン伝播のサポートについて学習してください。