Skip to content

Commit

Permalink
PoC for custom token migration
Browse files Browse the repository at this point in the history
  • Loading branch information
thomasdarimont committed Feb 23, 2024
1 parent 022ab75 commit ed4e1e1
Show file tree
Hide file tree
Showing 4 changed files with 158 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.github.thomasdarimont.keycloak.custom.endpoints.admin.AdminSettingsResource;
import com.github.thomasdarimont.keycloak.custom.endpoints.applications.ApplicationsInfoResource;
import com.github.thomasdarimont.keycloak.custom.endpoints.credentials.UserCredentialsInfoResource;
import com.github.thomasdarimont.keycloak.custom.endpoints.migration.TokenMigrationResource;
import com.github.thomasdarimont.keycloak.custom.endpoints.offline.OfflineSessionPropagationResource;
import com.github.thomasdarimont.keycloak.custom.endpoints.profile.UserProfileResource;
import com.github.thomasdarimont.keycloak.custom.endpoints.settings.UserSettingsResource;
Expand Down Expand Up @@ -108,4 +109,14 @@ public AdminSettingsResource adminSettings() {

return new AdminSettingsResource(session, authResult);
}

/**
* http://localhost:8080/auth/realms/acme-token-migration/custom-resources/migration/token
*
* @return
*/
@Path("migration/token")
public TokenMigrationResource migration() {
return new TokenMigrationResource(session, token);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package com.github.thomasdarimont.keycloak.custom.endpoints.migration;

import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.core.Request;
import jakarta.ws.rs.core.Response;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.keycloak.OAuth2Constants;
import org.keycloak.events.EventBuilder;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.services.Urls;
import org.keycloak.services.util.DefaultClientSessionContext;
import org.keycloak.util.TokenUtil;

import java.util.Set;

/**
* Example for migrating an existing offline session form client-1 to a client-2
*/
@RequiredArgsConstructor
public class TokenMigrationResource {

private static final Set<String> ALLOWED_MIGRATION_CLIENT_ID_PAIRS = Set.of("client-1:client-2", "client-1:client-3");

private final KeycloakSession session;

private final AccessToken token;

@POST
public Response migrateToken(Request request, TokenMigrationInput input) {

// validate token (X)
// validate source-client
// validate target-client
if (!isAllowedMigration(input)) {
return Response.status(Response.Status.BAD_REQUEST).build();
}

// lookup current client / user session referenced by token
var sid = token.getSessionId();
RealmModel realm = session.getContext().getRealm();
String issuer = Urls.realmIssuer(session.getContext().getUri().getBaseUri(), realm.getName());
UserSessionModel userSession = session.sessions().getUserSession(realm, sid);

ClientModel sourceClient = session.clients().getClientByClientId(realm, token.issuedFor);
ClientModel targetClient = session.clients().getClientByClientId(realm, input.getTargetClientId());

AuthenticatedClientSessionModel sourceClientAuthClientSession = userSession.getAuthenticatedClientSessionByClient(sourceClient.getId());
// propagate new target-client in session
session.getContext().setClient(targetClient);

// generate new client session
AuthenticatedClientSessionModel clientSession = session.sessions().createClientSession(realm, targetClient, userSession);
AuthenticatedClientSessionModel offlineClientSession = session.sessions().createOfflineClientSession(clientSession, userSession);
offlineClientSession.setNote(OAuth2Constants.SCOPE, sourceClientAuthClientSession.getNote(OAuth2Constants.SCOPE));

// generate new access token response (AT+RT) with azp=target-client
Set<String> clientScopeIds = Set.of();
ClientSessionContext clientSessionCtx = DefaultClientSessionContext.fromClientSessionAndClientScopeIds(offlineClientSession, clientScopeIds, session);

var event = new EventBuilder(realm, session);
event.detail("migration", "true");

TokenManager tokenManager = new TokenManager();
TokenManager.AccessTokenResponseBuilder responseBuilder = tokenManager.responseBuilder(realm, targetClient, event, this.session, userSession, clientSessionCtx);
responseBuilder.generateAccessToken();
responseBuilder.getAccessToken().issuer(issuer);
responseBuilder.getAccessToken().setScope(token.getScope());
responseBuilder.getAccessToken().issuedFor(targetClient.getClientId());
responseBuilder.generateRefreshToken();
responseBuilder.getRefreshToken().issuer(issuer);
responseBuilder.getRefreshToken().setScope(token.getScope());
responseBuilder.getRefreshToken().type(TokenUtil.TOKEN_TYPE_OFFLINE);
responseBuilder.getRefreshToken().issuedFor(targetClient.getClientId());

// skip generation of access token
responseBuilder.accessToken(null);

AccessTokenResponse accessTokenResponse = responseBuilder.build();

return Response.ok(accessTokenResponse).build();
}

private boolean isAllowedMigration(TokenMigrationInput input) {
return token != null && input != null && ALLOWED_MIGRATION_CLIENT_ID_PAIRS.contains(token.issuedFor + ":" + input.targetClientId);
}

@Data
public static class TokenMigrationInput {

@JsonProperty("target_client_id")
String targetClientId;
}
}
32 changes: 32 additions & 0 deletions keycloak/http-tests/custom-token-migration.http
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
### Resource Owner Password Credentials Grant Flow with Public Client
POST {{ISSUER}}/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded

client_id={{CLIENT_ID_1}}&username={{USER_USERNAME}}&password={{USER_PASSWORD}}&grant_type=password&scope=profile offline_access

> {%
client.global.set("KC_ACCESS_TOKEN", response.body.access_token);
client.global.set("KC_REFRESH_TOKEN", response.body.refresh_token);
%}

####

### Call custom token migration endpoint
POST {{ISSUER}}/custom-resources/migration/token
Content-Type: application/json
Authorization: Bearer {{KC_ACCESS_TOKEN}}

{
"target_client_id": "client-2"
}

> {%
client.global.set("KC_ACCESS_TOKEN_NEW", response.body.access_token);
client.global.set("KC_REFRESH_TOKEN_NEW", response.body.refresh_token);
%}

### Obtain new Tokens via RefreshToken
POST {{ISSUER}}/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded

client_id={{CLIENT_ID_2}}&grant_type=refresh_token&refresh_token={{KC_REFRESH_TOKEN_NEW}}
12 changes: 12 additions & 0 deletions keycloak/http-tests/http-client.env.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,17 @@
"APIKEY": "api-user-42:7FTt1Q0PG5yv3YqaZGhEB19KIollpNFurA",
"API_GATEWAY_CLIENT": "acme-api-gateway",
"API_GATEWAY_CLIENT_SECRET": "secret"
},

"custom-token-migration": {
"ISSUER": "https://id.acme.test:8443/auth/realms/acme-token-migration",
"ADMIN_USERNAME": "admin",
"ADMIN_PASSWORD": "admin",
"USER_USERNAME": "tester",
"USER_PASSWORD": "test",
"CLIENT_SECRET": "secret",
"CLIENT_ID_1": "client-1",
"CLIENT_ID_2": "client-2",
"CLIENT_ID_3": "client-3"
}
}

0 comments on commit ed4e1e1

Please sign in to comment.