Skip to content

Instantly share code, notes, and snippets.

@thomasdarimont
Last active April 12, 2024 10:53
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save thomasdarimont/f6a6d5ede58000b01c16dc9221a9cc55 to your computer and use it in GitHub Desktop.
Save thomasdarimont/f6a6d5ede58000b01c16dc9221a9cc55 to your computer and use it in GitHub Desktop.
PoC for Federated Credential Management API support in Keycloak
package com.thomasdarimont.training.keycloak.endpoints;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.FormParam;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Request;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder;
import org.keycloak.OAuth2Constants;
import org.keycloak.events.EventBuilder;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.KeycloakContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.services.Urls;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.sessions.AuthenticationSessionModel;
import org.keycloak.sessions.RootAuthenticationSessionModel;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* See: https://developers.google.com/privacy-sandbox/3pcd/fedcm-developer-guide
*/
public class FedCmResource {
private final KeycloakSession session;
public FedCmResource(KeycloakSession session) {
this.session = session;
}
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("config.json")
public Response getConfig() {
KeycloakContext context = session.getContext();
UriBuilder fedCmUriBuilder = UriBuilder.fromUri(context.getUri().getRequestUri().resolve("."));
UriBuilder realmUriBuilder = UriBuilder.fromUri(context.getUri().getRequestUri().resolve("../.."));
// See: https://developers.google.com/privacy-sandbox/3pcd/fedcm-developer-guide
/*
{
"accounts_endpoint": "/accounts.php",
"client_metadata_endpoint": "/client_metadata.php",
"id_assertion_endpoint": "/assertion.php",
"disconnect_endpoint": "/disconnect.php",
"login_url": "/login",
"branding": {
"background_color": "green",
"color": "#FFEEAA",
"icons": [{
"url": "https://idp.example/icon.ico",
"size": 25
}]
}
}
*/
FedCmConfig config = new FedCmConfig();
config.accountsEndpoint = fedCmUriBuilder.clone().path("accounts").build().toString();
config.clientMetadataEndpoint = fedCmUriBuilder.clone().path("client_metadata").build().toString();
config.idAssertionEndpoint = fedCmUriBuilder.clone().path("identity_assertion").build().toString();
config.disconnectEndpoint = fedCmUriBuilder.clone().path("disconnect").build().toString();
// use the redirect url for logging in
// TODO figure out proper way to generate login url
config.loginUrl = realmUriBuilder.clone().path("clients/{clientId}/redirect").build("acme-minispa").toString();
// TODO Pull branding information from realm attributes / theme
FedCmBranding branding = new FedCmBranding();
branding.backgroundColor = "rose";
branding.color = "black";
branding.icons.add(new FedCmBrandingIcon("https://www.keycloak.org/resources/favicon.ico", 25));
config.branding = branding;
return Response.ok(config).build();
}
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("accounts")
public Response accounts() {
checkFedCmRequest();
/*
{
"accounts": [{
"id": "1234",
"given_name": "John",
"name": "John Doe",
"email": "john_doe@idp.example",
"picture": "https://idp.example/profile/123",
"approved_clients": ["123", "456", "789"],
"login_hints": ["demo1", "demo1@idp.example"]
}, ...]
}
*/
KeycloakContext context = session.getContext();
AuthenticationManager.AuthResult authResult = AuthenticationManager.authenticateIdentityCookie(session, context.getRealm(), true);
if (authResult == null) {
return Response.status(Response.Status.UNAUTHORIZED).build();
}
UserModel user = authResult.getUser();
List<FedCmAccountInfo> accounts = new ArrayList<>();
FedCmAccountInfo accountInfo = createAccountInfo(context, user);
accounts.add(accountInfo);
var accountsResponse = Map.of("accounts", accounts);
return Response.ok(accountsResponse).build();
}
private FedCmAccountInfo createAccountInfo(KeycloakContext context, UserModel user) {
String domain = context.getUri().getBaseUri().getHost();
FedCmAccountInfo accountInfo = new FedCmAccountInfo();
accountInfo.id = user.getId();
accountInfo.givenName = user.getFirstName();
accountInfo.name = user.getFirstName() + " " + user.getLastName();
accountInfo.email = user.getEmail();
accountInfo.picture = user.getFirstAttribute("picture");
accountInfo.approvedClients = Set.of("acme-minispa"); // TODO how to determine the list of approved clients? (clients with autologin)
accountInfo.loginHints = Set.of(user.getUsername(), user.getEmail());
accountInfo.domainHints = Set.of(domain);
return accountInfo;
}
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("client_metadata")
public Response clientMetadata(@QueryParam("client_id") String clientId) {
checkFedCmRequest();
KeycloakContext context = session.getContext();
String origin = context.getRequestHeaders().getHeaderString("Origin");
/*
{
"privacy_policy_url": "https://rp.example/privacy_policy.html",
"terms_of_service_url": "https://rp.example/terms_of_service.html",
}
*/
ClientModel client = session.clients().getClientByClientId(context.getRealm(), clientId);
if (client == null) {
Map<Object, Object> error = Map.of( //
"code", "invalid_request", //
"url", "https://idp.example/error?type=invalid_request" //
);
return Response.status(Response.Status.BAD_REQUEST).entity(error).build();
}
// TODO implement required Origin check
// if (RedirectUtils.verifyRedirectUri(session, origin, client) == null) {
// Map<Object, Object> error = Map.of( //
// "code", "invalid_request", //
// "url", "https://idp.example/error?type=invalid_request" //
// );
// return Response.status(Response.Status.BAD_REQUEST).entity(error).build();
// }
FedCmClientMetadata clientMetadata = createClientMetadata(session, client, context);
return Response.ok(clientMetadata).build();
}
private FedCmClientMetadata createClientMetadata(KeycloakSession session, ClientModel client, KeycloakContext context) {
String policyUri = client.getAttribute("policyUri");
if (policyUri == null) {
policyUri = "https://apps.training.test/apps/site/privacy.html";
}
String tosUri = client.getAttribute("tosUri");
if (tosUri == null) {
tosUri = "https://apps.training.test/apps/site/terms.html";
}
FedCmClientMetadata clientMetadata = new FedCmClientMetadata();
clientMetadata.privacyPolicyUrl = policyUri;
clientMetadata.termsOfServiceUrl = tosUri;
return clientMetadata;
}
/**
* @param clientId (required) The RP's client identifier.
* @param accountId (required) The RP's client identifier.
* @param nonce (optional) The request nonce, provided by the RP.
* @param disclosureTextShown Results in a string of "true" or "false" (rather than a boolean). The result is "false" if the disclosure text was not shown. This happens when the RP's client ID was included in the approved_clients property list of the response from the accounts list endpoint or if the browser has observed a sign-up moment in the past in the absence of approved_clients.
* @param isAutoSelected If auto-reauthentication is performed on the RP, is_auto_selected indicates "true". Otherwise "false". This is helpful to support more security related features. For example, some users may prefer a higher security tier which requires explicit user mediation in authentication. If an IdP receives a token request without such mediation, they could handle the request differently. For example, return an error code such that the RP can call the FedCM API again with mediation: required.
*/
@POST
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Produces(MediaType.APPLICATION_JSON)
@Path("identity_assertion")
public Response identityAssertion(@FormParam("client_id") String clientId, //
@FormParam("account_id") String accountId, //
@FormParam("nonce") String nonce, //
@FormParam("disclosure_text_shown") Boolean disclosureTextShown, //
@FormParam("is_auto_selected") Boolean isAutoSelected //
) {
checkFedCmRequest();
// TODO check if origin is allowed by RP! Origin: https://rp.example/
var identityAssertionRequest = new FedCmIdentityAssertionRequest();
identityAssertionRequest.clientId = clientId;
identityAssertionRequest.accountId = accountId;
identityAssertionRequest.nonce = nonce;
identityAssertionRequest.disclosureTextShown = disclosureTextShown;
identityAssertionRequest.isAutoSelected = isAutoSelected;
KeycloakContext context = session.getContext();
AuthenticationManager.AuthResult authResult = AuthenticationManager.authenticateIdentityCookie(session, context.getRealm(), true);
if (authResult == null) {
return Response.status(Response.Status.UNAUTHORIZED).build();
}
UserModel user = authResult.getUser();
if (!user.getId().equals(accountId)) {
Map<Object, Object> error = Map.of( //
"code", "invalid_request", //
"url", "https://idp.example/error?type=invalid_request" //
);
return Response.status(Response.Status.BAD_REQUEST).entity(error).build();
}
Map<String, Object> tokenResponse = Map.of("token", createToken(session, authResult, context, identityAssertionRequest));
return Response.ok(tokenResponse) //
.header("Set-Login", "logged-in") //
.build();
}
private Object createToken(KeycloakSession session, AuthenticationManager.AuthResult authResult, KeycloakContext context, FedCmIdentityAssertionRequest identityAssertionRequest) {
UserSessionModel userSession = authResult.getSession();
RealmModel realm = context.getRealm();
ClientModel client = session.clients().getClientByClientId(realm, identityAssertionRequest.clientId);
RootAuthenticationSessionModel rootAuthenticationSession = session.authenticationSessions().createRootAuthenticationSession(realm);
AuthenticationSessionModel authenticationSession = rootAuthenticationSession.createAuthenticationSession(client);
authenticationSession.setClientScopes(client.getClientScopes(true).keySet());
ClientSessionContext clientSessionContext = TokenManager.attachAuthenticationSession(session, userSession, authenticationSession);
TokenManager tm = new TokenManager();
EventBuilder event = new EventBuilder(realm, session, context.getConnection());
TokenManager.AccessTokenResponseBuilder responseBuilder = tm //
.responseBuilder(realm, client, event, session, userSession, clientSessionContext) //
.generateAccessToken();
responseBuilder.getAccessToken().issuer(Urls.realmBase(context.getUri().getBaseUri()).path("{realm}").build(realm.getName()).toString());
responseBuilder.getAccessToken().setScope(OAuth2Constants.SCOPE_OPENID);
return responseBuilder.build().getToken();
}
/**
* @param accountHint A hint for the IdP account.
* @param clientId The RP's client identifier.
* @return
*/
@POST
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Produces(MediaType.APPLICATION_JSON)
@Path("disconnect")
public Response disconnect(@FormParam("account_hint") String accountHint, @FormParam("client_id") String clientId) {
checkFedCmRequest();
KeycloakContext context = session.getContext();
AuthenticationManager.AuthResult authResult = AuthenticationManager.authenticateIdentityCookie(session, context.getRealm(), true);
if (authResult == null) {
return Response.status(Response.Status.UNAUTHORIZED).build();
}
UserModel user = authResult.getUser();
if (!user.getId().equals(accountHint)) {
Map<Object, Object> error = Map.of("code", "invalid_request", "url", "https://idp.example/error?type=invalid_request");
return Response.status(Response.Status.BAD_REQUEST).entity(error).build();
}
// TODO perform "disconnect"
FedCmDisconnectResponse disconnectResponse = new FedCmDisconnectResponse();
disconnectResponse.accountId = user.getId();
return Response.ok(disconnectResponse).build();
}
@GET
public Response login(Request request) {
return Response.ok().entity("logging in...").build();
}
public static class FedCmAccountInfo {
/**
* id (required) Unique ID of the user.
*/
@JsonProperty("id")
String id;
/**
* given_name (optional) Given name of the user.
*/
@JsonProperty("given_name")
String givenName;
/**
* name (required) Given and family name of the user.
*/
@JsonProperty("name")
String name;
/**
* email (required) Email address of the user.
*/
@JsonProperty("email")
String email;
/**
* picture (optional) URL of the user avatar image.
*/
@JsonProperty("picture")
String picture;
/**
* approved_clients (optional) An array of RP client IDs which the user has registered with.
*/
@JsonProperty("approved_clients")
Set<String> approvedClients;
/**
* login_hints (optional) An array of all possible filter types that the IdP supports to specify an account. The RP can invoke navigator.credentials.get() with the loginHint property to selectively show the specified account.
*/
@JsonProperty("login_hints")
Set<String> loginHints;
/**
* domain_hints (optional) An array of all the domains the account is associated with. The RP can call navigator.credentials.get() with a domainHint property to filter the accounts.
*/
@JsonProperty("domain_clients")
Set<String> domainHints;
}
public static class FedCmClientMetadata {
/**
* privacy_policy_url (optional) RP privacy policy URL.
*/
@JsonProperty("privacy_policy_url")
String privacyPolicyUrl;
/**
* terms_of_service_url (optional) RP terms of service URL.
*/
@JsonProperty("terms_of_service_url")
String termsOfServiceUrl;
}
private void checkFedCmRequest() {
String secFetchDest = session.getContext().getRequestHeaders().getHeaderString("Sec-Fetch-Dest");
if (!"webidentity".equals(secFetchDest)) {
throw new WebApplicationException(Response.Status.BAD_REQUEST);
}
}
public static class FedCmIdentityAssertionRequest {
/**
* client_id (required) The RP's client identifier.
*/
@JsonProperty("client_id")
String clientId;
/**
* client_id (required) The RP's client identifier.
*/
@JsonProperty("account_id")
String accountId;
/**
* nonce (optional) The request nonce, provided by the RP.
*/
@JsonProperty("nounce")
String nonce;
/**
* disclosure_text_shown Results in a string of "true" or "false" (rather than a boolean). The result is "false" if the disclosure text was not shown. This happens when the RP's client ID was included in the approved_clients property list of the response from the accounts list endpoint or if the browser has observed a sign-up moment in the past in the absence of approved_clients.
*/
@JsonProperty("disclosure_text_shown")
Boolean disclosureTextShown;
/**
* is_auto_selected If auto-reauthentication is performed on the RP, is_auto_selected indicates "true". Otherwise "false". This is helpful to support more security related features. For example, some users may prefer a higher security tier which requires explicit user mediation in authentication. If an IdP receives a token request without such mediation, they could handle the request differently. For example, return an error code such that the RP can call the FedCM API again with mediation: required.
*/
@JsonProperty("is_auto_selected")
Boolean isAutoSelected;
}
public static class FedCmDisconnectResponse {
@JsonProperty("account_id") // TODO use userId here?
String accountId;
}
/*
* {
* "accounts_endpoint": "/accounts.php",
* "client_metadata_endpoint": "/client_metadata.php",
* "id_assertion_endpoint": "/assertion.php",
* "disconnect_endpoint": "/disconnect.php",
* "login_url": "/login",
* "branding": {
* "background_color": "green",
* "color": "#FFEEAA",
* "icons": [{
* "url": "https://idp.example/icon.ico",
* "size": 25
* }]
* }
* }
*/
public static class FedCmConfig {
/**
* URL for the accounts list endpoint.
* Required
*/
@JsonProperty("accounts_endpoint")
String accountsEndpoint;
/**
* URL for the client metadata endpoint.
* Optional
*/
@JsonProperty("client_metadata_endpoint")
String clientMetadataEndpoint;
/**
* URL for the ID assertion endpoint.
* Required
*/
@JsonProperty("id_assertion_endpoint")
String idAssertionEndpoint;
/**
* URL for the disconnect endpoint.
* Optional
*/
@JsonProperty("disconnect_endpoint")
String disconnectEndpoint;
/**
* The login page URL for the user to sign in to the IdP.
* Required
*/
@JsonProperty("login_url")
String loginUrl;
/**
* Object which contains various branding options.
*/
@JsonProperty("branding")
FedCmBranding branding;
}
/**
* Object which contains various branding options.
*/
public static class FedCmBranding {
/**
* Branding option which sets the background color of the "Continue as..." button. Use the relevant CSS syntax, namely hex-color, hsl(), rgb(), or named-color.
* Optional
*/
@JsonProperty("background_color")
String backgroundColor;
/**
* Branding option which sets the text color of the "Continue as..." button. Use the relevant CSS syntax, namely hex-color, hsl(), rgb(), or named-color.
* Optional
*/
@JsonProperty("color")
String color;
/**
* Branding option which sets the icon object, displayed in the sign-in dialog. The icon object is an array with two parameters:
* url (required): URL of the icon image. This does not support SVG images.
* size (optional): icon dimensions, assumed by the application to be square and single resolution. This number must be greater than or equal to 25.
*/
@JsonProperty("icons")
private List<FedCmBrandingIcon> icons = new ArrayList<>();
}
public static class FedCmBrandingIcon {
/**
* url (required): URL of the icon image. This does not support SVG images.
*/
@JsonProperty("url")
String url;
/**
* size (optional): icon dimensions, assumed by the application to be square and single resolution. This number must be greater than or equal to 25.
*/
@JsonProperty("size")
int size;
public FedCmBrandingIcon(String url, int size) {
this.url = url;
this.size = size;
}
}
}
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Keycloak Dev Training</title>
</head>
<body>
<h1>Keycloak FedCM Demo</h1>
<div>
<button onclick="trySignIn()">Test FedCM API</button>
<h2 id="greeting"></h2>
</div>
<script defer>
async function initFedCm() {
let nonce = "1234";
// could be provided from new keycloak endpoint
let idpConfig = {
configURL: 'https://id.training.test/auth/realms/acme/acme-endpoints/fedcm/config.json',
clientId: 'acme-minispa',
nonce: nonce,
// loginHint : "tester"
};
let context = 'signin'; // Optional property can be one of "signin" (default), "signup", "use" or "continue"
try {
const credential = await navigator.credentials.get({
identity: {
context: context,
providers: [idpConfig]
}
});
const {token} = credential;
console.log("received token", token);
let userInfoResponse = await sendRequest("https://id.training.test/auth/realms/acme/protocol/openid-connect/userinfo", token);
let userInfoPayload = await userInfoResponse.json()
document.querySelector("#greeting").innerHTML = "Hello " + (userInfoPayload.name || userInfoPayload.sub);
} catch (e) {
const code = e.code;
const url = e.url;
console.error(e);
}
}
async function sendRequest(url, token, requestOptions) {
let requestData = {
timeout: 2000,
method: "GET",
headers: {
"Authorization": "Bearer " + token,
"Accept": "application/json",
'Content-Type': 'application/json'
}
, ...requestOptions
}
return await fetch(url, requestData);
}
async function trySignIn() {
if ('IdentityCredential' in window) {
console.log("fedcm api detected");
let response = await initFedCm();
// If the feature is available, take action
} else {
console.log("fedcm api NOT detected");
}
}
</script>
</body>
</html>
{
"provider_urls": ["https://id.training.test/auth/realms/acme/acme-endpoints/fedcm/config.json"]
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment