Add oid4vci to the account console (#29174)

closes #25945

Signed-off-by: Stefan Wiedemann <wistefan@googlemail.com>


Co-authored-by: Erik Jan de Wit <edewit@redhat.com>
Co-authored-by: Jon Koops <jonkoops@gmail.com>
This commit is contained in:
Stefan Wiedemann 2024-05-31 15:11:32 +02:00 committed by GitHub
parent af23150343
commit 0f6f9543ba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 584 additions and 19 deletions

View file

@ -188,7 +188,7 @@ jobs:
- name: Start Keycloak server
run: |
tar xfvz keycloak-999.0.0-SNAPSHOT.tar.gz
keycloak-999.0.0-SNAPSHOT/bin/kc.sh start-dev --features=transient-users &> ~/server.log &
keycloak-999.0.0-SNAPSHOT/bin/kc.sh start-dev --features=transient-users,oid4vc-vci &> ~/server.log &
env:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin

View file

@ -192,3 +192,10 @@ recovery-authn-codes-help-text=These codes can be used to regain your access in
recovery-codes-number-used={0} recovery codes used
recovery-codes-number-remaining={0} recovery codes remaining
recovery-codes-generate-new-codes=Generate new codes to ensure access to your account
oid4vci=Verifiable Credentials
verifiableCredentialsTitle=Verifiable Credentials
verifiableCredentialsDescription=Select the credential for import into your wallet.
verifiableCredentialsIssuerAlert=Was not able to retrieve the issuer information.
verifiableCredentialsConfigAlert=Was not able to retrieve the credential configuration.
verifiableCredentialsOfferAlert=Was not able to retrieve an offer.
verifiableCredentialsSelectionDefault=Select a credential configuration.

View file

@ -165,7 +165,8 @@
"deleteAccountAllowed": ${deleteAccountAllowed?c},
"updateEmailFeatureEnabled": ${updateEmailFeatureEnabled?c},
"updateEmailActionEnabled": ${updateEmailActionEnabled?c},
"isViewGroupsEnabled": ${isViewGroupsEnabled?c}
"isViewGroupsEnabled": ${isViewGroupsEnabled?c},
"isOid4VciEnabled": ${isOid4VciEnabled?c}
},
"referrerName": "${referrerName!""}",
"referrerUrl": "${referrer_uri!""}"

View file

@ -22,5 +22,10 @@
"label": "resources",
"path": "resources",
"isVisible": "isMyResourcesEnabled"
},
{
"label": "oid4vci",
"path": "oid4vci",
"isVisible":"isOid4VciEnabled"
}
]

View file

@ -3,8 +3,9 @@ import { BaseEnvironment } from "@keycloak/keycloak-ui-shared/dist/context/envir
import { CallOptions } from "./api/methods";
import { Links, parseLinks } from "./api/parse-links";
import { parseResponse } from "./api/parse-response";
import { Permission, Resource, Scope } from "./api/representations";
import { Permission, Resource, Scope, CredentialsIssuer, SupportedCredentialConfiguration } from "./api/representations";
import { request } from "./api/request";
import { joinPath } from "./utils/joinPath";
export const fetchResources = async (
{ signal, context }: CallOptions,
@ -68,3 +69,34 @@ function checkResponse<T>(response: T) {
if (!response) throw new Error("Could not fetch");
return response;
}
export async function getIssuer(
context: KeycloakContext<BaseEnvironment>
) {
const response = await request(
"/realms/" + context.environment.realm + "/.well-known/openid-credential-issuer",
context,
{},
new URL(
joinPath(context.environment.authUrl + "/realms/" + context.environment.realm + "/.well-known/openid-credential-issuer"),
),
);
return parseResponse<CredentialsIssuer>(response);
}
export async function requestVCOffer(
context: KeycloakContext<BaseEnvironment>,
supportedCredentialConfiguration:SupportedCredentialConfiguration,
credentialsIssuer:CredentialsIssuer
) {
const response = await request(
"/protocol/oid4vc/credential-offer-uri",
context,
{ searchParams: {"credential_configuration_id": supportedCredentialConfiguration.id, "type": "qr-code", "width": "500", "height": "500"} },
new URL(
joinPath(credentialsIssuer.credential_issuer + "/protocol/oid4vc/credential-offer-uri"),
),
);
return response.blob()
}

View file

@ -202,3 +202,15 @@ export interface Group {
name: string;
path: string;
}
export interface SupportedCredentialConfiguration {
id: string;
format: string;
scope: string;
}
export interface CredentialsIssuer {
credential_issuer: string;
credential_endpoint: string;
authorization_servers: string[];
credential_configurations_supported: Record<string, SupportedCredentialConfiguration>
}

View file

@ -37,8 +37,12 @@ export async function request(
path: string,
{ environment, keycloak }: KeycloakContext<BaseEnvironment>,
opts: RequestOptions = {},
fullUrl?: URL
) {
return _request(url(environment, path), {
if (typeof fullUrl === 'undefined') {
fullUrl = url(environment, path)
}
return _request(fullUrl, {
...opts,
getAccessToken: token(keycloak),
});

View file

@ -38,6 +38,7 @@ export { Resources } from "./resources/Resources";
export { ResourcesTab } from "./resources/ResourcesTab";
export { ResourceToolbar } from "./resources/ResourceToolbar";
export { SharedWith } from "./resources/SharedWith";
export { Oid4Vci } from "./oid4vci/Oid4Vci";
export { ShareTheResource } from "./resources/ShareTheResource";
export {
deleteConsent,

View file

@ -0,0 +1,132 @@
import {
Select,
SelectList,
SelectOption,
PageSectionVariants,
PageSection,
ActionList,
ActionListItem,
List,
ListItem,
MenuToggleElement,
MenuToggle
} from '@patternfly/react-core';
import { useEffect, useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useAlerts, useEnvironment } from "@keycloak/keycloak-ui-shared";
import { usePromise } from "../utils/usePromise";
import { Page } from "../components/page/Page";
import { CredentialsIssuer } from "../api/representations";
import { getIssuer, requestVCOffer } from "../api";
export const Oid4Vci = () => {
const context = useEnvironment();
const { t } = useTranslation();
const initialSelected = t('verifiableCredentialsSelectionDefault')
const [selected, setSelected] = useState<string>(initialSelected);
const [qrCode, setQrCode] = useState<string>("")
const [isOpen, setIsOpen] = useState<boolean>(false)
const [offerQRVisible, setOfferQRVisible] = useState<boolean>(false)
const [credentialsIssuer, setCredentialsIssuer] = useState<CredentialsIssuer>()
usePromise(() => getIssuer(context), setCredentialsIssuer);
const selectOptions = useMemo(
() => {
if(typeof credentialsIssuer !== 'undefined') {
return credentialsIssuer.credential_configurations_supported
}
return {}
},
[credentialsIssuer],
)
const dropdownItems = useMemo(
() => {
if (typeof selectOptions !== 'undefined') {
return Array.from(Object.keys(selectOptions))
}
return []
},
[selectOptions],
)
useEffect(() => {
if(initialSelected !== selected && credentialsIssuer !== undefined){
requestVCOffer(context, selectOptions[selected], credentialsIssuer)
.then((blob) => {
var reader = new FileReader();
reader.readAsDataURL(blob)
reader.onloadend = function() {
let result = reader.result
if (typeof result === "string") {
setQrCode(result);
setOfferQRVisible(true);
setIsOpen(false);
}
}
})
}
}, [selected]);
const onToggleClick = () => {
setIsOpen(!isOpen);
};
const toggle = (toggleRef: React.Ref<MenuToggleElement>) => (
<MenuToggle
ref={toggleRef}
onClick={onToggleClick}
isExpanded={isOpen}
data-testid="menu-toggle"
>
{selected}
</MenuToggle>
);
return (
<Page title={t('verifiableCredentialsTitle')} description={t('verifiableCredentialsDescription')}>
<PageSection isFilled variant={PageSectionVariants.light}>
<List isPlain>
<ListItem>
<Select
data-testid="credential-select"
onOpenChange={(isOpen) => setIsOpen(isOpen)}
onSelect={(_event, val) => setSelected(val as string)}
isOpen={isOpen}
selected={selected}
toggle={toggle}
shouldFocusToggleOnSelect={true}
>
<SelectList>
{dropdownItems.map((option, index) => (
<SelectOption
value={option}
data-testid='select-${option}'
>
{option}
</SelectOption>
))}
</SelectList>
</Select>
</ListItem>
<ListItem>
<ActionList>
{ offerQRVisible &&
<ActionListItem>
<img width='500' height='500' src={`${qrCode}`} data-testid="qr-code"/>
</ActionListItem>
}
</ActionList>
</ListItem>
</List>
</PageSection>
</Page>
);
};
export default Oid4Vci;

View file

@ -12,6 +12,7 @@ const Groups = lazy(() => import("./groups/Groups"));
const PersonalInfo = lazy(() => import("./personal-info/PersonalInfo"));
const Resources = lazy(() => import("./resources/Resources"));
const ContentComponent = lazy(() => import("./content/ContentComponent"));
const Oid4Vci = lazy(() => import("./oid4vci/Oid4Vci"));
export const DeviceActivityRoute: RouteObject = {
path: "account-security/device-activity",
@ -57,6 +58,11 @@ export const PersonalInfoRoute: IndexRouteObject = {
element: <PersonalInfo />,
};
export const Oid4VciRoute: RouteObject = {
path: "oid4vci",
element: <Oid4Vci />,
}
export const RootRoute: RouteObject = {
path: decodeURIComponent(new URL(environment.baseUrl).pathname),
element: <Root />,
@ -71,6 +77,7 @@ export const RootRoute: RouteObject = {
PersonalInfoRoute,
ResourcesRoute,
ContentRoute,
Oid4VciRoute,
],
};

View file

@ -0,0 +1,19 @@
import { expect, test } from "@playwright/test";
import { login } from "../login";
const realm = "verifiable-credentials"
test.describe("Verifiable Credentials page", () => {
test("Get offer for test-credential.", async ({ page }) => {
await login(page, "test-user", "test")
await expect(page.getByTestId("qr-code")).toBeHidden
await page.getByTestId("oid4vci").click
await page.getByTestId("credential-select").click
await expect(page.getByTestId("select-verifiable-credential")).toBeVisible
await expect(page.getByTestId("select-natural-person")).toBeVisible
await page.getByTestId("select-natural-person").click
await expect(page.getByTestId("qr-code")).toBeVisible
})
})

View file

@ -5,9 +5,11 @@ import { importRealm } from "./admin-client";
import groupsRealm from "./realms/groups-realm.json" assert { type: "json" };
import resourcesRealm from "./realms/resources-realm.json" assert { type: "json" };
import userProfileRealm from "./realms/user-profile-realm.json" assert { type: "json" };
import verifiableCredentialsRealm from "./realms/verifiable-credentials-realm.json" assert { type: "json" };
setup("import realm", async () => {
await importRealm(groupsRealm as RealmRepresentation);
await importRealm(resourcesRealm as RealmRepresentation);
await importRealm(userProfileRealm as RealmRepresentation);
await importRealm(verifiableCredentialsRealm as RealmRepresentation);
});

View file

@ -5,4 +5,5 @@ setup("delete realm", async () => {
await deleteRealm("photoz");
await deleteRealm("groups");
await deleteRealm("user-profile");
await deleteRealm("verifiable-credentials");
});

File diff suppressed because one or more lines are too long

View file

@ -12,6 +12,7 @@ export type Feature = {
updateEmailFeatureEnabled: boolean;
updateEmailActionEnabled: boolean;
isViewGroupsEnabled: boolean;
isOid4VciEnabled: boolean;
};
export type BaseEnvironment = {
@ -83,6 +84,7 @@ const defaultEnvironment: AdminEnvironment & AccountEnvironment = {
updateEmailFeatureEnabled: true,
updateEmailActionEnabled: true,
isViewGroupsEnabled: true,
isOid4VciEnabled: false,
},
};

View file

@ -19,8 +19,16 @@ package org.keycloak.protocol.oid4vc.issuance;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.WriterException;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import com.google.zxing.qrcode.encoder.QRCode;
import jakarta.annotation.Nullable;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
@ -29,6 +37,7 @@ import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.Response;
import org.apache.http.HttpStatus;
import org.jboss.logging.Logger;
import org.keycloak.common.util.SecretGenerator;
import org.keycloak.models.AuthenticatedClientSessionModel;
@ -50,6 +59,7 @@ import org.keycloak.protocol.oid4vc.model.ErrorResponse;
import org.keycloak.protocol.oid4vc.model.ErrorType;
import org.keycloak.protocol.oid4vc.model.Format;
import org.keycloak.protocol.oid4vc.model.OID4VCClient;
import org.keycloak.protocol.oid4vc.model.OfferUriType;
import org.keycloak.protocol.oid4vc.model.PreAuthorizedCode;
import org.keycloak.protocol.oid4vc.model.PreAuthorizedGrant;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
@ -59,8 +69,14 @@ import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.utils.MediaType;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Base64;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
@ -81,6 +97,7 @@ public class OID4VCIssuerEndpoint {
public static final String CREDENTIAL_PATH = "credential";
public static final String CREDENTIAL_OFFER_PATH = "credential-offer/";
public static final String RESPONSE_TYPE_IMG_PNG = "image/png";
private final KeycloakSession session;
private final AppAuthManager.BearerTokenAuthenticator bearerTokenAuthenticator;
private final ObjectMapper objectMapper;
@ -107,18 +124,18 @@ public class OID4VCIssuerEndpoint {
}
/**
* Provides the URI to the OID4VCI compliant credentials offer
*/
@GET
@Produces(MediaType.APPLICATION_JSON)
@Produces({MediaType.APPLICATION_JSON, RESPONSE_TYPE_IMG_PNG})
@Path("credential-offer-uri")
public Response getCredentialOfferURI(@QueryParam("credential_configuration_id") String vcId) {
public Response getCredentialOfferURI(@QueryParam("credential_configuration_id") String vcId, @QueryParam("type") @DefaultValue("uri") OfferUriType type, @QueryParam("width") @DefaultValue("200") int width, @QueryParam("height") @DefaultValue("200") int height) {
AuthenticatedClientSessionModel clientSession = getAuthenticatedClientSession();
Map<String, SupportedCredentialConfiguration> credentialsMap = OID4VCIssuerWellKnownProvider.getSupportedCredentials(session);
LOGGER.debugf("Get an offer for %s", vcId);
if (!credentialsMap.containsKey(vcId)) {
LOGGER.debugf("No credential with id %s exists.", vcId);
@ -142,14 +159,36 @@ public class OID4VCIssuerEndpoint {
throw new BadRequestException(getErrorResponse(ErrorType.INVALID_CREDENTIAL_REQUEST));
}
return switch (type) {
case URI -> getOfferUriAsUri(nonce);
case QR_CODE -> getOfferUriAsQr(nonce, width, height);
};
}
private Response getOfferUriAsUri(String nonce) {
CredentialOfferURI credentialOfferURI = new CredentialOfferURI()
.setIssuer(OID4VCIssuerWellKnownProvider.getIssuer(session.getContext()) + "/protocol/" + OID4VCLoginProtocolFactory.PROTOCOL_ID + "/" + CREDENTIAL_OFFER_PATH)
.setNonce(nonce);
return Response.ok()
.type(MediaType.APPLICATION_JSON)
.entity(credentialOfferURI)
.build();
}
private Response getOfferUriAsQr(String nonce, int width, int height) {
QRCodeWriter qrCodeWriter = new QRCodeWriter();
String endcodedOfferUri = URLEncoder.encode(OID4VCIssuerWellKnownProvider.getIssuer(session.getContext()) + "/protocol/" + OID4VCLoginProtocolFactory.PROTOCOL_ID + "/" + CREDENTIAL_OFFER_PATH + nonce, StandardCharsets.UTF_8);
try {
BitMatrix bitMatrix = qrCodeWriter.encode("openid-credential-offer://?credential_offer_uri=" + endcodedOfferUri, BarcodeFormat.QR_CODE, width, height);
ByteArrayOutputStream bos = new ByteArrayOutputStream();
MatrixToImageWriter.writeToStream(bitMatrix, "png", bos);
return Response.ok().type(RESPONSE_TYPE_IMG_PNG).entity(bos.toByteArray()).build();
} catch (WriterException | IOException e) {
LOGGER.warnf("Was not able to create a qr code of dimension %s:%s.", width, height, e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity("Was not able to generate qr.").build();
}
}
/**

View file

@ -0,0 +1,57 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.protocol.oid4vc.model;
import com.fasterxml.jackson.annotation.JsonCreator;
import java.util.Optional;
/**
* Type of credential offer uri to be returned.
*
* @author <a href="https://github.com/wistefan">Stefan Wiedemann</a>
*/
public enum OfferUriType {
URI("uri"),
QR_CODE("qr-code");
private final String value;
OfferUriType(String value) {
this.value = value;
}
public String getValue() {
return value;
}
@JsonCreator
public static OfferUriType fromString(String value) {
return Optional.ofNullable(value)
.map(v -> {
if (v.equals(URI.getValue())) {
return URI;
} else if (v.equals(QR_CODE.getValue())) {
return QR_CODE;
} else return null;
})
.orElseThrow(() -> new IllegalArgumentException(String.format("%s is not a supported OfferUriType.", value)));
}
}

View file

@ -12,11 +12,13 @@ import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder;
import jakarta.ws.rs.core.UriInfo;
import org.jboss.logging.Logger;
import org.jboss.resteasy.reactive.NoCache;
import org.keycloak.authentication.requiredactions.DeleteAccount;
import org.keycloak.common.Profile;
@ -88,7 +90,8 @@ public class AccountConsole implements AccountResourceProvider {
}
@Override
public void close() {}
public void close() {
}
@GET
@NoCache
@ -149,6 +152,7 @@ public class AccountConsole implements AccountResourceProvider {
map.put("deleteAccountAllowed", deleteAccountAllowed);
map.put("isViewGroupsEnabled", isViewGroupsEnabled);
map.put("isOid4VciEnabled", Profile.isFeatureEnabled(Profile.Feature.OID4VC_VCI));
map.put("updateEmailFeatureEnabled", Profile.isFeatureEnabled(Profile.Feature.UPDATE_EMAIL));
RequiredActionProviderModel updateEmailActionProvider = realm.getRequiredActionProviderByAlias(UserModel.RequiredAction.UPDATE_EMAIL.name());

View file

@ -58,6 +58,7 @@ import org.keycloak.protocol.oid4vc.model.CredentialRequest;
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
import org.keycloak.protocol.oid4vc.model.Format;
import org.keycloak.protocol.oid4vc.model.OfferUriType;
import org.keycloak.protocol.oid4vc.model.PreAuthorizedGrant;
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
@ -112,7 +113,7 @@ public class OID4VCIssuerEndpointTest extends OID4VCTest {
authenticator.setTokenString(token);
OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator);
oid4VCIssuerEndpoint.getCredentialOfferURI("inexistent-id");
oid4VCIssuerEndpoint.getCredentialOfferURI("inexistent-id", OfferUriType.URI, 0, 0);
})));
}
@ -124,7 +125,7 @@ public class OID4VCIssuerEndpointTest extends OID4VCTest {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString(null);
OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator);
oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential");
oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0);
})));
}
@ -135,7 +136,7 @@ public class OID4VCIssuerEndpointTest extends OID4VCTest {
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
authenticator.setTokenString("invalid-token");
OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator);
oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential");
oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0);
})));
}
@ -150,7 +151,7 @@ public class OID4VCIssuerEndpointTest extends OID4VCTest {
authenticator.setTokenString(token);
OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator);
Response response = oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential");
Response response = oid4VCIssuerEndpoint.getCredentialOfferURI("test-credential", OfferUriType.URI, 0, 0);
assertEquals("An offer uri should have been returned.", HttpStatus.SC_OK, response.getStatus());
CredentialOfferURI credentialOfferURI = new ObjectMapper().convertValue(response.getEntity(), CredentialOfferURI.class);