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:
parent
af23150343
commit
0f6f9543ba
19 changed files with 584 additions and 19 deletions
2
.github/workflows/js-ci.yml
vendored
2
.github/workflows/js-ci.yml
vendored
|
@ -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
|
||||
|
|
|
@ -191,4 +191,11 @@ recovery-authn-codes-display-name=Recovery authentication codes
|
|||
recovery-authn-codes-help-text=These codes can be used to regain your access in case your other 2FA means are not available.
|
||||
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
|
||||
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.
|
|
@ -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!""}"
|
||||
|
|
|
@ -22,5 +22,10 @@
|
|||
"label": "resources",
|
||||
"path": "resources",
|
||||
"isVisible": "isMyResourcesEnabled"
|
||||
},
|
||||
{
|
||||
"label": "oid4vci",
|
||||
"path": "oid4vci",
|
||||
"isVisible":"isOid4VciEnabled"
|
||||
}
|
||||
]
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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),
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
132
js/apps/account-ui/src/oid4vci/Oid4Vci.tsx
Normal file
132
js/apps/account-ui/src/oid4vci/Oid4Vci.tsx
Normal 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;
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
19
js/apps/account-ui/test/oid4vci/oid4vci.spec.ts
Normal file
19
js/apps/account-ui/test/oid4vci/oid4vci.spec.ts
Normal 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
|
||||
})
|
||||
})
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -5,4 +5,5 @@ setup("delete realm", async () => {
|
|||
await deleteRealm("photoz");
|
||||
await deleteRealm("groups");
|
||||
await deleteRealm("user-profile");
|
||||
await deleteRealm("verifiable-credentials");
|
||||
});
|
||||
|
|
239
js/apps/account-ui/test/realms/verifiable-credentials-realm.json
Normal file
239
js/apps/account-ui/test/realms/verifiable-credentials-realm.json
Normal file
File diff suppressed because one or more lines are too long
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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)));
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
@ -138,7 +141,7 @@ public class AccountConsole implements AccountResourceProvider {
|
|||
map.put("isAuthorizationEnabled", Profile.isFeatureEnabled(Profile.Feature.AUTHORIZATION));
|
||||
|
||||
boolean deleteAccountAllowed = false;
|
||||
boolean isViewGroupsEnabled= false;
|
||||
boolean isViewGroupsEnabled = false;
|
||||
if (user != null) {
|
||||
RoleModel deleteAccountRole = realm.getClientByClientId(Constants.ACCOUNT_MANAGEMENT_CLIENT_ID).getRole(AccountRoles.DELETE_ACCOUNT);
|
||||
deleteAccountAllowed = deleteAccountRole != null && user.hasRole(deleteAccountRole) && realm.getRequiredActionProviderByAlias(DeleteAccount.PROVIDER_ID).isEnabled();
|
||||
|
@ -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());
|
||||
|
@ -159,16 +163,16 @@ public class AccountConsole implements AccountResourceProvider {
|
|||
Response.ResponseBuilder builder = Response.status(Response.Status.OK).type(MediaType.TEXT_HTML_UTF_8).language(Locale.ENGLISH).entity(result);
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
|
||||
private Map<String, String> supportedLocales(Properties messages) {
|
||||
return realm.getSupportedLocalesStream()
|
||||
.collect(Collectors.toMap(Function.identity(), l -> messages.getProperty("locale_" + l, l)));
|
||||
}
|
||||
|
||||
|
||||
private String messagesToJsonString(Properties props) {
|
||||
if (props == null) return "";
|
||||
Properties newProps = new Properties();
|
||||
for (String prop: props.stringPropertyNames()) {
|
||||
for (String prop : props.stringPropertyNames()) {
|
||||
newProps.put(prop, convertPropValue(props.getProperty(prop)));
|
||||
}
|
||||
try {
|
||||
|
@ -177,7 +181,7 @@ public class AccountConsole implements AccountResourceProvider {
|
|||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private String convertPropValue(String propertyValue) {
|
||||
// this mimics the behavior of java.text.MessageFormat used for the freemarker templates:
|
||||
// To print a single quote one needs to write two single quotes.
|
||||
|
@ -188,7 +192,7 @@ public class AccountConsole implements AccountResourceProvider {
|
|||
|
||||
return propertyValue;
|
||||
}
|
||||
|
||||
|
||||
// Put java resource bundle params in ngx-translate format
|
||||
// Do you like {0} and {1} ?
|
||||
// becomes
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in a new issue