WellData Implementation Guide
0.1.0 - ci-build
WellData Implementation Guide - Local Development build (v0.1.0) built by the FHIR (HL7® FHIR® Standard) Build Tools. See the Directory of published versions
access_token
DPoP
tokenThis document provides a technical walkthrough for accessing research data in the WellData ecosystem using the delegated consent model through a Trusted Third Party (TTP).
In the WellData research data flow, applications write data and consent directly to the data store, bypassing SOLID/POD ecosystem. This creates a separate flow optimized for research data management, including anonymization and aggregation.
baseUrl
, researcher's internal NUTS url: example https://researcher-nuts-node-int.example.comsubject
, the subject representing the researcher, for example researcher-uni-amsterdam
.async function _createResearcherSubject(baseUrl: string, subject: string) {
let url = `${baseUrl}/internal/vdr/v2/subject`;
const data = {
'subject': subject
}
let resp = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data)
})
if (resp.ok) {
return await resp.json()
}
}
{
"documents": [
{
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json"
],
"assertionMethod": [
"did:web:nuts-node.example.com:iam:8ca9e2b7-d5a8-4b9f-a1c3-e6f8d712b456#7f2a9b5c-c3e8-4fd7-9a84-1b6c5d8e9f42"
],
"authentication": [
"did:web:nuts-node.example.com:iam:8ca9e2b7-d5a8-4b9f-a1c3-e6f8d712b456#7f2a9b5c-c3e8-4fd7-9a84-1b6c5d8e9f42"
],
"capabilityDelegation": [
"did:web:nuts-node.example.com:iam:8ca9e2b7-d5a8-4b9f-a1c3-e6f8d712b456#7f2a9b5c-c3e8-4fd7-9a84-1b6c5d8e9f42"
],
"capabilityInvocation": [
"did:web:nuts-node.example.com:iam:8ca9e2b7-d5a8-4b9f-a1c3-e6f8d712b456#7f2a9b5c-c3e8-4fd7-9a84-1b6c5d8e9f42"
],
"id": "did:web:nuts-node.example.com:iam:8ca9e2b7-d5a8-4b9f-a1c3-e6f8d712b456",
"verificationMethod": [
{
"controller": "did:web:nuts-node.example.com:iam:8ca9e2b7-d5a8-4b9f-a1c3-e6f8d712b456",
"id": "did:web:nuts-node.example.com:iam:8ca9e2b7-d5a8-4b9f-a1c3-e6f8d712b456#7f2a9b5c-c3e8-4fd7-9a84-1b6c5d8e9f42",
"publicKeyJwk": {
"crv": "P-256",
"kty": "EC",
"x": "MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4",
"y": "4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM"
},
"type": "JsonWebKey2020"
}
]
}
],
"subject": "researcher-uni-amsterdam"
}
baseUrl
, researcher's internal NUTS url: example https://researcher-nuts-node-int.example.comsubject
, the subject representing the researcher, for example researcher-uni-amsterdam
.async function _fetchResearcherDid(baseUrl: string, subject: string) {
url = `${baseUrl}/internal/vdr/v2/subject/${subject}`
resp = await fetch(url)
if (resp.ok) {
let dids = await resp.json() as Array<string>;
for (const did of dids) {
if (did.startsWith('did:web:')) {
return did
}
}
console.error("Failed to find a did web")
} else {
console.error("Failed to get did", resp.statusText)
}
}
[
"did:web:nuts-node.example.com:iam:8ca9e2b7-d5a8-4b9f-a1c3-e6f8d712b456"
]
Note: The actual qualification request process (researcher applying to TTP) happens outside of this technical specification - typically via email, web forms, or other communication channels. Once the TTP approves the researcher, they issue a Verifiable Credential using their NUTS node.
baseUrl
, TTP's internal NUTS url: example https://ttp-nuts-node.example.comresearcherDid
, the DID of the researcher being qualifiedcredentialData
, the qualification details for the researcherasync function issueResearcherCredential(baseUrl: string, researcherDid: string, credentialData: any) {
let url = `${baseUrl}/internal/vcr/v2/issuer/vc`;
const data = {
"type": "ResearcherQualification",
"issuer": "did:web:ttp.welldata.example.com",
"credentialSubject": {
"id": researcherDid,
"qualificationLevel": credentialData.level,
"allowedPurposes": credentialData.purposes,
"maxKAnonymity": credentialData.anonymityLevel,
"dataRetentionDays": credentialData.retentionDays
},
"expirationDate": credentialData.expirationDate,
"format": "ldp_vc"
}
let resp = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data)
})
if (resp.ok) {
return await resp.json()
}
}
{
"@context": [
"https://www.w3.org/2018/credentials/v1",
"https://nuts.nl/credentials/v1"
],
"type": ["VerifiableCredential", "ResearcherQualification"],
"issuer": "did:web:ttp.welldata.example.com",
"issuanceDate": "2024-05-12T10:00:00Z",
"expirationDate": "2024-12-31T23:59:59Z",
"credentialSubject": {
"id": "did:web:nuts-node.example.com:iam:8ca9e2b7-d5a8-4b9f-a1c3-e6f8d712b456",
"qualificationLevel": "BasicResearcher",
"allowedPurposes": ["medical_research", "public_health"],
"maxKAnonymity": 5,
"dataRetentionDays": 90
},
"proof": {
"type": "Ed25519Signature2020",
"created": "2024-05-12T10:00:00Z",
"verificationMethod": "did:web:ttp.welldata.example.com#key1",
"proofPurpose": "assertionMethod",
"proofValue": "z58DAdFfagzSzXdP1..."
}
}
Note: The Verifiable Credential from the previous step is transferred to the researcher through methods outside of this technical specification, such as email, secure portals, or other digital channels. Once the researcher receives the credential, they store it in their NUTS wallet as shown below.
baseUrl
, researcher's internal NUTS url: example https://researcher-nuts-node-int.example.comsubject
, the subject representing the researchercredential
, the qualification credential from the TTPasync function storeQualificationCredential(baseUrl: string, subject: string, credential: any) {
let url = `${baseUrl}/internal/vcr/v2/holder/${subject}/vc`;
await fetch(url, {
method: "POST",
cache: "no-store",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(credential),
})
}
access_token
baseUrl
, researcher's internal NUTS url: example https://researcher-nuts-node-int.example.comsubject
, the subject representing the researcher, for example researcher-uni-amsterdam
.token_type
, the token type can be either Bearer or DPoP. DPoP is highly preferred and token type Bearer might become deprecated as soon as all parties have implemented DPoP.A JSON map with the access_token
as access token and, in case of token type the field dpop_kid
is also returned.
export async function getAccessToken(baseUrl: string, subject: string) {
const authorization_server = `https://data-station.welldata.example.com/oauth2/research`
const url = `${baseUrl}/internal/auth/v2/${subject}/request-service-access-token`;
const data = {
"authorization_server": authorization_server,
"scope": "research_data_read",
"token_type": 'DPoP' // "Bearer" if skipping DPoP
}
return await fetch(url, {
method: "POST",
cache: "no-store",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
}).then((data) => data.json())
}
access_token
as access tokendpop_kid
if the token_type
is DPoP, used for requesting the DPoP headerexpires_in
depicting the validity of the token.token_type
, either Bearer of DPoP.{
"access_token": "eyJhbGciOi...X7zPwA",
"dpop_kid": "did:web:nuts-node.example.com:iam:8ca9e2b7-d5a8-4b9f-a1c3-e6f8d712b456#7f2a9b5c-c3e8-4fd7-9a84-1b6c5d8e9f42",
"expires_in": 900,
"token_type": "DPoP",
"scope": "research_data_read"
}
DPoP
tokenThe DPoP header ensures that the same public/private key pair used in requesting the access_token is associated with the key pair used in using the access_token. The access_token can be used for multiple requests as long as it is valid, a dpop header has to be requested for each individual request. Each request needs to be signed by the private key, making sure that the access_token cannot be used by anyone other than the owner of the key pair, adding an extra layer of security. The signature method takes as input the URL and request method.
Fortunately, the NUTS node takes care of most of the complexity in getting the DPoP header, the NUTS client just needs to call the NUTS internal endpoint to fetch the DPoP header.
The following example code fetches the header:
baseUrl
, researcher's internal NUTS url: example https://researcher-nuts-node-int.example.comdpop_kid
: the dpop_kid from the access token response.access_token
: the access_token from the token response.requestMethod
: the HTTP method for the request, for research queries this is POST
. Note: This method must exactly match the method used in the subsequent research data query.requestUrl
: the full URL of the request, for research queries this is ${dataStationUrl}/api/v1/research/query
. Note: This URL must exactly match the URL used in the subsequent research data query.export async function getDpopHeader(baseUrl: string, dpop_kid: string, token: string, requestMethod: string, requestUrl: string): Promise<{ dpop: string }> {
const url = `${baseUrl}/internal/auth/v2/dpop/${encodeURIComponent(dpop_kid)}`;
const data = {
"htm": requestMethod,
"htu": requestUrl,
"token": token
}
return await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data)
})
.then((data) => data.json() as any)
}
dpop
as dpop token.{
"dpop": "eyJhbGciOi...Q8mDw"
}
The research data query can be done with the access_token as follows:
access_token
, the access token as requested above.dpop_header
, the dpop header as requested above.query
, the research query object with consent restrictions.async function queryResearchData(dataStationUrl: string, access_token: string, dpop_header: string, query: any) {
let url = `${dataStationUrl}/api/v1/research/query`;
const resp = await fetch(url, {
method: "POST",
headers: {
"Authorization": `DPoP ${access_token}`,
"DPoP": dpop_header,
"Content-Type": "application/json",
},
body: JSON.stringify(query)
})
if (resp.ok) {
return await resp.json()
}
}
POST /api/v1/research/query HTTP/1.1
Authorization: DPoP {access_token}
DPoP: {dpop_header}
Host: data-station.welldata.example.com
Content-Type: application/json
{
"dataTypes": ["questionnaire_responses"],
"purposeRestrictions": ["medical_research"],
"timeRange": {
"start": "2024-01-01T00:00:00Z",
"end": "2024-05-01T00:00:00Z"
},
"filters": {
"ageRange": {"min": 18, "max": 65}
},
"anonymization": {
"level": "k-anonymity-5"
}
}
{
"queryId": "query-789abc-def123",
"status": "completed",
"results": {
"anonymizedData": [
{
"id": "anon-123",
"responses": {
"q1": "response_value",
"q2": 42
},
"demographics": {
"ageGroup": "25-34",
"region": "anonymized"
}
}
],
"metadata": {
"recordCount": 1247,
"anonymizationMethod": "k-anonymity-5",
"queryTimestamp": "2024-05-12T14:30:00Z"
}
},
"auditLog": {
"researcherDid": "did:web:nuts-node.example.com:iam:8ca9e2b7-d5a8-4b9f-a1c3-e6f8d712b456",
"consentMatches": 1247,
"purposes": ["medical_research"],
"dataTypes": ["questionnaire_responses"]
}
}
Consent verification is performed by first doing an introspect call on the access token. This validates the token and returns the consent information associated with it.
baseUrl
, data station's internal NUTS url: example https://data-station-nuts.example.comaccess_token
, the access token to introspectexport async function introspect(baseUrl: string, access_token: string) {
const url = `${baseUrl}/internal/auth/v2/accesstoken/introspect_extended`
return await fetch(url, {
method: "POST",
body: 'token=' + encodeURIComponent(access_token),
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}).then((data) => data.json())
}
{
"active": true,
"token_type": "Bearer",
"scope": "research_data_read",
"username": "did:web:nuts-node.example.com:iam:8ca9e2b7-d5a8-4b9f-a1c3-e6f8d712b456",
"exp": 1699876543,
"iat": 1699875643,
"jti": "access-token-12345",
"client_id": "researcher-uni-amsterdam",
"consent": {
"purposes": ["medical_research", "public_health"],
"dataTypes": ["questionnaire_responses"],
"maxKAnonymity": 5,
"dataRetentionDays": 90,
"grantedBy": "did:web:ttp.welldata.example.com",
"grantedAt": "2024-05-12T10:00:00Z"
},
"permissions": {
"read": ["questionnaire_responses"],
"anonymizationLevel": "k-anonymity-5",
"purposeRestrictions": ["medical_research", "public_health"]
}
}
After introspecting the token, validate that your research query complies with the consent permissions:
async function validateQueryConsent(access_token: string, query: any, dataStationBaseUrl: string) {
const introspection = await introspect(dataStationBaseUrl, access_token);
if (!introspection.active) {
throw new Error("Access token is not active");
}
// Validate purposes
const allowedPurposes = introspection.consent.purposes;
if (!query.purposeRestrictions.every(p => allowedPurposes.includes(p))) {
throw new Error("Query purposes exceed consent permissions");
}
// Validate data types
const allowedDataTypes = introspection.consent.dataTypes;
if (!query.dataTypes.every(dt => allowedDataTypes.includes(dt))) {
throw new Error("Query data types exceed consent permissions");
}
// Validate anonymization level
if (query.anonymization.level !== `k-anonymity-${introspection.consent.maxKAnonymity}`) {
throw new Error("Query anonymization level does not match consent requirements");
}
return true;
}
When using DPoP tokens, the DPoP header must also be validated in addition to the access token. The procedure for validating request headers is as follows:
Authorization
header from the requestBearer
or DPoP
using regex: ^(Bearer|DPoP) (.+)$
Bearer
, only validate the access tokenDPoP
, validate both the access token and the DPoP headerbaseUrl
, data station's internal NUTS url: example https://data-station-nuts.example.comdpop_header
, the DPoP header from the requestaccess_token
, the access token from the Authorization headercnfJkt
, the thumbprint from the introspection response at the field cnf.jkt
request_url
, the URL of the requestrequest_method
, the HTTP method from the request (e.g., GET
, POST
)export async function validateDpop(
baseUrl: string,
access_token: string,
dpop_header: string,
cnfJkt: string,
request_url: string,
request_method: string
) {
const url = `${baseUrl}/internal/auth/v2/dpop/validate`;
const data = {
"dpop_proof": dpop_header,
"thumbprint": cnfJkt,
"token": access_token,
"url": request_url,
"method": request_method
};
return await fetch(url, {
method: "POST",
body: JSON.stringify(data),
headers: {
'Content-Type': 'application/json'
}
}).then((data) => data.json());
}
{
"valid": true
}
Here's how to validate both the access token and DPoP header together:
async function validateAuthorizationHeader(
baseUrl: string,
authorizationHeader: string,
dpopHeader: string,
requestUrl: string,
requestMethod: string
) {
// Parse authorization header
const authMatch = authorizationHeader.match(/^(Bearer|DPoP) (.+)$/);
if (!authMatch) {
throw new Error("Invalid Authorization header format");
}
const [, tokenType, accessToken] = authMatch;
// Introspect access token
const introspection = await introspect(baseUrl, accessToken);
if (!introspection.active) {
throw new Error("Access token is not active");
}
// Validate scope, issuer, etc.
if (introspection.scope !== "research_data_read") {
throw new Error("Invalid scope for research data access");
}
// If using DPoP, validate the DPoP header
if (tokenType === "DPoP") {
if (!dpopHeader) {
throw new Error("DPoP header required for DPoP tokens");
}
if (!introspection.cnf?.jkt) {
throw new Error("Token confirmation thumbprint missing");
}
const dpopValidation = await validateDpop(
baseUrl,
accessToken,
dpopHeader,
introspection.cnf.jkt,
requestUrl,
requestMethod
);
if (!dpopValidation.valid) {
throw new Error("Invalid DPoP header");
}
}
return introspection;
}
When validating the introspection response, ensure:
active
field: Must be true
(note: response status will be 200 even if token is invalid)scope
field: Must match the expected scope for research data accessiss
field: Must match the authorization server URLclient_id
field: Must match the expected client IDcnf.jkt
field: Must be present for DPoP tokens and used for header validation