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
|
- name: Start Keycloak server
|
||||||
run: |
|
run: |
|
||||||
tar xfvz keycloak-999.0.0-SNAPSHOT.tar.gz
|
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:
|
env:
|
||||||
KEYCLOAK_ADMIN: admin
|
KEYCLOAK_ADMIN: admin
|
||||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||||
|
|
|
@ -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-used={0} recovery codes used
|
||||||
recovery-codes-number-remaining={0} recovery codes remaining
|
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},
|
"deleteAccountAllowed": ${deleteAccountAllowed?c},
|
||||||
"updateEmailFeatureEnabled": ${updateEmailFeatureEnabled?c},
|
"updateEmailFeatureEnabled": ${updateEmailFeatureEnabled?c},
|
||||||
"updateEmailActionEnabled": ${updateEmailActionEnabled?c},
|
"updateEmailActionEnabled": ${updateEmailActionEnabled?c},
|
||||||
"isViewGroupsEnabled": ${isViewGroupsEnabled?c}
|
"isViewGroupsEnabled": ${isViewGroupsEnabled?c},
|
||||||
|
"isOid4VciEnabled": ${isOid4VciEnabled?c}
|
||||||
},
|
},
|
||||||
"referrerName": "${referrerName!""}",
|
"referrerName": "${referrerName!""}",
|
||||||
"referrerUrl": "${referrer_uri!""}"
|
"referrerUrl": "${referrer_uri!""}"
|
||||||
|
|
|
@ -22,5 +22,10 @@
|
||||||
"label": "resources",
|
"label": "resources",
|
||||||
"path": "resources",
|
"path": "resources",
|
||||||
"isVisible": "isMyResourcesEnabled"
|
"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 { CallOptions } from "./api/methods";
|
||||||
import { Links, parseLinks } from "./api/parse-links";
|
import { Links, parseLinks } from "./api/parse-links";
|
||||||
import { parseResponse } from "./api/parse-response";
|
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 { request } from "./api/request";
|
||||||
|
import { joinPath } from "./utils/joinPath";
|
||||||
|
|
||||||
export const fetchResources = async (
|
export const fetchResources = async (
|
||||||
{ signal, context }: CallOptions,
|
{ signal, context }: CallOptions,
|
||||||
|
@ -68,3 +69,34 @@ function checkResponse<T>(response: T) {
|
||||||
if (!response) throw new Error("Could not fetch");
|
if (!response) throw new Error("Could not fetch");
|
||||||
return response;
|
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;
|
name: string;
|
||||||
path: 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,
|
path: string,
|
||||||
{ environment, keycloak }: KeycloakContext<BaseEnvironment>,
|
{ environment, keycloak }: KeycloakContext<BaseEnvironment>,
|
||||||
opts: RequestOptions = {},
|
opts: RequestOptions = {},
|
||||||
|
fullUrl?: URL
|
||||||
) {
|
) {
|
||||||
return _request(url(environment, path), {
|
if (typeof fullUrl === 'undefined') {
|
||||||
|
fullUrl = url(environment, path)
|
||||||
|
}
|
||||||
|
return _request(fullUrl, {
|
||||||
...opts,
|
...opts,
|
||||||
getAccessToken: token(keycloak),
|
getAccessToken: token(keycloak),
|
||||||
});
|
});
|
||||||
|
|
|
@ -38,6 +38,7 @@ export { Resources } from "./resources/Resources";
|
||||||
export { ResourcesTab } from "./resources/ResourcesTab";
|
export { ResourcesTab } from "./resources/ResourcesTab";
|
||||||
export { ResourceToolbar } from "./resources/ResourceToolbar";
|
export { ResourceToolbar } from "./resources/ResourceToolbar";
|
||||||
export { SharedWith } from "./resources/SharedWith";
|
export { SharedWith } from "./resources/SharedWith";
|
||||||
|
export { Oid4Vci } from "./oid4vci/Oid4Vci";
|
||||||
export { ShareTheResource } from "./resources/ShareTheResource";
|
export { ShareTheResource } from "./resources/ShareTheResource";
|
||||||
export {
|
export {
|
||||||
deleteConsent,
|
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 PersonalInfo = lazy(() => import("./personal-info/PersonalInfo"));
|
||||||
const Resources = lazy(() => import("./resources/Resources"));
|
const Resources = lazy(() => import("./resources/Resources"));
|
||||||
const ContentComponent = lazy(() => import("./content/ContentComponent"));
|
const ContentComponent = lazy(() => import("./content/ContentComponent"));
|
||||||
|
const Oid4Vci = lazy(() => import("./oid4vci/Oid4Vci"));
|
||||||
|
|
||||||
export const DeviceActivityRoute: RouteObject = {
|
export const DeviceActivityRoute: RouteObject = {
|
||||||
path: "account-security/device-activity",
|
path: "account-security/device-activity",
|
||||||
|
@ -57,6 +58,11 @@ export const PersonalInfoRoute: IndexRouteObject = {
|
||||||
element: <PersonalInfo />,
|
element: <PersonalInfo />,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const Oid4VciRoute: RouteObject = {
|
||||||
|
path: "oid4vci",
|
||||||
|
element: <Oid4Vci />,
|
||||||
|
}
|
||||||
|
|
||||||
export const RootRoute: RouteObject = {
|
export const RootRoute: RouteObject = {
|
||||||
path: decodeURIComponent(new URL(environment.baseUrl).pathname),
|
path: decodeURIComponent(new URL(environment.baseUrl).pathname),
|
||||||
element: <Root />,
|
element: <Root />,
|
||||||
|
@ -71,6 +77,7 @@ export const RootRoute: RouteObject = {
|
||||||
PersonalInfoRoute,
|
PersonalInfoRoute,
|
||||||
ResourcesRoute,
|
ResourcesRoute,
|
||||||
ContentRoute,
|
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 groupsRealm from "./realms/groups-realm.json" assert { type: "json" };
|
||||||
import resourcesRealm from "./realms/resources-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 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 () => {
|
setup("import realm", async () => {
|
||||||
await importRealm(groupsRealm as RealmRepresentation);
|
await importRealm(groupsRealm as RealmRepresentation);
|
||||||
await importRealm(resourcesRealm as RealmRepresentation);
|
await importRealm(resourcesRealm as RealmRepresentation);
|
||||||
await importRealm(userProfileRealm 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("photoz");
|
||||||
await deleteRealm("groups");
|
await deleteRealm("groups");
|
||||||
await deleteRealm("user-profile");
|
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;
|
updateEmailFeatureEnabled: boolean;
|
||||||
updateEmailActionEnabled: boolean;
|
updateEmailActionEnabled: boolean;
|
||||||
isViewGroupsEnabled: boolean;
|
isViewGroupsEnabled: boolean;
|
||||||
|
isOid4VciEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BaseEnvironment = {
|
export type BaseEnvironment = {
|
||||||
|
@ -83,6 +84,7 @@ const defaultEnvironment: AdminEnvironment & AccountEnvironment = {
|
||||||
updateEmailFeatureEnabled: true,
|
updateEmailFeatureEnabled: true,
|
||||||
updateEmailActionEnabled: true,
|
updateEmailActionEnabled: true,
|
||||||
isViewGroupsEnabled: 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.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
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.BadRequestException;
|
||||||
import jakarta.ws.rs.Consumes;
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.DefaultValue;
|
||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
import jakarta.ws.rs.POST;
|
import jakarta.ws.rs.POST;
|
||||||
import jakarta.ws.rs.Path;
|
import jakarta.ws.rs.Path;
|
||||||
|
@ -29,6 +37,7 @@ import jakarta.ws.rs.Produces;
|
||||||
import jakarta.ws.rs.QueryParam;
|
import jakarta.ws.rs.QueryParam;
|
||||||
import jakarta.ws.rs.WebApplicationException;
|
import jakarta.ws.rs.WebApplicationException;
|
||||||
import jakarta.ws.rs.core.Response;
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import org.apache.http.HttpStatus;
|
||||||
import org.jboss.logging.Logger;
|
import org.jboss.logging.Logger;
|
||||||
import org.keycloak.common.util.SecretGenerator;
|
import org.keycloak.common.util.SecretGenerator;
|
||||||
import org.keycloak.models.AuthenticatedClientSessionModel;
|
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.ErrorType;
|
||||||
import org.keycloak.protocol.oid4vc.model.Format;
|
import org.keycloak.protocol.oid4vc.model.Format;
|
||||||
import org.keycloak.protocol.oid4vc.model.OID4VCClient;
|
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.PreAuthorizedCode;
|
||||||
import org.keycloak.protocol.oid4vc.model.PreAuthorizedGrant;
|
import org.keycloak.protocol.oid4vc.model.PreAuthorizedGrant;
|
||||||
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
|
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.services.managers.AuthenticationManager;
|
||||||
import org.keycloak.utils.MediaType;
|
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.URI;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.Base64;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
@ -81,6 +97,7 @@ public class OID4VCIssuerEndpoint {
|
||||||
|
|
||||||
public static final String CREDENTIAL_PATH = "credential";
|
public static final String CREDENTIAL_PATH = "credential";
|
||||||
public static final String CREDENTIAL_OFFER_PATH = "credential-offer/";
|
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 KeycloakSession session;
|
||||||
private final AppAuthManager.BearerTokenAuthenticator bearerTokenAuthenticator;
|
private final AppAuthManager.BearerTokenAuthenticator bearerTokenAuthenticator;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
@ -107,18 +124,18 @@ public class OID4VCIssuerEndpoint {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Provides the URI to the OID4VCI compliant credentials offer
|
* Provides the URI to the OID4VCI compliant credentials offer
|
||||||
*/
|
*/
|
||||||
@GET
|
@GET
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces({MediaType.APPLICATION_JSON, RESPONSE_TYPE_IMG_PNG})
|
||||||
@Path("credential-offer-uri")
|
@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();
|
AuthenticatedClientSessionModel clientSession = getAuthenticatedClientSession();
|
||||||
|
|
||||||
Map<String, SupportedCredentialConfiguration> credentialsMap = OID4VCIssuerWellKnownProvider.getSupportedCredentials(session);
|
Map<String, SupportedCredentialConfiguration> credentialsMap = OID4VCIssuerWellKnownProvider.getSupportedCredentials(session);
|
||||||
|
|
||||||
LOGGER.debugf("Get an offer for %s", vcId);
|
LOGGER.debugf("Get an offer for %s", vcId);
|
||||||
if (!credentialsMap.containsKey(vcId)) {
|
if (!credentialsMap.containsKey(vcId)) {
|
||||||
LOGGER.debugf("No credential with id %s exists.", 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));
|
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()
|
CredentialOfferURI credentialOfferURI = new CredentialOfferURI()
|
||||||
.setIssuer(OID4VCIssuerWellKnownProvider.getIssuer(session.getContext()) + "/protocol/" + OID4VCLoginProtocolFactory.PROTOCOL_ID + "/" + CREDENTIAL_OFFER_PATH)
|
.setIssuer(OID4VCIssuerWellKnownProvider.getIssuer(session.getContext()) + "/protocol/" + OID4VCLoginProtocolFactory.PROTOCOL_ID + "/" + CREDENTIAL_OFFER_PATH)
|
||||||
.setNonce(nonce);
|
.setNonce(nonce);
|
||||||
|
|
||||||
return Response.ok()
|
return Response.ok()
|
||||||
|
.type(MediaType.APPLICATION_JSON)
|
||||||
.entity(credentialOfferURI)
|
.entity(credentialOfferURI)
|
||||||
.build();
|
.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.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
import jakarta.ws.rs.Path;
|
import jakarta.ws.rs.Path;
|
||||||
import jakarta.ws.rs.core.Response;
|
import jakarta.ws.rs.core.Response;
|
||||||
import jakarta.ws.rs.core.UriBuilder;
|
import jakarta.ws.rs.core.UriBuilder;
|
||||||
import jakarta.ws.rs.core.UriInfo;
|
import jakarta.ws.rs.core.UriInfo;
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
import org.jboss.resteasy.reactive.NoCache;
|
import org.jboss.resteasy.reactive.NoCache;
|
||||||
import org.keycloak.authentication.requiredactions.DeleteAccount;
|
import org.keycloak.authentication.requiredactions.DeleteAccount;
|
||||||
import org.keycloak.common.Profile;
|
import org.keycloak.common.Profile;
|
||||||
|
@ -88,7 +90,8 @@ public class AccountConsole implements AccountResourceProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close() {}
|
public void close() {
|
||||||
|
}
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@NoCache
|
@NoCache
|
||||||
|
@ -149,6 +152,7 @@ public class AccountConsole implements AccountResourceProvider {
|
||||||
map.put("deleteAccountAllowed", deleteAccountAllowed);
|
map.put("deleteAccountAllowed", deleteAccountAllowed);
|
||||||
|
|
||||||
map.put("isViewGroupsEnabled", isViewGroupsEnabled);
|
map.put("isViewGroupsEnabled", isViewGroupsEnabled);
|
||||||
|
map.put("isOid4VciEnabled", Profile.isFeatureEnabled(Profile.Feature.OID4VC_VCI));
|
||||||
|
|
||||||
map.put("updateEmailFeatureEnabled", Profile.isFeatureEnabled(Profile.Feature.UPDATE_EMAIL));
|
map.put("updateEmailFeatureEnabled", Profile.isFeatureEnabled(Profile.Feature.UPDATE_EMAIL));
|
||||||
RequiredActionProviderModel updateEmailActionProvider = realm.getRequiredActionProviderByAlias(UserModel.RequiredAction.UPDATE_EMAIL.name());
|
RequiredActionProviderModel updateEmailActionProvider = realm.getRequiredActionProviderByAlias(UserModel.RequiredAction.UPDATE_EMAIL.name());
|
||||||
|
|
|
@ -58,6 +58,7 @@ import org.keycloak.protocol.oid4vc.model.CredentialRequest;
|
||||||
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
|
import org.keycloak.protocol.oid4vc.model.CredentialResponse;
|
||||||
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
|
import org.keycloak.protocol.oid4vc.model.CredentialsOffer;
|
||||||
import org.keycloak.protocol.oid4vc.model.Format;
|
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.PreAuthorizedGrant;
|
||||||
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
|
import org.keycloak.protocol.oid4vc.model.SupportedCredentialConfiguration;
|
||||||
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
|
import org.keycloak.protocol.oid4vc.model.VerifiableCredential;
|
||||||
|
@ -112,7 +113,7 @@ public class OID4VCIssuerEndpointTest extends OID4VCTest {
|
||||||
authenticator.setTokenString(token);
|
authenticator.setTokenString(token);
|
||||||
|
|
||||||
OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
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);
|
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
|
||||||
authenticator.setTokenString(null);
|
authenticator.setTokenString(null);
|
||||||
OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
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);
|
AppAuthManager.BearerTokenAuthenticator authenticator = new AppAuthManager.BearerTokenAuthenticator(session);
|
||||||
authenticator.setTokenString("invalid-token");
|
authenticator.setTokenString("invalid-token");
|
||||||
OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
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);
|
authenticator.setTokenString(token);
|
||||||
OID4VCIssuerEndpoint oid4VCIssuerEndpoint = prepareIssuerEndpoint(session, authenticator);
|
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());
|
assertEquals("An offer uri should have been returned.", HttpStatus.SC_OK, response.getStatus());
|
||||||
CredentialOfferURI credentialOfferURI = new ObjectMapper().convertValue(response.getEntity(), CredentialOfferURI.class);
|
CredentialOfferURI credentialOfferURI = new ObjectMapper().convertValue(response.getEntity(), CredentialOfferURI.class);
|
||||||
|
|
Loading…
Reference in a new issue