feat(auth): add Feishu as a public login provider

Adds Feishu (Lark) as a public sign-in option: it authenticates a
SkillHub platform account and nothing more. No Organization membership,
no directory sync, no Namespace grants.

Feishu deviates from standard OAuth in two ways this handles:
its userinfo response is wrapped in a {code, msg, data} envelope, and it
reports errors with HTTP 200. FeishuOAuth2UserService unwraps that
envelope into flat attributes; FeishuClaimsExtractor maps them to the
shared OAuthClaims, so account decisions still run through the unified
identity core added in R1-A.

Subject and email semantics, which decide whether a login can reach an
existing account:

- open_id is the only subject. union_id stays in extra rather than
  acting as a fallback: a subject that can change between logins would
  split one person across two platform accounts. Promoting union_id
  later needs an explicit alias migration.
- A blank or missing open_id fails the login instead of binding the
  literal string "null".
- emailVerified is always false. Feishu emails are imported by an
  organization admin and never confirmed with the user, so they carry no
  verification signal and cannot be used to join an existing account.

Operational bounds: the userinfo call has connect and read timeouts so an
unresponsive Feishu endpoint cannot hold a login thread, and the
OAuth2Error description carries only the provider error code, because an
upstream message can quote the request URI and with it the access token.
Like the GitHub and GitLab extractors, the claims extractor logs nothing.

The login button follows the existing config-driven catalog: with no
client id configured, /api/v1/auth/methods does not list Feishu and no
button renders. No frontend code change is needed; the icon resolves by
provider name.

Adapted from the implementation in #696 by @yhd4711499, re-extracted onto
current main with the subject, logging and timeout changes above.

Part of R1-A2 (public Provider adapters) per
openspec/changes/enterprise-identity-platform/rollout-plan.md.

Co-authored-by: yhd4711499 <yhd4711499@users.noreply.github.com>
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-09-18 15:12:48 +08:00
parent 6e6cf19e00
commit f29ac241fc
7 changed files with 528 additions and 0 deletions

View file

@ -117,6 +117,17 @@ OAUTH2_GITLAB_CLIENT_SECRET=
OAUTH2_GITLAB_BASE_URI=https://gitlab.com
OAUTH2_GITLAB_DISPLAY_NAME=GitLab
# Optional: Feishu (Lark) login as a public sign-in provider. Leaving the client id empty keeps
# the button off the login page. Grant contact:user.base:readonly and
# contact:user.email:readonly on the Feishu open-platform app itself; scopes are not sent here.
# Set OAUTH2_FEISHU_AUTHORIZE_URI/OAUTH2_FEISHU_BASE_URI to the Lark endpoints for
# international tenants (open.larksuite.com).
OAUTH2_FEISHU_CLIENT_ID=
OAUTH2_FEISHU_CLIENT_SECRET=
OAUTH2_FEISHU_AUTHORIZE_URI=https://accounts.feishu.cn
OAUTH2_FEISHU_BASE_URI=https://open.feishu.cn
OAUTH2_FEISHU_DISPLAY_NAME=飞书
# Optional: OIDC login (e.g. Keycloak, Okta, Azure AD).
# Replace "OIDC" in variable names with your registration id (uppercase).
# The registration id becomes identity_binding.provider_code — keep it stable.

View file

@ -70,6 +70,15 @@ spring:
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
client-name: ${OAUTH2_GITLAB_DISPLAY_NAME:GitLab}
feishu:
client-id: ${OAUTH2_FEISHU_CLIENT_ID:placeholder}
client-secret: ${OAUTH2_FEISHU_CLIENT_SECRET:placeholder}
# Feishu scopes are configured on the open platform app itself
# (contact:user.base:readonly, contact:user.email:readonly).
authorization-grant-type: authorization_code
client-authentication-method: client_secret_post
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
client-name: ${OAUTH2_FEISHU_DISPLAY_NAME:飞书}
provider:
github:
api-base-url: ${OAUTH2_GITHUB_API_BASE_URL:https://api.github.com}
@ -79,6 +88,11 @@ spring:
token-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/oauth/token
user-info-uri: ${OAUTH2_GITLAB_BASE_URI:https://gitlab.com}/api/v4/user
user-name-attribute: username
feishu:
authorization-uri: ${OAUTH2_FEISHU_AUTHORIZE_URI:https://accounts.feishu.cn}/open-apis/authen/v1/authorize
token-uri: ${OAUTH2_FEISHU_BASE_URI:https://open.feishu.cn}/open-apis/authen/v2/oauth/token
user-info-uri: ${OAUTH2_FEISHU_BASE_URI:https://open.feishu.cn}/open-apis/authen/v1/user_info
user-name-attribute: open_id
servlet:
multipart:
max-file-size: 100MB

View file

@ -0,0 +1,71 @@
package com.iflytek.skillhub.auth.oauth;
import java.util.Map;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component;
/**
* Provider-specific claims extractor for Feishu (Lark) OAuth users. Attributes are already
* unwrapped from the Feishu response envelope by {@link FeishuOAuth2UserService}.
*
* <p>Like the GitHub and GitLab extractors, this class logs nothing: the subject, display name
* and email it handles are exactly the values that must stay out of the logs.
*/
@Component
public class FeishuClaimsExtractor implements OAuthClaimsExtractor {
@Override
public String getProvider() {
return FeishuOAuth2UserService.PROVIDER;
}
@Override
public OAuthClaims extract(OAuth2UserRequest request, OAuth2User oAuth2User) {
Map<String, Object> attrs = oAuth2User.getAttributes();
// open_id is the stable primary subject: unique per user within one Feishu app, and it is
// what Feishu guarantees to keep across logins. union_id stays in extra rather than acting
// as a fallback -- a subject that can silently change identity between logins would bind
// the same person to two platform accounts. Promoting union_id later needs an explicit
// alias migration, not a fallback here.
String subject = requireText(attrs.get("open_id"), "open_id");
String email = (String) attrs.get("enterprise_email");
if (email == null) {
email = (String) attrs.get("email");
}
// Feishu emails are imported by the organization admin and not verified with the user
// in real time, so they carry no verification signal; keep emailVerified false.
boolean emailVerified = false;
String username = (String) attrs.get("name");
if (username == null || username.isBlank()) {
username = (String) attrs.get("en_name");
}
if (username == null || username.isBlank()) {
username = "feishu-" + subject;
}
return new OAuthClaims(
FeishuOAuth2UserService.PROVIDER,
subject,
email,
emailVerified,
username,
attrs
);
}
private static String requireText(Object value, String attribute) {
String text = value == null ? null : String.valueOf(value).trim();
if (text == null || text.isEmpty()) {
throw new OAuth2AuthenticationException(
new OAuth2Error("missing_subject", "Feishu user info is missing " + attribute, null)
);
}
return text;
}
}

View file

@ -0,0 +1,149 @@
package com.iflytek.skillhub.auth.oauth;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.Duration;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
/**
* Loads Feishu (Lark) user info, which deviates from the standard OAuth format: the response is
* wrapped in a {@code {code, msg, data}} envelope and errors are reported with HTTP 200.
*/
@Component
public class FeishuOAuth2UserService implements ProviderOAuth2UserService {
static final String PROVIDER = "feishu";
private final RestClient restClient;
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5);
private static final Duration READ_TIMEOUT = Duration.ofSeconds(10);
/**
* Uses an external-service client that is intentionally not customized with application
* tracing. Trace context must not be propagated to the external Feishu service.
*/
@Autowired
public FeishuOAuth2UserService() {
this(RestClient.builder().requestFactory(defaultRequestFactory()));
}
public FeishuOAuth2UserService(RestClient.Builder restClientBuilder) {
this.restClient = restClientBuilder
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.build();
}
/**
* Bounds the userinfo call so an unresponsive Feishu endpoint cannot hold a login thread. The
* timeouts apply to this provider client only and do not change the shared HTTP defaults.
*/
private static ClientHttpRequestFactory defaultRequestFactory() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(CONNECT_TIMEOUT);
factory.setReadTimeout(READ_TIMEOUT);
return factory;
}
@Override
public String getProvider() {
return PROVIDER;
}
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
String userInfoUri = userRequest.getClientRegistration().getProviderDetails()
.getUserInfoEndpoint().getUri();
FeishuUserResponse response;
try {
response = restClient.get()
.uri(userInfoUri)
.header(HttpHeaders.AUTHORIZATION, "Bearer " + userRequest.getAccessToken().getTokenValue())
.retrieve()
.body(new ParameterizedTypeReference<FeishuUserResponse>() {});
} catch (Exception e) {
// The cause carries the detail for operators; the OAuth2Error description stays generic
// because an upstream message can quote the request URI, which holds the access token.
throw new OAuth2AuthenticationException(
new OAuth2Error("feishu_userinfo_error", "Failed to load Feishu user info", null),
e
);
}
if (response == null || response.code() != 0 || response.data() == null) {
throw new OAuth2AuthenticationException(
new OAuth2Error(
"feishu_userinfo_error",
"Feishu user info error, code " + (response == null ? "none" : response.code()),
null
)
);
}
String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails()
.getUserInfoEndpoint().getUserNameAttributeName();
Map<String, Object> attributes = flatten(response.data(), userNameAttributeName);
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")),
attributes,
userNameAttributeName
);
}
private Map<String, Object> flatten(FeishuUserData data, String userNameAttributeName) {
Map<String, Object> attributes = new LinkedHashMap<>();
putIfPresent(attributes, "open_id", data.openId());
putIfPresent(attributes, "union_id", data.unionId());
putIfPresent(attributes, "name", data.name());
putIfPresent(attributes, "en_name", data.enName());
putIfPresent(attributes, "avatar_url", data.avatarUrl());
putIfPresent(attributes, "email", data.email());
putIfPresent(attributes, "enterprise_email", data.enterpriseEmail());
putIfPresent(attributes, "mobile", data.mobile());
if (!attributes.containsKey(userNameAttributeName)) {
throw new OAuth2AuthenticationException(
new OAuth2Error("feishu_userinfo_error", "Feishu user info missing " + userNameAttributeName, null)
);
}
return attributes;
}
private void putIfPresent(Map<String, Object> attributes, String key, String value) {
if (value != null && !value.isBlank()) {
attributes.put(key, value);
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
record FeishuUserResponse(int code, String msg, @JsonProperty("data") FeishuUserData data) {}
@JsonIgnoreProperties(ignoreUnknown = true)
record FeishuUserData(
@JsonProperty("open_id") String openId,
@JsonProperty("union_id") String unionId,
@JsonProperty("name") String name,
@JsonProperty("en_name") String enName,
@JsonProperty("avatar_url") String avatarUrl,
@JsonProperty("email") String email,
@JsonProperty("enterprise_email") String enterpriseEmail,
@JsonProperty("mobile") String mobile
) {}
}

View file

@ -0,0 +1,151 @@
package com.iflytek.skillhub.auth.oauth;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2User;
class FeishuClaimsExtractorTest {
private final FeishuClaimsExtractor extractor = new FeishuClaimsExtractor();
@Test
void extract_prefersEnterpriseEmailOverPersonalEmail() {
Map<String, Object> attrs = new HashMap<>(Map.of(
"open_id", "ou_123",
"name", "张三",
"email", "zhangsan@personal.example",
"enterprise_email", "zhangsan@corp.example"
));
OAuthClaims claims = extractor.extract(userRequest(), user(attrs));
assertThat(claims.provider()).isEqualTo("feishu");
assertThat(claims.subject()).isEqualTo("ou_123");
assertThat(claims.email()).isEqualTo("zhangsan@corp.example");
// Feishu emails are admin-imported; the extractor must not claim verification.
assertThat(claims.emailVerified()).isFalse();
assertThat(claims.providerLogin()).isEqualTo("张三");
}
@Test
void extract_allowsNullEmailAndFallsBackUsername() {
Map<String, Object> attrs = new HashMap<>(Map.of("open_id", "ou_456"));
OAuthClaims claims = extractor.extract(userRequest(), user(attrs));
assertThat(claims.subject()).isEqualTo("ou_456");
assertThat(claims.email()).isNull();
assertThat(claims.emailVerified()).isFalse();
assertThat(claims.providerLogin()).isEqualTo("feishu-ou_456");
}
@Test
void extract_fallsBackToEnglishNameWhenChineseNameBlank() {
Map<String, Object> attrs = new HashMap<>(Map.of(
"open_id", "ou_789",
"en_name", "Alice"
));
OAuthClaims claims = extractor.extract(userRequest(), user(attrs));
assertThat(claims.providerLogin()).isEqualTo("Alice");
}
@Test
void extract_rejectsBlankOpenId() {
// Blank must fail rather than become a subject. DefaultOAuth2User already rejects a
// wholly absent open_id, so a permissive OAuth2User is used to test this contract
// directly instead of relying on that upstream guard.
Map<String, Object> attrs = new HashMap<>();
attrs.put("open_id", " ");
attrs.put("name", "张三");
assertThatThrownBy(() -> extractor.extract(userRequest(), permissiveUser(attrs)))
.isInstanceOf(OAuth2AuthenticationException.class)
.hasMessageContaining("open_id");
}
@Test
void extract_rejectsMissingOpenIdWithoutFabricatingASubject() {
Map<String, Object> attrs = new HashMap<>();
attrs.put("name", "张三");
assertThatThrownBy(() -> extractor.extract(userRequest(), permissiveUser(attrs)))
.isInstanceOf(OAuth2AuthenticationException.class)
.hasMessageContaining("open_id");
}
/** An {@link OAuth2User} that does not enforce the name attribute, unlike DefaultOAuth2User. */
private OAuth2User permissiveUser(Map<String, Object> attrs) {
return new OAuth2User() {
@Override
public Map<String, Object> getAttributes() {
return attrs;
}
@Override
public java.util.Collection<? extends org.springframework.security.core.GrantedAuthority>
getAuthorities() {
return java.util.List.of();
}
@Override
public String getName() {
return String.valueOf(attrs.get("open_id"));
}
};
}
@Test
void extract_doesNotPromoteUnionIdToSubject() {
// union_id stays in extra: a subject that can change between logins would split one
// person across two platform accounts.
Map<String, Object> attrs = new HashMap<>(Map.of(
"open_id", "ou_abc",
"union_id", "on_xyz"
));
OAuthClaims claims = extractor.extract(userRequest(), user(attrs));
assertThat(claims.subject()).isEqualTo("ou_abc");
assertThat(claims.extra()).containsEntry("union_id", "on_xyz");
}
private DefaultOAuth2User user(Map<String, Object> attrs) {
return new DefaultOAuth2User(java.util.List.of(), attrs, "open_id");
}
private OAuth2UserRequest userRequest() {
ClientRegistration registration = ClientRegistration.withRegistrationId("feishu")
.clientId("cli_test123")
.clientSecret("client-secret")
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
.authorizationUri("https://accounts.feishu.cn/open-apis/authen/v1/authorize")
.tokenUri("https://open.feishu.cn/open-apis/authen/v2/oauth/token")
.userInfoUri("https://open.feishu.cn/open-apis/authen/v1/user_info")
.userNameAttributeName("open_id")
.clientName("飞书")
.build();
OAuth2AccessToken accessToken = new OAuth2AccessToken(
OAuth2AccessToken.TokenType.BEARER,
"token-123",
Instant.now(),
Instant.now().plusSeconds(3600)
);
return new OAuth2UserRequest(registration, accessToken);
}
}

View file

@ -0,0 +1,130 @@
package com.iflytek.skillhub.auth.oauth;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
import java.time.Instant;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
class FeishuOAuth2UserServiceTest {
@Test
void loadUser_unwrapsFeishuEnvelopeIntoFlatAttributes() {
RestClient.Builder restClientBuilder = RestClient.builder();
MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build();
server.expect(requestTo("https://open.feishu.cn/open-apis/authen/v1/user_info"))
.andExpect(header(HttpHeaders.AUTHORIZATION, "Bearer token-123"))
.andRespond(withSuccess(
"""
{
"code": 0,
"msg": "success",
"data": {
"open_id": "ou_123",
"union_id": "on_456",
"name": "张三",
"avatar_url": "https://avatar.example/zhangsan.png",
"enterprise_email": "zhangsan@corp.example",
"email": "zhangsan@personal.example"
}
}
""",
MediaType.APPLICATION_JSON
));
FeishuOAuth2UserService service = new FeishuOAuth2UserService(restClientBuilder);
OAuth2User user = service.loadUser(userRequest());
assertThat(user.getName()).isEqualTo("ou_123");
assertThat(user.getAttributes())
.containsEntry("open_id", "ou_123")
.containsEntry("union_id", "on_456")
.containsEntry("name", "张三")
.containsEntry("avatar_url", "https://avatar.example/zhangsan.png")
.containsEntry("enterprise_email", "zhangsan@corp.example")
.doesNotContainKey("code")
.doesNotContainKey("data");
server.verify();
}
@Test
void loadUser_throwsWhenFeishuReportsErrorCode() {
RestClient.Builder restClientBuilder = RestClient.builder();
MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build();
server.expect(requestTo("https://open.feishu.cn/open-apis/authen/v1/user_info"))
.andRespond(withSuccess(
"""
{"code": 99991663, "msg": "invalid access token"}
""",
MediaType.APPLICATION_JSON
));
FeishuOAuth2UserService service = new FeishuOAuth2UserService(restClientBuilder);
assertThatThrownBy(() -> service.loadUser(userRequest()))
.isInstanceOf(OAuth2AuthenticationException.class)
.satisfies(ex -> assertThat(((OAuth2AuthenticationException) ex).getError().getErrorCode())
.isEqualTo("feishu_userinfo_error"));
server.verify();
}
@Test
void loadUser_errorDescriptionDoesNotEchoUpstreamTextOrToken() {
RestClient.Builder restClientBuilder = RestClient.builder();
MockRestServiceServer server = MockRestServiceServer.bindTo(restClientBuilder).build();
server.expect(requestTo("https://open.feishu.cn/open-apis/authen/v1/user_info"))
.andRespond(withSuccess(
"""
{"code": 99991663, "msg": "token token-123 rejected for cli_test123"}
""",
MediaType.APPLICATION_JSON
));
FeishuOAuth2UserService service = new FeishuOAuth2UserService(restClientBuilder);
assertThatThrownBy(() -> service.loadUser(userRequest()))
.isInstanceOf(OAuth2AuthenticationException.class)
.satisfies(ex -> {
String description = ((OAuth2AuthenticationException) ex).getError().getDescription();
// The upstream message can quote the access token; only the code may surface.
assertThat(description).doesNotContain("token-123");
assertThat(description).doesNotContain("rejected");
assertThat(description).contains("99991663");
});
server.verify();
}
private OAuth2UserRequest userRequest() {
ClientRegistration registration = ClientRegistration.withRegistrationId("feishu")
.clientId("cli_test123")
.clientSecret("client-secret")
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
.authorizationUri("https://accounts.feishu.cn/open-apis/authen/v1/authorize")
.tokenUri("https://open.feishu.cn/open-apis/authen/v2/oauth/token")
.userInfoUri("https://open.feishu.cn/open-apis/authen/v1/user_info")
.userNameAttributeName("open_id")
.clientName("飞书")
.build();
OAuth2AccessToken accessToken = new OAuth2AccessToken(
OAuth2AccessToken.TokenType.BEARER,
"token-123",
Instant.now(),
Instant.now().plusSeconds(3600)
);
return new OAuth2UserRequest(registration, accessToken);
}
}

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Official Feishu/Lark logo, source: homarr-labs/dashboard-icons -->
<svg xmlns="http://www.w3.org/2000/svg" width="800px" height="800px" viewBox="62.16 94.5 407.87 324.19"><path d="M274.18 264.785q.515-.517 1.03-1.027c.685-.688 1.372-1.258 2.056-1.945l1.37-1.372 4.118-4.113 5.598-5.601 4.8-4.797 4.575-4.457 4.796-4.688 4.344-4.344 6.059-6.054c1.14-1.145 2.285-2.29 3.543-3.317 2.168-2.054 4.457-4 6.855-5.828 2.172-1.715 4.344-3.312 6.516-4.914 3.082-2.172 6.398-4.344 9.71-6.285 3.204-1.941 6.63-3.656 10.06-5.371 3.199-1.602 6.515-2.973 9.827-4.23 1.829-.684 3.774-1.372 5.602-2.055.914-.344 1.941-.688 2.856-.914-8.57-33.715-24.227-64.575-45.258-90.86-4.114-5.14-10.399-8.113-17.028-8.113H130.754c-3.203 0-4.457 4-1.945 5.941 59.543 43.66 109.144 99.887 145.03 164.801 0-.226.227-.34.34-.457m0 0" style="stroke:none;fill-rule:nonzero;fill:#00d6b9;fill-opacity:1"/><path d="M204.79 418.691c90.288 0 169.03-49.828 210.058-123.543 1.488-2.628 2.859-5.257 4.23-7.882q-3.087 6-6.86 11.312l-2.741 3.77c-1.141 1.488-2.399 2.972-3.657 4.457-1.03 1.144-2.058 2.285-3.086 3.316-2.058 2.172-4.343 4.227-6.629 6.172a53 53 0 0 1-3.886 3.2c-1.598 1.144-3.086 2.284-4.684 3.429-1.031.683-2.058 1.371-3.086 1.941-1.144.684-2.172 1.258-3.316 1.942a131 131 0 0 1-6.969 3.543c-2.059.918-4.117 1.828-6.289 2.515-2.285.801-4.57 1.602-6.969 2.285-3.543.914-7.086 1.715-10.742 2.286-2.629.457-5.258.687-8 .914-2.86.23-5.601.23-8.457.23-3.086 0-6.289-.23-9.488-.57a83 83 0 0 1-7.086-1.031c-2.055-.34-4.113-.801-6.168-1.258-1.031-.227-2.176-.57-3.203-.797-2.973-.8-6.055-1.602-9.028-2.516-1.488-.457-2.972-.914-4.457-1.258-2.172-.683-4.457-1.37-6.629-2.058-1.828-.57-3.656-1.14-5.37-1.711q-2.573-.86-5.145-1.715c-1.14-.344-2.285-.8-3.543-1.144-1.371-.457-2.856-1.028-4.227-1.485-1.027-.344-2.058-.687-2.972-1.027-1.942-.688-4-1.488-5.942-2.172-1.144-.457-2.285-.914-3.43-1.258-1.484-.57-3.085-1.144-4.57-1.828-1.601-.687-3.203-1.258-4.8-1.945-1.028-.457-2.06-.797-3.087-1.258-1.257-.57-2.628-1.027-3.886-1.598-1.028-.457-1.942-.8-2.969-1.258l-3.086-1.37c-.914-.344-1.832-.801-2.746-1.145a44 44 0 0 1-2.512-1.14c-.8-.345-1.715-.802-2.515-1.145-.914-.344-1.715-.801-2.512-1.141-1.031-.457-2.172-1.031-3.203-1.484-1.14-.575-2.285-1.032-3.426-1.602-1.258-.574-2.402-1.144-3.66-1.715-1.027-.457-2.055-1.027-3.082-1.484-54.172-26.973-102.172-63.086-143.09-106.746-2.055-2.172-5.71-.684-5.71 2.289l.112 154.398v12.57c0 7.317 3.543 14.06 9.598 18.172 38.172 24.801 83.773 39.543 132.914 39.543m0 0" style="stroke:none;fill-rule:nonzero;fill:#3370ff;fill-opacity:1"/><path d="M414.84 295.188c0 .113-.113.113-.113.226zl.8-1.489c-.343.457-.574 1.028-.8 1.488m3.793-7.05.226-.457.114-.23q-.17.513-.34.687m0 0" style="stroke:none;fill-rule:nonzero;fill:#133c9a;fill-opacity:1"/><path d="M470.035 201.121c-18.285-9.031-38.86-14.059-60.687-14.059-12.914 0-25.485 1.829-37.371 5.141-1.372.344-2.743.8-4.114 1.258-.914.344-1.941.574-2.855.914-1.945.688-3.774 1.375-5.602 2.059-3.316 1.257-6.629 2.742-9.828 4.23-3.43 1.598-6.742 3.426-10.058 5.371a128 128 0 0 0-9.715 6.285c-2.285 1.602-4.457 3.2-6.512 4.914a154 154 0 0 0-6.86 5.828c-1.14 1.141-2.398 2.172-3.542 3.313l-6.055 6.059-4.344 4.343-4.8 4.684-4.57 4.46-4.802 4.798-11.086 11.086c-.687.687-1.37 1.37-2.058 1.945l-1.028 1.027c-.457.457-1.027 1.028-1.601 1.485-.57.57-1.14 1.031-1.711 1.601a244.4 244.4 0 0 1-49.828 35.313c1.027.457 2.168 1.027 3.199 1.488.8.34 1.715.797 2.512 1.14.8.344 1.715.801 2.515 1.145.801.344 1.602.684 2.516 1.14.914.345 1.828.802 2.742 1.145l3.086 1.371c1.027.457 1.942.801 2.969 1.258 1.258.57 2.629 1.028 3.887 1.598 1.03.46 2.058.8 3.086 1.258 1.601.687 3.199 1.258 4.8 1.945 1.485.57 3.086 1.14 4.57 1.828 1.145.457 2.286.914 3.43 1.258 1.946.684 4 1.484 5.946 2.172a81 81 0 0 1 2.968 1.027c1.371.457 2.856 1.028 4.23 1.485 1.141.343 2.286.8 3.544 1.14q2.567.86 5.14 1.719c1.829.57 3.657 1.14 5.372 1.71 2.171.688 4.457 1.376 6.628 2.06 1.489.457 2.973.914 4.457 1.257 2.973.914 5.942 1.715 9.032 2.512 1.027.344 2.168.574 3.199.8 2.055.458 4.113.915 6.172 1.259 2.398.457 4.683.8 7.082 1.03 3.203.34 6.402.571 9.488.571 2.856 0 5.715 0 8.457-.23 2.63-.227 5.371-.457 8-.914 3.656-.57 7.2-1.371 10.742-2.286 2.399-.683 4.688-1.37 6.973-2.285 2.172-.8 4.227-1.601 6.285-2.515 2.399-1.028 4.684-2.285 6.973-3.543 1.14-.57 2.168-1.258 3.312-1.942 1.028-.687 2.059-1.257 3.086-1.945 1.602-1.027 3.2-2.168 4.684-3.426a52 52 0 0 0 3.887-3.203c2.289-1.941 4.457-4 6.628-6.168 1.032-1.031 2.06-2.172 3.086-3.316 1.258-1.485 2.516-2.969 3.657-4.457.918-1.258 1.828-2.512 2.742-3.77 2.515-3.543 4.8-7.316 6.86-11.199l2.284-4.688 21.145-42.171v.113c6.742-14.742 16.226-28.113 27.656-39.426m0 0" style="stroke:none;fill-rule:nonzero;fill:#133c9a;fill-opacity:1"/></svg>

After

Width:  |  Height:  |  Size: 4.6 KiB