Skip to content

Commit

Permalink
Merge pull request #532 from juliensadaoui/feat/511-oauth0-support
Browse files Browse the repository at this point in the history
Add support for Auth0
  • Loading branch information
pascalgrimaud authored Feb 24, 2022
2 parents b857214 + fbae879 commit e0dcacd
Show file tree
Hide file tree
Showing 16 changed files with 273 additions and 94 deletions.
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,34 @@ vault:
```

- After successful start, you shall require entering a new password as provided in vault.

## OAuth 2.0 and OpenID Connect

OAuth is a stateful security mechanism, like HTTP Session. Spring Security provides excellent OAuth 2.0 and OIDC support, and this is leveraged by JHipster. If you’re not sure what OAuth and OpenID Connect (OIDC) are, please see [What the Heck is OAuth?](https://developer.okta.com/blog/2017/06/21/what-the-heck-is-oauth)

Please note that [JSON Web Token (JWT)](https://jwt.io/) is the default option when using the JHipster Registry. It has to be started with **oauth2** spring profile to enable the OAuth authentication.

In order to run your JHipster Registry with OAuth 2.0 and OpenID Connect:

- For development run `SPRING_PROFILES_ACTIVE=dev,oauth2,native ./mvnw`
- For production you can use environment variables. For example:

```
export SPRING_PROFILES_ACTIVE=prod,oauth2,api-docs
```

### Keycloak

[Keycloak](https://www.keycloak.org/) is the default OpenID Connect server configured with JHipster.

If you want to use Keycloak, you can follow the [documentation for Keycloak](https://www.jhipster.tech/security/#keycloak)

### Okta

If you'd like to use [Okta](https://www.okta.com/) instead of Keycloak, you can follow the [documentation for Okta](https://www.jhipster.tech/security/#okta)

### Auth0

If you'd like to use [Auth0](https://auth0.com/) instead of Keycloak, you can follow the [documentation for Auth0](https://www.jhipster.tech/security/#auth0)

NOTE: Using the JHipster Registry, add URLs for port 8761 too ("Allowed Callback URLs" and "Allowed Logout URLs")
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ public CorsFilter corsFilter() {
log.debug("Registering CORS filter");
source.registerCorsConfiguration("/api/**", config);
source.registerCorsConfiguration("/management/**", config);
source.registerCorsConfiguration("/config/**", config);
source.registerCorsConfiguration("/eureka/**", config);
source.registerCorsConfiguration("/v3/api-docs", config);
source.registerCorsConfiguration("/swagger-ui/**", config);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
*/
public final class SecurityUtils {

public static final String CLAIMS_NAMESPACE = "https://www.jhipster.tech/";

private SecurityUtils() {}

/**
Expand Down Expand Up @@ -129,7 +131,10 @@ public static List<GrantedAuthority> extractAuthorityFromClaims(Map<String, Obje

@SuppressWarnings("unchecked")
private static Collection<String> getRolesFromClaims(Map<String, Object> claims) {
return (Collection<String>) claims.getOrDefault("groups", claims.getOrDefault("roles", new ArrayList<>()));
return (Collection<String>) claims.getOrDefault(
"groups",
claims.getOrDefault("roles", claims.getOrDefault(CLAIMS_NAMESPACE + "roles", new ArrayList<>()))
);
}

private static List<GrantedAuthority> mapRolesToGrantedAuthorities(Collection<String> roles) {
Expand Down
27 changes: 22 additions & 5 deletions src/main/java/tech/jhipster/registry/web/rest/LogoutResource.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
Expand Down Expand Up @@ -35,12 +36,28 @@ public LogoutResource(ClientRegistrationRepository registrations) {
*/
@PostMapping("/api/logout")
public ResponseEntity<?> logout(HttpServletRequest request, @AuthenticationPrincipal(expression = "idToken") OidcIdToken idToken) {
String logoutUrl = this.registration.getProviderDetails().getConfigurationMetadata().get("end_session_endpoint").toString();
StringBuilder logoutUrl = new StringBuilder();

String issuerUri = this.registration.getProviderDetails().getIssuerUri();
if (issuerUri.contains("auth0.com")) {
logoutUrl.append(issuerUri.endsWith("/") ? issuerUri + "v2/logout" : issuerUri + "/v2/logout");
} else {
logoutUrl.append(this.registration.getProviderDetails().getConfigurationMetadata().get("end_session_endpoint").toString());
}

String originUrl = request.getHeader(HttpHeaders.ORIGIN);

if (logoutUrl.indexOf("/protocol") > -1) {
logoutUrl.append("?redirect_uri=").append(originUrl);
} else if (logoutUrl.indexOf("auth0.com") > -1) {
// Auth0
logoutUrl.append("?client_id=").append(this.registration.getClientId()).append("&returnTo=").append(originUrl);
} else {
// Okta
logoutUrl.append("?id_token_hint=").append(idToken.getTokenValue()).append("&post_logout_redirect_uri=").append(originUrl);
}

Map<String, String> logoutDetails = new HashMap<>();
logoutDetails.put("logoutUrl", logoutUrl);
logoutDetails.put("idToken", idToken.getTokenValue());
request.getSession().invalidate();
return ResponseEntity.ok().body(logoutDetails);
return ResponseEntity.ok().body(Map.of("logoutUrl", logoutUrl.toString()));
}
}
41 changes: 0 additions & 41 deletions src/main/webapp/app/core/auth/auth-session.service.ts

This file was deleted.

2 changes: 1 addition & 1 deletion src/main/webapp/app/home/home.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { AccountService } from 'app/core/auth/account.service';
import { ProfileService } from 'app/layouts/profiles/profile.service';
import { ApplicationsService } from 'app/registry/applications/applications.service';
import { HealthService } from 'app/shared/health/health.service';
import { LoginOAuth2Service } from 'app/shared/oauth2/login-oauth2.service';
import { LoginOAuth2Service } from 'app/login/login-oauth2.service';
import { RefreshService } from 'app/shared/refresh/refresh.service';
import { EventManager } from 'app/core/util/event-manager.service';
import { EurekaStatusService } from './eureka.status.service';
Expand Down
2 changes: 1 addition & 1 deletion src/main/webapp/app/home/home.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { AccountService } from 'app/core/auth/account.service';
import { Account } from 'app/core/auth/account.model';
import { EventManager } from 'app/core/util/event-manager.service';
import { ApplicationsService, Instance } from 'app/registry/applications/applications.service';
import { LoginOAuth2Service } from 'app/shared/oauth2/login-oauth2.service';
import { LoginOAuth2Service } from 'app/login/login-oauth2.service';
import { Health, HealthStatus } from 'app/shared/health/health.model';
import { HealthService } from 'app/shared/health/health.service';
import { RefreshService } from 'app/shared/refresh/refresh.service';
Expand Down
4 changes: 2 additions & 2 deletions src/main/webapp/app/layouts/navbar/navbar.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
jest.mock('app/core/auth/account.service');
jest.mock('app/login/login.service');
jest.mock('app/shared/oauth2/login-oauth2.service');
jest.mock('app/login/login-oauth2.service');

import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { HttpClientTestingModule } from '@angular/common/http/testing';
Expand All @@ -12,7 +12,7 @@ import { AccountService } from 'app/core/auth/account.service';
import { ProfileService } from 'app/layouts/profiles/profile.service';
import { ProfileInfo } from 'app/layouts/profiles/profile-info.model';
import { LoginService } from 'app/login/login.service';
import { LoginOAuth2Service } from 'app/shared/oauth2/login-oauth2.service';
import { LoginOAuth2Service } from 'app/login/login-oauth2.service';

import { NavbarComponent } from './navbar.component';

Expand Down
18 changes: 4 additions & 14 deletions src/main/webapp/app/layouts/navbar/navbar.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { AccountService } from 'app/core/auth/account.service';
import { EventManager } from 'app/core/util/event-manager.service';
import { ProfileService } from 'app/layouts/profiles/profile.service';
import { LoginService } from 'app/login/login.service';
import { LoginOAuth2Service } from 'app/shared/oauth2/login-oauth2.service';
import { LoginOAuth2Service } from 'app/login/login-oauth2.service';
import { Logout } from '../../login/logout.model';

@Component({
selector: 'jhi-navbar',
Expand Down Expand Up @@ -67,19 +68,8 @@ export class NavbarComponent implements OnInit, OnDestroy {
.pipe(takeUntil(this.unsubscribe$))
.subscribe(profileInfo => {
if (profileInfo.activeProfiles!.includes('oauth2')) {
this.loginOAuth2Service.logout().subscribe(response => {
const data = response.body;
let logoutUrl = data.logoutUrl;
// if Keycloak, uri has protocol/openid-connect/token
if (logoutUrl.indexOf('/protocol') > -1) {
logoutUrl = `${String(logoutUrl)}?redirect_uri=${String(window.location.origin)}`;
} else {
// Okta
logoutUrl = `${String(logoutUrl)}?id_token_hint=${String(data.idToken)}&post_logout_redirect_uri=${String(
window.location.origin
)}`;
}
window.location.href = logoutUrl;
this.loginOAuth2Service.logout().subscribe((logout: Logout) => {
window.location.href = logout.logoutUrl;
});
} else {
this.loginService.logout();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { Injectable } from '@angular/core';
import { HttpClient, HttpResponse } from '@angular/common/http';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

import { ApplicationConfigService } from '../core/config/application-config.service';
import { Logout } from 'app/login/logout.model';

@Injectable({ providedIn: 'root' })
export class LoginOAuth2Service {
constructor(private http: HttpClient) {}
constructor(private http: HttpClient, private applicationConfigService: ApplicationConfigService) {}

login(): void {
let port = location.port ? `:${location.port}` : '';
Expand All @@ -24,8 +26,7 @@ export class LoginOAuth2Service {
location.href = `//${location.hostname}${port}${contextPath}oauth2/authorization/oidc`;
}

logout(): Observable<any> {
// logout from the server
return this.http.post(`${SERVER_API_URL}api/logout`, {}, { observe: 'response' }).pipe(map((response: HttpResponse<any>) => response));
logout(): Observable<Logout> {
return this.http.post<Logout>(this.applicationConfigService.getEndpointFor('api/logout'), {});
}
}
3 changes: 3 additions & 0 deletions src/main/webapp/app/login/logout.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export class Logout {
constructor(public logoutUrl: string) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.AuthenticatedPrincipalOAuth2AuthorizedClientRepository;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizedClientRepository;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.jwt.JwtDecoder;
Expand All @@ -23,25 +21,25 @@
@TestConfiguration
public class TestSecurityConfiguration {

private final ClientRegistration clientRegistration;

public TestSecurityConfiguration() {
this.clientRegistration = clientRegistration().build();
@Bean
ClientRegistration clientRegistration() {
return clientRegistrationBuilder().build();
}

@Bean
ClientRegistrationRepository clientRegistrationRepository() {
ClientRegistrationRepository clientRegistrationRepository(ClientRegistration clientRegistration) {
return new InMemoryClientRegistrationRepository(clientRegistration);
}

private ClientRegistration.Builder clientRegistration() {
private ClientRegistration.Builder clientRegistrationBuilder() {
Map<String, Object> metadata = new HashMap<>();
metadata.put("end_session_endpoint", "https://jhipster.org/logout");

return ClientRegistration
.withRegistrationId("oidc")
.issuerUri("{baseUrl}")
.redirectUri("{baseUrl}/{action}/oauth2/code/{registrationId}")
.clientAuthenticationMethod(ClientAuthenticationMethod.BASIC)
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.scope("read:user")
.authorizationUri("https://jhipster.org/login/oauth/authorize")
Expand All @@ -64,9 +62,4 @@ JwtDecoder jwtDecoder() {
public OAuth2AuthorizedClientService authorizedClientService(ClientRegistrationRepository clientRegistrationRepository) {
return new InMemoryOAuth2AuthorizedClientService(clientRegistrationRepository);
}

@Bean
public OAuth2AuthorizedClientRepository authorizedClientRepository(OAuth2AuthorizedClientService authorizedClientService) {
return new AuthenticatedPrincipalOAuth2AuthorizedClientRepository(authorizedClientService);
}
}
93 changes: 93 additions & 0 deletions src/test/java/tech/jhipster/registry/utils/OAuth2TestUtil.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package tech.jhipster.registry.utils;

import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.OAuth2AccessToken.TokenType;
import org.springframework.security.oauth2.core.oidc.OidcIdToken;
import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;
import tech.jhipster.registry.security.AuthoritiesConstants;
import tech.jhipster.registry.security.SecurityUtils;

public class OAuth2TestUtil {

public static final String TEST_USER_LOGIN = "test";

public static final String ID_TOKEN =
"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9" +
".eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsIm" +
"p0aSI6ImQzNWRmMTRkLTA5ZjYtNDhmZi04YTkzLTdjNmYwMzM5MzE1OSIsImlhdCI6MTU0M" +
"Tk3MTU4MywiZXhwIjoxNTQxOTc1MTgzfQ.QaQOarmV8xEUYV7yvWzX3cUE_4W1luMcWCwpr" +
"oqqUrg";

public static OAuth2AuthenticationToken testAuthenticationToken() {
Map<String, Object> claims = new HashMap<>();
claims.put("sub", TEST_USER_LOGIN);
claims.put("preferred_username", TEST_USER_LOGIN);
claims.put("email", "[email protected]");
claims.put("roles", Collections.singletonList(AuthoritiesConstants.ADMIN));

return authenticationToken(claims);
}

public static OAuth2AuthenticationToken authenticationToken(Map<String, Object> claims) {
Instant issuedAt = Instant.now();
Instant expiresAt = Instant.now().plus(1, ChronoUnit.DAYS);
if (!claims.containsKey("sub")) {
claims.put("sub", "jane");
}
if (!claims.containsKey("preferred_username")) {
claims.put("preferred_username", "jane");
}
if (!claims.containsKey("email")) {
claims.put("email", "[email protected]");
}
if (claims.containsKey("auth_time")) {
issuedAt = (Instant) claims.get("auth_time");
} else {
claims.put("auth_time", issuedAt);
}
if (claims.containsKey("exp")) {
expiresAt = (Instant) claims.get("exp");
} else {
claims.put("exp", expiresAt);
}
Collection<GrantedAuthority> authorities = SecurityUtils.extractAuthorityFromClaims(claims);
OidcIdToken token = new OidcIdToken(ID_TOKEN, issuedAt, expiresAt, claims);
OidcUserInfo userInfo = new OidcUserInfo(claims);
DefaultOidcUser user = new DefaultOidcUser(authorities, token, userInfo, "preferred_username");
return new OAuth2AuthenticationToken(user, user.getAuthorities(), "oidc");
}

public static OAuth2AuthenticationToken registerAuthenticationToken(
OAuth2AuthorizedClientService authorizedClientService,
ClientRegistration clientRegistration,
OAuth2AuthenticationToken authentication
) {
Map<String, Object> userDetails = authentication.getPrincipal().getAttributes();

OAuth2AccessToken token = new OAuth2AccessToken(
TokenType.BEARER,
"Token",
(Instant) userDetails.get("auth_time"),
(Instant) userDetails.get("exp")
);

authorizedClientService.saveAuthorizedClient(
new OAuth2AuthorizedClient(clientRegistration, authentication.getName(), token),
authentication
);

return authentication;
}
}
Loading

0 comments on commit e0dcacd

Please sign in to comment.