Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Github SSO / OAuth2 support #1319

Merged
merged 2 commits into from
Jan 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,9 @@ dependencies {

//AWS MSK IAM Auth
implementation group: 'software.amazon.msk', name: 'aws-msk-iam-auth', version: '1.1.5'

// https://mvnrepository.com/artifact/io.projectreactor/reactor-core
implementation group: 'io.projectreactor', name: 'reactor-core', version: '3.5.1'
}

/**********************************************************************************************************************\
Expand Down
6 changes: 4 additions & 2 deletions client/src/containers/Login/Login.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ class Login extends Form {
errors: {},
config: {
formEnabled: true,
oidcAuths: []
oidcAuths: [],
oauthAuths: []
}
};

Expand Down Expand Up @@ -157,7 +158,7 @@ class Login extends Form {
}

render() {
const { formEnabled, oidcAuths } = this.state.config;
const { formEnabled, oidcAuths, oauthAuths } = this.state.config;

return (
<div>
Expand All @@ -177,6 +178,7 @@ class Login extends Form {
{formEnabled && this._renderForm()}
{formEnabled && oidcAuths && this._renderSeparator()}
{oidcAuths && this._renderOidc(oidcAuths)}
{oauthAuths && this._renderOidc(oauthAuths)}
</form>
</main>
</div>
Expand Down
1 change: 1 addition & 0 deletions docs/.vuepress/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ module.exports = {
'/docs/configuration/authentifications/basic-auth.md',
'/docs/configuration/authentifications/aws-iam-auth.md',
'/docs/configuration/authentifications/oidc.md',
'/docs/configuration/authentifications/github.md',
'/docs/configuration/authentifications/ldap.md',
'/docs/configuration/authentifications/header.md',
'/docs/configuration/authentifications/external.md',
Expand Down
48 changes: 48 additions & 0 deletions docs/docs/configuration/authentifications/github.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@

# GitHub SSO / OAuth2
To enable GitHub SSO in the application, you'll first have to enable OAuth2 in micronaut:

```yaml
micronaut:
security:
enabled: true
oauth2:
enabled: true
clients:
github:
client-id: "<client-id>"
client-secret: "<client-secret>"
scopes:
- user:email
- read:user
authorization:
url: https://github.com/login/oauth/authorize
token:
url: https://github.com/login/oauth/access_token
auth-method: client-secret-post
```

To further tell AKHQ to display GitHub SSO options on the login page and customize claim mapping, configure Oauth in the AKHQ config:

```yaml
akhq:
security:
default-group: no-roles
oauth2:
enabled: true
providers:
github:
label: "Login with GitHub"
username-field: login
users:
- username: franz
groups:
# the corresponding akhq groups (eg. topic-reader/writer or akhq default groups like admin/reader/no-role)
- topic-reader
- topic-writer
```

The username field can be any string field, the roles field has to be a JSON array.

## References
https://micronaut-projects.github.io/micronaut-security/latest/guide/#oauth2-configuration
30 changes: 30 additions & 0 deletions src/main/java/org/akhq/configs/Oauth.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package org.akhq.configs;

import io.micronaut.context.annotation.ConfigurationProperties;
import lombok.Data;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;

@ConfigurationProperties("akhq.security.oauth2")
@Data
public class Oauth {
private boolean enabled;
private Map<String, Provider> providers;

@Data
public static class Provider {
private String label = "Login with OAuth";
private String usernameField = "login";
private String groupsField = "organizations_url";
private String defaultGroup;
private List<GroupMapping> groups = new ArrayList<>();
private List<UserMapping> users = new ArrayList<>();
}

public Provider getProvider(String key) {
providers.putIfAbsent(key, new Provider());
return providers.get(key);
}
}
19 changes: 19 additions & 0 deletions src/main/java/org/akhq/controllers/AkhqController.java
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ public class AkhqController extends AbstractController {
@Inject
private Oidc oidc;

@Inject
private Oauth oauth;

@Inject
private UIOptions uIOptions;

Expand Down Expand Up @@ -80,6 +83,13 @@ public AuthDefinition auths() {
.collect(Collectors.toList());
}

if (oauth.isEnabled()) {
authDefinition.oauthAuths = oauth.getProviders().entrySet()
.stream()
.map(e -> new OauthAuth(e.getKey(), e.getValue().getLabel()))
.collect(Collectors.toList());
}

if (applicationContext.containsBean(SecurityService.class)) {
authDefinition.loginEnabled = true;
// Display login form if there are LocalUsers OR Ldap is enabled
Expand Down Expand Up @@ -180,6 +190,7 @@ public static class AuthDefinition {
private boolean loginEnabled;
private boolean formEnabled;
private List<OidcAuth> oidcAuths;
private List<OauthAuth> oauthAuths;
private String version;
}

Expand All @@ -191,6 +202,14 @@ public static class OidcAuth {
private String label;
}

@AllArgsConstructor
@NoArgsConstructor
@Getter
public static class OauthAuth {
private String key;
private String label;
}

@AllArgsConstructor
@NoArgsConstructor
@Getter
Expand Down
23 changes: 23 additions & 0 deletions src/main/java/org/akhq/models/GithubClaims.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package org.akhq.models;

import io.micronaut.security.token.Claims;

import java.util.HashMap;
import java.util.Set;

public class GithubClaims extends HashMap<String, Object> implements Claims {
@Override
public Object get(String name) {
return super.get(name);
}

@Override
public Set<String> names() {
return super.keySet();
}

@Override
public boolean contains(String name) {
return super.containsKey(name);
}
}
85 changes: 85 additions & 0 deletions src/main/java/org/akhq/modules/GithubAuthenticationMapper.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package org.akhq.modules;

import io.micronaut.context.annotation.Requires;
import io.micronaut.core.annotation.Nullable;
import io.micronaut.core.util.StringUtils;
import io.micronaut.security.authentication.AuthenticationResponse;
import io.micronaut.security.oauth2.endpoint.authorization.state.State;
import io.micronaut.security.oauth2.endpoint.token.response.OauthAuthenticationMapper;
import io.micronaut.security.oauth2.endpoint.token.response.TokenResponse;
import jakarta.inject.Named;
import org.akhq.configs.Oauth;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import org.akhq.models.GithubClaims;
import org.akhq.utils.*;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Flux;

import java.util.*;
import java.util.stream.Collectors;

@Singleton
@Named("github")
@Requires(property = "akhq.security.oauth2.enabled", value = StringUtils.TRUE)
public class GithubAuthenticationMapper implements OauthAuthenticationMapper {
@Inject
private Oauth oauth;
@Inject
private GithubApiClient apiClient;
@Inject
private ClaimProvider claimProvider;

@Override
public Publisher<AuthenticationResponse> createAuthenticationResponse(TokenResponse tokenResponse, @Nullable State state) {
return Flux.from(apiClient.getUser("token " + tokenResponse.getAccessToken()))
.map(user -> {
ClaimRequest request = ClaimRequest.builder()
.providerType(ClaimProviderType.OAUTH)
.providerName("github")
.username(getUsername(oauth.getProvider("github"), user))
.groups(getOauthGroups(oauth.getProvider("github"), user))
.build();

ClaimResponse claim = claimProvider.generateClaim(request);

return AuthenticationResponse.success(getUsername(oauth.getProvider("github"), user), claim.getRoles(), claim.getAttributes());
});
}

/**
* Tries to read the username from the configured username field.
*
* @param provider The OAuth provider
* @param user The OAuth claims
* @return The username to set in the {@link io.micronaut.security.authentication.Authentication}
*/
protected String getUsername(Oauth.Provider provider, GithubClaims user) {
String userNameField = provider.getUsernameField();
return Objects.toString(user.get(userNameField));
}

/**
* Tries to read groups from the configured groups field.
* If the configured field cannot be found or isn't some kind of collection, it will return an empty set.
*
* @param provider The OAuth provider configuration
* @param user The OAuth claims
* @return The groups from oauth
*/
protected List<String> getOauthGroups(Oauth.Provider provider, GithubClaims user) {
List<String> groups = new ArrayList<>();
if (user.contains(provider.getGroupsField())) {
Object groupsField = user.get(provider.getGroupsField());
if (groupsField instanceof Collection) {
groups = ((Collection<Object>) groupsField)
.stream()
.map(Objects::toString)
.collect(Collectors.toList());
} else if (groupsField instanceof String) {
groups.add((String) groupsField);
}
}
return groups;
}
}
3 changes: 2 additions & 1 deletion src/main/java/org/akhq/utils/ClaimProviderType.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ public enum ClaimProviderType {
HEADER,
BASIC_AUTH,
LDAP,
OIDC
OIDC,
OAUTH
}
16 changes: 16 additions & 0 deletions src/main/java/org/akhq/utils/GithubApiClient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package org.akhq.utils;

import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.Header;
import io.micronaut.http.client.annotation.Client;
import org.akhq.models.GithubClaims;
import org.reactivestreams.Publisher;

@Header(name = "User-Agent", value = "Micronaut")
@Header(name = "Accept", value = "application/vnd.github.v3+json, application/json")
@Client("https://api.github.com")
public interface GithubApiClient {

@Get("/user")
Publisher<GithubClaims> getUser(@Header("Authorization") String authorization);
}
12 changes: 12 additions & 0 deletions src/main/java/org/akhq/utils/LocalSecurityClaimProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ public class LocalSecurityClaimProvider implements ClaimProvider {
Ldap ldapProperties;
@Inject
Oidc oidcProperties;
@Inject
Oauth oauthProperties;

@Override
public ClaimResponse generateClaim(ClaimRequest request) {
Expand Down Expand Up @@ -63,6 +65,16 @@ public ClaimResponse generateClaim(ClaimRequest request) {
defaultGroup = provider.getDefaultGroup();
akhqGroups.addAll(mapToAkhqGroups(request.getUsername(), request.getGroups(), groupMappings, userMappings, defaultGroup));
break;
case OAUTH:
// we need to convert from OAUTH login name to AKHQ groups to find the roles and attributes
// using akhq.security.oauth2.groups and akhq.security.oauth2.users
// as well as akhq.security.oauth2.default-group
Oauth.Provider oauthPropertiesProvider = oauthProperties.getProvider(request.getProviderName());
userMappings = oauthPropertiesProvider.getUsers();
groupMappings = oauthPropertiesProvider.getGroups();
defaultGroup = oauthPropertiesProvider.getDefaultGroup();
akhqGroups.addAll(mapToAkhqGroups(request.getUsername(), request.getGroups(), groupMappings, userMappings, defaultGroup));
break;
default:
break;
}
Expand Down