Skip to content

Commit e8f3c07

Browse files
authored
Replace the exchange approach with the bootstrap grant type in the login flow
1 parent 33b8bef commit e8f3c07

6 files changed

Lines changed: 211 additions & 7 deletions

File tree

auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/controller/AuthController.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ class AuthController(private val loginService: LoginService) {
2727
}
2828

2929
@PostMapping("/token/resend-otp")
30-
suspend fun confirmGetToken(
30+
suspend fun resendOtp(
3131
@RequestBody resendOtpRequest: ResendOtpRequest,
3232
@CurrentSecurityContext securityContext: SecurityContext,
3333
): ResponseEntity<ResendOtpResponse> {

auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/proxy/KeycloakProxy.kt

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,4 +439,35 @@ class KeycloakProxy(
439439
return internalId
440440

441441
}
442+
443+
suspend fun getClientBTokenWithBootstrap(
444+
bootstrapToken: String,
445+
clientId: String,
446+
clientSecret: String?,
447+
rememberMe: Boolean
448+
): Token {
449+
// There is no way to define a custom grant type in keycloak, so we use a password grant with a custom Bootstrap token field, we defined a custom factory to pars this request
450+
val tokenUrl = "${keycloakConfig.url}/realms/${keycloakConfig.realm}/protocol/openid-connect/token"
451+
452+
val token = keycloakClient.post()
453+
.uri(tokenUrl)
454+
.header("Content-Type", "application/x-www-form-urlencoded")
455+
.bodyValue(
456+
"grant_type=password" +
457+
"&client_id=$clientId" +
458+
"&client_secret=$clientSecret" +
459+
"&bootstrap_token=$bootstrapToken" +
460+
"&username=bootstrap_user" + // Required dummy field
461+
"&password=bootstrap_pass" + // Required dummy field
462+
"&scope=offline_access"
463+
)
464+
.retrieve()
465+
.onStatus({ it == HttpStatus.valueOf(401) }) {
466+
throw OpexError.InvalidUserCredentials.exception()
467+
}
468+
.awaitBody<Token>()
469+
470+
if (!rememberMe) token.refreshToken = null
471+
return token
472+
}
442473
}

auth-gateway/auth-gateway-app/src/main/kotlin/co/nilin/opex/auth/service/LoginService.kt

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ class LoginService(
4747
request.clientId,
4848
request.clientSecret
4949
).apply { if (!request.rememberMe) refreshToken = null }
50+
sendLoginEvent(user.id, token.sessionState, request, token.expiresIn)
5051
return TokenResponse(token, null, null)
5152
}
5253

@@ -106,12 +107,18 @@ class LoginService(
106107
}
107108
}
108109

109-
val token = keycloakProxy.exchangeUserToken(
110-
request.token,
111-
PRE_AUTH_CLIENT_ID,
112-
preAuthClientSecretKey,
113-
request.clientId
114-
).apply { if (!request.rememberMe) refreshToken = null }
110+
// val token = keycloakProxy.exchangeUserToken(
111+
// request.token, request.clientId,
112+
// request.clientSecret,
113+
// request.clientId
114+
// ).apply { if (!request.rememberMe) refreshToken = null }
115+
val token = keycloakProxy.getClientBTokenWithBootstrap(
116+
bootstrapToken = request.token,
117+
clientId = request.clientId,
118+
clientSecret = request.clientSecret,
119+
rememberMe = request.rememberMe
120+
)
121+
115122
sendLoginEvent(extractUserUuidFromToken(token.accessToken), token.sessionState, request, token.expiresIn)
116123

117124
return TokenResponse(token, null, null)
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package co.nilin.opex.keycloak.spi;
2+
3+
import org.keycloak.TokenVerifier;
4+
import org.keycloak.authentication.AuthenticationFlowContext;
5+
import org.keycloak.authentication.AuthenticationFlowError;
6+
import org.keycloak.authentication.Authenticator;
7+
import org.keycloak.models.*;
8+
import jakarta.ws.rs.core.MultivaluedMap;
9+
import jakarta.ws.rs.core.Response;
10+
import jakarta.ws.rs.core.MediaType;
11+
import org.keycloak.representations.AccessToken;
12+
13+
import java.util.*;
14+
15+
public class BootstrapTokenGrantAuthenticator implements Authenticator {
16+
17+
@Override
18+
public void authenticate(AuthenticationFlowContext context) {
19+
MultivaluedMap<String, String> params = context.getHttpRequest().getDecodedFormParameters();
20+
String bootstrapTokenString = params.getFirst("bootstrap_token");
21+
22+
if (bootstrapTokenString == null || bootstrapTokenString.isEmpty()) {
23+
24+
System.out.println("No bootstrap token found, skipping to next authenticator.");
25+
26+
String username = context.getHttpRequest().getDecodedFormParameters().getFirst("username");
27+
if (username != null) {
28+
UserModel user = context.getSession().users().getUserByUsername(context.getRealm(), username);
29+
if (user != null) {
30+
context.setUser(user); // Attach the user so the next step (Password) knows who to check
31+
}
32+
}
33+
context.attempted();
34+
return;
35+
}
36+
try {
37+
// Parse the JWT to get the user ID (the 'sub' claim)
38+
AccessToken token = TokenVerifier.create(bootstrapTokenString, AccessToken.class).getToken();
39+
String userId = token.getSubject();
40+
41+
KeycloakSession session = context.getSession();
42+
RealmModel realm = context.getRealm();
43+
44+
// Find the actual user from the database
45+
UserModel user = session.users().getUserById(realm, userId);
46+
47+
if (user == null || !user.isEnabled()) {
48+
sendError(context, "invalid_grant");
49+
return;
50+
}
51+
52+
// IMPORTANT: Identify the user and tell Keycloak this step is finished successfully
53+
context.setUser(user);
54+
context.success();
55+
56+
} catch (Exception e) {
57+
// This happens if the JWT is malformed or expired
58+
sendError(context, "invalid_grant");
59+
}
60+
}
61+
62+
private void sendError(AuthenticationFlowContext context, String errorCode) {
63+
Map<String, String> errorEntity = new HashMap<>();
64+
errorEntity.put("error", errorCode);
65+
66+
Response response = Response.status(Response.Status.BAD_REQUEST)
67+
.entity(errorEntity)
68+
.type(MediaType.APPLICATION_JSON_TYPE)
69+
.build();
70+
71+
context.failure(AuthenticationFlowError.UNKNOWN_USER, response);
72+
}
73+
74+
@Override public void action(AuthenticationFlowContext context) {}
75+
@Override public boolean requiresUser() { return false; }
76+
@Override public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { return true; }
77+
@Override public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {}
78+
@Override public void close() {}
79+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package co.nilin.opex.keycloak.spi.endpoints;
2+
3+
import co.nilin.opex.keycloak.spi.BootstrapTokenGrantAuthenticator;
4+
import org.keycloak.Config;
5+
import org.keycloak.authentication.Authenticator;
6+
import org.keycloak.authentication.AuthenticatorFactory;
7+
import org.keycloak.models.AuthenticationExecutionModel;
8+
import org.keycloak.models.KeycloakSession;
9+
import org.keycloak.models.KeycloakSessionFactory;
10+
import org.keycloak.provider.ProviderConfigProperty;
11+
import java.util.Collections;
12+
import java.util.List;
13+
14+
public class BootstrapTokenGrantProviderFactory implements AuthenticatorFactory {
15+
16+
// 1. Change PROVIDER_ID to a simple name.
17+
// Do not use the URN here, as we are now intercepting the standard password grant.
18+
public static final String PROVIDER_ID = "bootstrap-token-grant";
19+
20+
private static final BootstrapTokenGrantAuthenticator SINGLETON = new BootstrapTokenGrantAuthenticator();
21+
22+
@Override
23+
public Authenticator create(KeycloakSession session) {
24+
return SINGLETON;
25+
}
26+
27+
@Override
28+
public String getId() {
29+
return PROVIDER_ID;
30+
}
31+
32+
@Override
33+
public String getDisplayType() {
34+
// This is the name you will see in the Keycloak Admin Console 'Add Step' list
35+
return "Opex Bootstrap Interceptor";
36+
}
37+
38+
@Override
39+
public String getReferenceCategory() {
40+
// Keep this as "grant" so it appears in the Direct Grant flow options
41+
return "grant";
42+
}
43+
44+
@Override
45+
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
46+
return new AuthenticationExecutionModel.Requirement[] {
47+
AuthenticationExecutionModel.Requirement.REQUIRED,
48+
AuthenticationExecutionModel.Requirement.ALTERNATIVE,
49+
AuthenticationExecutionModel.Requirement.DISABLED
50+
};
51+
}
52+
53+
@Override
54+
public boolean isConfigurable() {
55+
return false;
56+
}
57+
58+
@Override
59+
public String getHelpText() {
60+
return "Intercepts standard password grant to exchange a bootstrap_token for full tokens";
61+
}
62+
63+
@Override
64+
public void init(Config.Scope config) {}
65+
66+
@Override
67+
public void postInit(KeycloakSessionFactory factory) {}
68+
69+
@Override
70+
public void close() {}
71+
72+
@Override
73+
public int order() {
74+
return 0;
75+
}
76+
77+
@Override
78+
public List<ProviderConfigProperty> getConfigProperties() {
79+
return Collections.emptyList();
80+
}
81+
82+
@Override
83+
public boolean isUserSetupAllowed() {
84+
return false;
85+
}
86+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
co.nilin.opex.keycloak.spi.endpoints.BootstrapTokenGrantProviderFactory

0 commit comments

Comments
 (0)