From 0f6f9543ba67870289b6ff24189e762dde76dd1a Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Fri, 31 May 2024 15:11:32 +0200 Subject: [PATCH] Add oid4vci to the account console (#29174) closes #25945 Signed-off-by: Stefan Wiedemann Co-authored-by: Erik Jan de Wit Co-authored-by: Jon Koops --- .github/workflows/js-ci.yml | 2 +- .../account/messages/messages_en.properties | 9 +- js/apps/account-ui/pom.xml | 3 +- js/apps/account-ui/public/content.json | 5 + js/apps/account-ui/src/api.ts | 34 ++- js/apps/account-ui/src/api/representations.ts | 12 + js/apps/account-ui/src/api/request.ts | 6 +- js/apps/account-ui/src/index.ts | 1 + js/apps/account-ui/src/oid4vci/Oid4Vci.tsx | 132 ++++++++++ js/apps/account-ui/src/routes.tsx | 7 + .../account-ui/test/oid4vci/oid4vci.spec.ts | 19 ++ js/apps/account-ui/test/realm.setup.ts | 2 + js/apps/account-ui/test/realm.teardown.ts | 1 + .../realms/verifiable-credentials-realm.json | 239 ++++++++++++++++++ js/libs/ui-shared/src/context/environment.ts | 2 + .../oid4vc/issuance/OID4VCIssuerEndpoint.java | 45 +++- .../protocol/oid4vc/model/OfferUriType.java | 57 +++++ .../resources/account/AccountConsole.java | 18 +- .../signing/OID4VCIssuerEndpointTest.java | 9 +- 19 files changed, 584 insertions(+), 19 deletions(-) create mode 100644 js/apps/account-ui/src/oid4vci/Oid4Vci.tsx create mode 100644 js/apps/account-ui/test/oid4vci/oid4vci.spec.ts create mode 100644 js/apps/account-ui/test/realms/verifiable-credentials-realm.json create mode 100644 services/src/main/java/org/keycloak/protocol/oid4vc/model/OfferUriType.java diff --git a/.github/workflows/js-ci.yml b/.github/workflows/js-ci.yml index a99846dfeb..cdabce3e85 100644 --- a/.github/workflows/js-ci.yml +++ b/.github/workflows/js-ci.yml @@ -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 diff --git a/js/apps/account-ui/maven-resources/theme/keycloak.v3/account/messages/messages_en.properties b/js/apps/account-ui/maven-resources/theme/keycloak.v3/account/messages/messages_en.properties index 38eff592a9..fed05bbef1 100644 --- a/js/apps/account-ui/maven-resources/theme/keycloak.v3/account/messages/messages_en.properties +++ b/js/apps/account-ui/maven-resources/theme/keycloak.v3/account/messages/messages_en.properties @@ -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 \ No newline at end of file +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. \ No newline at end of file diff --git a/js/apps/account-ui/pom.xml b/js/apps/account-ui/pom.xml index fe932ccc64..7b7b41894d 100644 --- a/js/apps/account-ui/pom.xml +++ b/js/apps/account-ui/pom.xml @@ -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!""}" diff --git a/js/apps/account-ui/public/content.json b/js/apps/account-ui/public/content.json index 04b9379c55..3a6e3515fa 100644 --- a/js/apps/account-ui/public/content.json +++ b/js/apps/account-ui/public/content.json @@ -22,5 +22,10 @@ "label": "resources", "path": "resources", "isVisible": "isMyResourcesEnabled" + }, + { + "label": "oid4vci", + "path": "oid4vci", + "isVisible":"isOid4VciEnabled" } ] diff --git a/js/apps/account-ui/src/api.ts b/js/apps/account-ui/src/api.ts index 5583a03049..d897bdad86 100644 --- a/js/apps/account-ui/src/api.ts +++ b/js/apps/account-ui/src/api.ts @@ -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(response: T) { if (!response) throw new Error("Could not fetch"); return response; } + + +export async function getIssuer( + context: KeycloakContext +) { + 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(response); +} + +export async function requestVCOffer( + context: KeycloakContext, + 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() +} \ No newline at end of file diff --git a/js/apps/account-ui/src/api/representations.ts b/js/apps/account-ui/src/api/representations.ts index ff5ab73307..291d4512e8 100644 --- a/js/apps/account-ui/src/api/representations.ts +++ b/js/apps/account-ui/src/api/representations.ts @@ -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 +} \ No newline at end of file diff --git a/js/apps/account-ui/src/api/request.ts b/js/apps/account-ui/src/api/request.ts index b73b3f98fe..fd2b0673e0 100644 --- a/js/apps/account-ui/src/api/request.ts +++ b/js/apps/account-ui/src/api/request.ts @@ -37,8 +37,12 @@ export async function request( path: string, { environment, keycloak }: KeycloakContext, opts: RequestOptions = {}, + fullUrl?: URL ) { - return _request(url(environment, path), { + if (typeof fullUrl === 'undefined') { + fullUrl = url(environment, path) + } + return _request(fullUrl, { ...opts, getAccessToken: token(keycloak), }); diff --git a/js/apps/account-ui/src/index.ts b/js/apps/account-ui/src/index.ts index eae5ccf4b9..bc29e4b8d7 100644 --- a/js/apps/account-ui/src/index.ts +++ b/js/apps/account-ui/src/index.ts @@ -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, diff --git a/js/apps/account-ui/src/oid4vci/Oid4Vci.tsx b/js/apps/account-ui/src/oid4vci/Oid4Vci.tsx new file mode 100644 index 0000000000..94c9a3a2f9 --- /dev/null +++ b/js/apps/account-ui/src/oid4vci/Oid4Vci.tsx @@ -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(initialSelected); + const [qrCode, setQrCode] = useState("") + const [isOpen, setIsOpen] = useState(false) + const [offerQRVisible, setOfferQRVisible] = useState(false) + const [credentialsIssuer, setCredentialsIssuer] = useState() + + 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) => ( + + {selected} + + ); + + return ( + + + + + + + + + { offerQRVisible && + + + + } + + + + + + ); +}; + + +export default Oid4Vci; \ No newline at end of file diff --git a/js/apps/account-ui/src/routes.tsx b/js/apps/account-ui/src/routes.tsx index 2e14a2227a..23fa6fa627 100644 --- a/js/apps/account-ui/src/routes.tsx +++ b/js/apps/account-ui/src/routes.tsx @@ -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: , }; +export const Oid4VciRoute: RouteObject = { + path: "oid4vci", + element: , +} + export const RootRoute: RouteObject = { path: decodeURIComponent(new URL(environment.baseUrl).pathname), element: , @@ -71,6 +77,7 @@ export const RootRoute: RouteObject = { PersonalInfoRoute, ResourcesRoute, ContentRoute, + Oid4VciRoute, ], }; diff --git a/js/apps/account-ui/test/oid4vci/oid4vci.spec.ts b/js/apps/account-ui/test/oid4vci/oid4vci.spec.ts new file mode 100644 index 0000000000..dc0a0001b5 --- /dev/null +++ b/js/apps/account-ui/test/oid4vci/oid4vci.spec.ts @@ -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 + }) +}) \ No newline at end of file diff --git a/js/apps/account-ui/test/realm.setup.ts b/js/apps/account-ui/test/realm.setup.ts index 77ae213e6e..1d7a709775 100644 --- a/js/apps/account-ui/test/realm.setup.ts +++ b/js/apps/account-ui/test/realm.setup.ts @@ -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); }); diff --git a/js/apps/account-ui/test/realm.teardown.ts b/js/apps/account-ui/test/realm.teardown.ts index ed2fde4e60..e68a1e5210 100644 --- a/js/apps/account-ui/test/realm.teardown.ts +++ b/js/apps/account-ui/test/realm.teardown.ts @@ -5,4 +5,5 @@ setup("delete realm", async () => { await deleteRealm("photoz"); await deleteRealm("groups"); await deleteRealm("user-profile"); + await deleteRealm("verifiable-credentials"); }); diff --git a/js/apps/account-ui/test/realms/verifiable-credentials-realm.json b/js/apps/account-ui/test/realms/verifiable-credentials-realm.json new file mode 100644 index 0000000000..4bbc072483 --- /dev/null +++ b/js/apps/account-ui/test/realms/verifiable-credentials-realm.json @@ -0,0 +1,239 @@ +{ + "id": "verifiable-credentials", + "realm": "verifiable-credentials", + "displayName": "Keycloak", + "displayNameHtml": "
Keycloak
", + "enabled": true, + "attributes": { + "frontendUrl": "http://localhost:8080/", + "issuerDid": "did:web:test.org" + }, + "sslRequired": "none", + "roles": { + "realm": [ + { + "name": "user", + "description": "User privileges", + "composite": false, + "clientRole": false, + "containerId": "dome", + "attributes": {} + } + ], + "client": { + "did:web:test-marketplace.org": [ + { + "name": "LEGAL_REPRESENTATIVE", + "clientRole": true + }, + { + "name": "EMPLOYEE", + "clientRole": true + } + ] + } + }, + "groups": [ + ], + "users": [ + { + "username": "test-user", + "enabled": true, + "email": "test@user.org", + "firstName": "Test", + "lastName": "Employee", + "credentials": [ + { + "type": "password", + "value": "test" + } + ], + "clientRoles": { + "did:web:test-marketplace.org": [ + "EMPLOYEE" + ], + "account": [ + "view-profile", + "manage-account" + ] + }, + "groups": [ + ] + } + ], + "clients": [ + { + "clientId": "did:web:test-marketplace.org", + "enabled": true, + "description": "Client to connect the marketplace", + "surrogateAuthRequired": false, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "defaultRoles": [], + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "oid4vc", + "attributes": { + "client.secret.creation.time": "1675260539", + "vc.natural-person.format": "jwt_vc", + "vc.natural-person.scope": "NaturalPersonCredential", + "vc.verifiable-credential.format": "jwt_vc", + "vc.verifiabel-credential.scope": "VerifiableCredential" + }, + "protocolMappers": [ + { + "name": "target-role-mapper", + "protocol": "oid4vc", + "protocolMapper": "oid4vc-target-role-mapper", + "config": { + "subjectProperty": "roles", + "clientId": "did:web:test-marketplace.org", + "supportedCredentialTypes": "NaturalPersonCredential" + } + }, + { + "name": "target-vc-role-mapper", + "protocol": "oid4vc", + "protocolMapper": "oid4vc-target-role-mapper", + "config": { + "subjectProperty": "roles", + "clientId": "did:web:test-marketplace.org", + "supportedCredentialTypes": "VerifiableCredential" + } + }, + { + "name": "email-mapper", + "protocol": "oid4vc", + "protocolMapper": "oid4vc-user-attribute-mapper", + "config": { + "subjectProperty": "email", + "userAttribute": "email", + "supportedCredentialTypes": "NaturalPersonCredential" + } + } + ], + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [], + "optionalClientScopes": [] + } + ], + "clientScopes": [ + { + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "true", + "consent.screen.text": "${rolesScopeConsentText}" + }, + "protocolMappers": [ + { + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "access.token.claim": "true" + } + }, + { + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "multivalued": "true", + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String" + } + }, + { + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "multivalued": "true", + "user.attribute": "foo", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String" + } + } + ] + } + ], + "defaultDefaultClientScopes": [ + ], + "defaultOptionalClientScopes": [ + ], + "components": { + "org.keycloak.protocol.oid4vc.issuance.signing.VerifiableCredentialsSigningService": [ + { + "id": "jwt-signing", + "name": "jwt-signing-service", + "providerId": "jwt_vc", + "subComponents": {}, + "config": { + "keyId": [ + "GwZfdu_xA5MYJmIWnrAdvbExMMWGdsqfY2qoqFVhVPM" + ], + "algorithmType": [ + "RS256" + ], + "issuerDid": [ + "did:web:test.org" + ], + "tokenType": [ + "JWT" + ] + } + } + ], + "org.keycloak.keys.KeyProvider": [ + { + "id": "a4589e8f-7f82-4345-b2ea-ccc9d4366600", + "name": "test-key", + "providerId": "rsa", + "subComponents": {}, + "config": { + "privateKey": [ + "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA1M1vl2mW0ewMctKEoCYG6+SgV9TqN+4oIt2ZLkQb1O+yWGTW\nuu8h2U7yZ+Dc1JfSPUd45eU1p9j3rYu5Bs2Labc6zZUYyBMjZXopv/AqIOhvuTRR\ng7v4yRkC6QACniLndPCkanlp/8dL98Gmm8x+oOjYf1UFbGxjGqqVxfNVZmGi9NLE\n6AM0e4wmBVknwWTcC3TTHDxgAxHHa0GhL1y7OYsmw9Kz1riUWlr0Az3lBclOFACb\nOp/cGnyHnotErw1xKVQtGOv4GIsYQZr4jIeQkoFcqbAQVOk30NjTRNgVra2JzEpM\nvhbm4l+WHK2OfsPfBx6OKTOmet6zJnnNC608jQIDAQABAoIBAA+gyro28fGWwU9J\ncJ4GTOnUD4aDx3O2FNKCrbY9IEIiHFnrhe2SlEzORHUmiXE/eRww/Ir9q1QZVg8z\nvLHoNH3eC/5/HaPL7ASO1TQYYi+qglH6qqXfDyVNpe4QpyCP9amb5qc/JW64ZzbZ\nzO+SNBaDIysuxkgxKZISxw8TkMkE7jvh6RafPkIBldIhGunWQ33on08UJaTAeCz2\nw+3g7Ei8Ejg6sMZ+HGTJ9omA7xVOT+baLm9zwwQh1RcYV9MRywljAnJRs9iDwRzn\n2uWOpqFbmdTRDRuZmocP2ks9Wty5Ub3iyu7n1M9y5W8fxdmuZ4mXSYoG6jm/tJzo\nd8yRHYECgYEA/KP8MFyhWsH9F/eMDdq6oMG/Gk5VQcp6gadoHy2g710h37stTPRN\nnyitAo6enWoKNFH2jZBvGxrUY4n7+Zib51zhUf6L6YvP77yYn4txIwZYITzdr4X7\nivL4IH4qmoM5T2DRMD/bSN2c87v5IKpnuySL81C+irr1ZFSqDe5anKECgYEA16HW\nebVv87icGHDlkON21VCeTpnpofntjKSvEYfHRsyzldgkaurGSjIjojtdj0t2fHfx\nMt0VBHLENop1E0Up/jhroYjlBXFcIPwaBwSECoIzzoZApL/kJciijek3V6GWne0R\nIskVLTBxvVf4gz2bovRY4zgsi3cLs719KL8hDG0CgYEAsJohC910nXDFbx+IM5cW\nppFI+SaQynCzujY/vquyuCAuMasyO3z7VaqlZgg0MG2TvIcfBk5UnGng1cP687sO\nIGj4yMxbGWK2dCsttTlQWN9yc6mMfcn20GaPtIb9WQ0p3qcbE9NPglwH/wkDWSZF\nZLhjbC6hQ3D1YLEePqbDiIECgYBgwl5bfu8djll9HivlOCy6y9I9sxMDfAL8eWmV\nlDf3rSNoufSdhXw1DwquYbU598LTV38EM/CabmVdlAO1AfQ1/1tMwQED0DpnErkb\nLQuTK5nTsqqPQww9aCqJQ31x9TCA7UAjO9gkzvg63p7FRX/xP3QjgbF7Y4/8t6rR\n/fH2gQKBgAvBF7+OQWNWC4VPYGX6GMveqvN7/87qwGMltB5OQZWiODEuKYPLKmll\nsin1Xlek/Fe4zRSe+mWTs9pnrZ7VWIUUlBbW9V/tgRKcW7c6S4r69Gih5gToFE6Q\nEH4j0DoVcaH3nWOxwQqKrQL0tk2faSS0Y0O6wGDQiEuDvk4AGmGp\n-----END RSA PRIVATE KEY-----\n\n" + ], + "certificate": [ + "-----BEGIN CERTIFICATE-----\nMIIE/jCCA+agAwIBAgISA6pEpAokqYJAJqQPgnCnsHAsMA0GCSqGSIb3DQEBCwUA\nMDIxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MQswCQYDVQQD\nEwJSMzAeFw0yMzEyMjQwNTQxMzFaFw0yNDAzMjMwNTQxMzBaMCMxITAfBgNVBAMT\nGGNvbnN1bWVyLmRvbWUuZml3YXJlLmRldjCCASIwDQYJKoZIhvcNAQEBBQADggEP\nADCCAQoCggEBANTNb5dpltHsDHLShKAmBuvkoFfU6jfuKCLdmS5EG9Tvslhk1rrv\nIdlO8mfg3NSX0j1HeOXlNafY962LuQbNi2m3Os2VGMgTI2V6Kb/wKiDob7k0UYO7\n+MkZAukAAp4i53TwpGp5af/HS/fBppvMfqDo2H9VBWxsYxqqlcXzVWZhovTSxOgD\nNHuMJgVZJ8Fk3At00xw8YAMRx2tBoS9cuzmLJsPSs9a4lFpa9AM95QXJThQAmzqf\n3Bp8h56LRK8NcSlULRjr+BiLGEGa+IyHkJKBXKmwEFTpN9DY00TYFa2ticxKTL4W\n5uJflhytjn7D3wcejikzpnresyZ5zQutPI0CAwEAAaOCAhswggIXMA4GA1UdDwEB\n/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwDAYDVR0TAQH/\nBAIwADAdBgNVHQ4EFgQUxOKFQ5oAbX4U5ixy4ofvajMkTdAwHwYDVR0jBBgwFoAU\nFC6zF7dYVsuuUAlA5h+vnYsUwsYwVQYIKwYBBQUHAQEESTBHMCEGCCsGAQUFBzAB\nhhVodHRwOi8vcjMuby5sZW5jci5vcmcwIgYIKwYBBQUHMAKGFmh0dHA6Ly9yMy5p\nLmxlbmNyLm9yZy8wIwYDVR0RBBwwGoIYY29uc3VtZXIuZG9tZS5maXdhcmUuZGV2\nMBMGA1UdIAQMMAowCAYGZ4EMAQIBMIIBBQYKKwYBBAHWeQIEAgSB9gSB8wDxAHcA\nSLDja9qmRzQP5WoC+p0w6xxSActW3SyB2bu/qznYhHMAAAGMmo6vgQAABAMASDBG\nAiEAzJ0YhzMGyKKrkD66BAJQkWOqQS4E32X9jYvVL/XjqR4CIQCtrHjnCE7LdIBh\nESY873ctjvd3izH/F+OeoLocfP/p8wB2AHb/iD8KtvuVUcJhzPWHujS0pM27Kdxo\nQgqf5mdMWjp0AAABjJqOsBQAAAQDAEcwRQIhALZ8CQK+/Rj8p0krq96y68KED2qN\n9VWEA/diHmc3BSPkAiBhmZRBIDYZ3+BwiYQXLmWB34Uc8RCvsEHBHLVsLWJtizAN\nBgkqhkiG9w0BAQsFAAOCAQEAn/2qNjtU0v1fQbTnFgrOzvCDnruhWSgqC7t9/vAv\n+mK5t/KEIwMfDAXiaNXofn8me5nXXsfGSxhqNfXpBBfzGA6MEM3Rfqd+D2ie5+oW\ntNY+5Tdoi/jdaww07ZiiFsFPPfPgHZ6LbU/jDP4J0VwwYt30+FWMkKecsXKOCt+V\nUB3tgo0PY3DQOsbmSt9rFAIv8LHa8mQ/ikF+sk07BP+CfAjPz/4Rg8AR8A9mtqKM\nlD3AvvMLxVga/KgEEaB4vGrcK1liBFZF6/RRwExeXn0nErcHYGiqeiyYD8sI07QC\nRv2bv0LYkmEKqB/RxgTWMkltTQUFjUD55Dy1/4UTXtM9yg==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIFFjCCAv6gAwIBAgIRAJErCErPDBinU/bWLiWnX1owDQYJKoZIhvcNAQELBQAw\nTzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh\ncmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMjAwOTA0MDAwMDAw\nWhcNMjUwOTE1MTYwMDAwWjAyMQswCQYDVQQGEwJVUzEWMBQGA1UEChMNTGV0J3Mg\nRW5jcnlwdDELMAkGA1UEAxMCUjMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK\nAoIBAQC7AhUozPaglNMPEuyNVZLD+ILxmaZ6QoinXSaqtSu5xUyxr45r+XXIo9cP\nR5QUVTVXjJ6oojkZ9YI8QqlObvU7wy7bjcCwXPNZOOftz2nwWgsbvsCUJCWH+jdx\nsxPnHKzhm+/b5DtFUkWWqcFTzjTIUu61ru2P3mBw4qVUq7ZtDpelQDRrK9O8Zutm\nNHz6a4uPVymZ+DAXXbpyb/uBxa3Shlg9F8fnCbvxK/eG3MHacV3URuPMrSXBiLxg\nZ3Vms/EY96Jc5lP/Ooi2R6X/ExjqmAl3P51T+c8B5fWmcBcUr2Ok/5mzk53cU6cG\n/kiFHaFpriV1uxPMUgP17VGhi9sVAgMBAAGjggEIMIIBBDAOBgNVHQ8BAf8EBAMC\nAYYwHQYDVR0lBBYwFAYIKwYBBQUHAwIGCCsGAQUFBwMBMBIGA1UdEwEB/wQIMAYB\nAf8CAQAwHQYDVR0OBBYEFBQusxe3WFbLrlAJQOYfr52LFMLGMB8GA1UdIwQYMBaA\nFHm0WeZ7tuXkAXOACIjIGlj26ZtuMDIGCCsGAQUFBwEBBCYwJDAiBggrBgEFBQcw\nAoYWaHR0cDovL3gxLmkubGVuY3Iub3JnLzAnBgNVHR8EIDAeMBygGqAYhhZodHRw\nOi8veDEuYy5sZW5jci5vcmcvMCIGA1UdIAQbMBkwCAYGZ4EMAQIBMA0GCysGAQQB\ngt8TAQEBMA0GCSqGSIb3DQEBCwUAA4ICAQCFyk5HPqP3hUSFvNVneLKYY611TR6W\nPTNlclQtgaDqw+34IL9fzLdwALduO/ZelN7kIJ+m74uyA+eitRY8kc607TkC53wl\nikfmZW4/RvTZ8M6UK+5UzhK8jCdLuMGYL6KvzXGRSgi3yLgjewQtCPkIVz6D2QQz\nCkcheAmCJ8MqyJu5zlzyZMjAvnnAT45tRAxekrsu94sQ4egdRCnbWSDtY7kh+BIm\nlJNXoB1lBMEKIq4QDUOXoRgffuDghje1WrG9ML+Hbisq/yFOGwXD9RiX8F6sw6W4\navAuvDszue5L3sz85K+EC4Y/wFVDNvZo4TYXao6Z0f+lQKc0t8DQYzk1OXVu8rp2\nyJMC6alLbBfODALZvYH7n7do1AZls4I9d1P4jnkDrQoxB3UqQ9hVl3LEKQ73xF1O\nyK5GhDDX8oVfGKF5u+decIsH4YaTw7mP3GFxJSqv3+0lUFJoi5Lc5da149p90Ids\nhCExroL1+7mryIkXPeFM5TgO9r0rvZaBFOvV2z0gp35Z0+L4WPlbuEjN/lxPFin+\nHlUjr8gRsI3qfJOQFy/9rKIJR0Y/8Omwt/8oTWgy1mdeHmmjk7j1nYsvC9JSQ6Zv\nMldlTTKB3zhThV1+XWYp6rjd5JW1zbVWEkLNxE7GJThEUG3szgBVGP7pSWTUTsqX\nnLRbwHOoq7hHwg==\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIFYDCCBEigAwIBAgIQQAF3ITfU6UK47naqPGQKtzANBgkqhkiG9w0BAQsFADA/\nMSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT\nDkRTVCBSb290IENBIFgzMB4XDTIxMDEyMDE5MTQwM1oXDTI0MDkzMDE4MTQwM1ow\nTzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh\ncmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwggIiMA0GCSqGSIb3DQEB\nAQUAA4ICDwAwggIKAoICAQCt6CRz9BQ385ueK1coHIe+3LffOJCMbjzmV6B493XC\nov71am72AE8o295ohmxEk7axY/0UEmu/H9LqMZshftEzPLpI9d1537O4/xLxIZpL\nwYqGcWlKZmZsj348cL+tKSIG8+TA5oCu4kuPt5l+lAOf00eXfJlII1PoOK5PCm+D\nLtFJV4yAdLbaL9A4jXsDcCEbdfIwPPqPrt3aY6vrFk/CjhFLfs8L6P+1dy70sntK\n4EwSJQxwjQMpoOFTJOwT2e4ZvxCzSow/iaNhUd6shweU9GNx7C7ib1uYgeGJXDR5\nbHbvO5BieebbpJovJsXQEOEO3tkQjhb7t/eo98flAgeYjzYIlefiN5YNNnWe+w5y\nsR2bvAP5SQXYgd0FtCrWQemsAXaVCg/Y39W9Eh81LygXbNKYwagJZHduRze6zqxZ\nXmidf3LWicUGQSk+WT7dJvUkyRGnWqNMQB9GoZm1pzpRboY7nn1ypxIFeFntPlF4\nFQsDj43QLwWyPntKHEtzBRL8xurgUBN8Q5N0s8p0544fAQjQMNRbcTa0B7rBMDBc\nSLeCO5imfWCKoqMpgsy6vYMEG6KDA0Gh1gXxG8K28Kh8hjtGqEgqiNx2mna/H2ql\nPRmP6zjzZN7IKw0KKP/32+IVQtQi0Cdd4Xn+GOdwiK1O5tmLOsbdJ1Fu/7xk9TND\nTwIDAQABo4IBRjCCAUIwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYw\nSwYIKwYBBQUHAQEEPzA9MDsGCCsGAQUFBzAChi9odHRwOi8vYXBwcy5pZGVudHJ1\nc3QuY29tL3Jvb3RzL2RzdHJvb3RjYXgzLnA3YzAfBgNVHSMEGDAWgBTEp7Gkeyxx\n+tvhS5B1/8QVYIWJEDBUBgNVHSAETTBLMAgGBmeBDAECATA/BgsrBgEEAYLfEwEB\nATAwMC4GCCsGAQUFBwIBFiJodHRwOi8vY3BzLnJvb3QteDEubGV0c2VuY3J5cHQu\nb3JnMDwGA1UdHwQ1MDMwMaAvoC2GK2h0dHA6Ly9jcmwuaWRlbnRydXN0LmNvbS9E\nU1RST09UQ0FYM0NSTC5jcmwwHQYDVR0OBBYEFHm0WeZ7tuXkAXOACIjIGlj26Ztu\nMA0GCSqGSIb3DQEBCwUAA4IBAQAKcwBslm7/DlLQrt2M51oGrS+o44+/yQoDFVDC\n5WxCu2+b9LRPwkSICHXM6webFGJueN7sJ7o5XPWioW5WlHAQU7G75K/QosMrAdSW\n9MUgNTP52GE24HGNtLi1qoJFlcDyqSMo59ahy2cI2qBDLKobkx/J3vWraV0T9VuG\nWCLKTVXkcGdtwlfFRjlBz4pYg1htmf5X6DYO8A4jqv2Il9DjXA6USbW1FzXSLr9O\nhe8Y4IWS6wY7bCkjCWDcRQJMEhg76fsO3txE+FiYruq9RUWhiF1myv4Q6W+CyBFC\nDfvp7OOGAN6dEOM4+qR9sdjoSYKEBpsr6GtPAQw4dy753ec5\n-----END CERTIFICATE-----\n\n" + ], + "active": [ + "true" + ], + "priority": [ + "0" + ], + "enabled": [ + "true" + ], + "algorithm": [ + "RS256" + ] + } + } + ] + } + } \ No newline at end of file diff --git a/js/libs/ui-shared/src/context/environment.ts b/js/libs/ui-shared/src/context/environment.ts index aef682a4ac..d5d726ea2e 100644 --- a/js/libs/ui-shared/src/context/environment.ts +++ b/js/libs/ui-shared/src/context/environment.ts @@ -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, }, }; diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java index b1fc55c697..344a6e3d75 100644 --- a/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/issuance/OID4VCIssuerEndpoint.java @@ -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 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(); + } } /** diff --git a/services/src/main/java/org/keycloak/protocol/oid4vc/model/OfferUriType.java b/services/src/main/java/org/keycloak/protocol/oid4vc/model/OfferUriType.java new file mode 100644 index 0000000000..7c7431c913 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oid4vc/model/OfferUriType.java @@ -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 Stefan Wiedemann + */ +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))); + } +} \ No newline at end of file diff --git a/services/src/main/java/org/keycloak/services/resources/account/AccountConsole.java b/services/src/main/java/org/keycloak/services/resources/account/AccountConsole.java index 39fa790461..618b1c8acd 100644 --- a/services/src/main/java/org/keycloak/services/resources/account/AccountConsole.java +++ b/services/src/main/java/org/keycloak/services/resources/account/AccountConsole.java @@ -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 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 diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java index acdbd6bbf4..72367c0b41 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oid4vc/issuance/signing/OID4VCIssuerEndpointTest.java @@ -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);