Warning
This repository is intended for prototyping and as a reference implementation. At this time, no security guarantees can be given.
Note
A preprint of our paper providing more background information is available on arXiv. While it is slightly outdated now, it provides a good introduction.
Note
This software artifact was originally intended to support only Gaia-X Participant Credentials. It has since evolved to be fully configurable for almost any Verifiable Credential, almost any wallet application, and almost any current OIDC client.
You operate a service and want to allow your users to sign in using Verifiable Credentials from a mobile wallet. But building that takes considerable time and expertise.
Additionally, you may need an easy solution to dynamically request additional Verifiable Credentials from a user during a session (i.e., "incremental authorization"). That would once again require a completely custom solution.
A service provider can run this dockerized bridge software that acts as a normal
OIDC Provider toward the service. That means any service supporting OIDC or
OAuth 2.0 for sign-ins can immediately be upgraded to accept sign-ins with
Verifiable Credentials. When setting up the bridge software, you can configure
what Verifiable Credentials are accepted and how the data within is put into
id_token
or access_token
. This bridge works with users entirely on mobile,
as well as users on desktop with a mobile wallet.
As a contribution to Gaia-X infrastructure, the main goal here is to enable users to use their Gaia-X Participant Credentials to access systems while making integration simpler through the use of established SSO protocols. The bridge can also be configured to use other Verifiable Credentials.
There are two main components to this project, as well as some additional containers for monitoring and databases. A company (or at least a small consortium) wanting to support SSI in their existing (or new) systems is expected to run this full setup to avoid introducing a middleman:
graph LR
  client[OIDC Client<br><i>Some Service<br>at Service Provider</i>] <-- OIDC via HTTP --> Hydra[Ory Hydra]
  subgraph bridge[Bridge <i>at Service Provider</i>]
  Hydra <-- REST HTTP API --> vclogin
  Hydra <-- TCP / IP --> postgres[(PostgreSQL)]
  vclogin <-- HTTP --> redis[(Redis)]
  end
  vclogin <-- OID4VP + SIOPv2 via HTTP --> altme[Altme Wallet<br><i>on Smartphone</i>]
  subgraph home[End User Devices]
  browser[Browser<br><i>on Desktop or Smartphone</i>]
  altme
  end
  browser <-- HTTP --> client
  browser <-- HTTP --> Hydra
  browser <-- HTTP --> vclogin
*In a deployment, external HTTP interfaces should be using HTTPS instead.
Note
While we test with Altme Wallet, any SSI wallet supporting OID4VP + SIOPv2 works._
Hydra is a OpenID-certified implementation and FOSS. It should allow any OIDC or OAuth2 client to leverage it as an IdP. For development, it has the advantage of giving us the freedom to build a custom login process, as we can specify arbitrary redirects.
This custom Next.js web app provides a user frontend for the login process, as well as necessary backend API routes. It handles the Verifiable Presentation exchange with the wallet, the verification of Verifiable Presentations and of the Verifiable Credentials inside, and the extraction and remapping of claims.
The user's browser starts out on the service website, which takes on the role of an OIDC client here. This browser may run on a desktop or smartphone. The flow is slightly simplified for improved readability. For example, the responses for Redis lookups are not shown. Also, redirects are shown immediately going to the redirect target. This is an authorization code flow:
sequenceDiagram
autonumber
actor User
participant Browser
participant Client as OIDC Client (Web Server)
  participant Wallet as Smartphone Wallet
  participant VPLS as vclogin
  participant Redis
  participant OP as Ory Hydra
User->>Browser: Click "Sign-in"
Browser->>OP: Redirect to /authorize
OP->>VPLS: Redirect to /login?login_challenge=<challenge>
VPLS->>VPLS: Generate random UUID to replace challenge
VPLS->>Redis: Save (UUID,challenge) and (challenge,UUID)
VPLS->>Browser: Send login page
Browser->>User: Show login page with QR Code
User->>Wallet: Scan QR code containing SIOP Provider Invocation
Wallet->>VPLS: GET /api/presentCredential?login_id=<UUID>
VPLS->>VPLS: Generate and sign Auth Request JWT
VPLS->>Wallet: Auth Request with Presentation Definition
Wallet->>VPLS: GET /api/clientMetadata
VPLS->>Wallet: Return static Client Metadata
Wallet->>User: Prompt for VC selection and consent
User->>Wallet: Choose VC(s) and confirm
Wallet->>Wallet: Create and sign VP
Wallet->>VPLS: Submit Auth Response via POST /api/presentCredential
VPLS->>VPLS: Verify VP
VPLS->>VPLS: Process claims from VP
VPLS->>Redis: Get challenge using UUID
VPLS->>OP: Confirm sign-in for subject DID using challenge
OP->>VPLS: Client redirect link
VPLS->>Redis: Save (subject DID, claims)
VPLS->>Redis: Save ("redirect" + UUID, redirect)
loop Every few seconds
Browser->>VPLS: Try to retrieve redirect using challenge
Note over Client,Redis: Failed lookups omitted
end
Browser->>VPLS: Get redirect using challenge
VPLS->>Redis: Get UUID using challenge
VPLS->>Redis: Get redirect using UUID
VPLS->>OP: Redirect to Hydra
OP->>VPLS: Redirect to /api/consent?consent_challenge=<challenge2>
VPLS->>OP: Get consent metadata using challenge2
OP->>VPLS: Metadata including subject DID
VPLS->>Redis: Get claims using subject DID
VPLS->>OP: Confirm consent and send user claims
OP->>Client: Redirect to client callback with code
Client->>OP: Get tokens using code
OP->>Client: Return id_token and access_token
Client->>Browser: Provide access to protected service
Independent of an OIDC session, a service provider can request additional VCs from a user. For example, this can be used to incrementally authorize a user to interact with more parts of a service. An example is that users of a mobility platform could, at any point in time, unlock the car sharing aspect by presenting an additional driver's license VC.
From a technical perspective, the service provider backend initiates the request by sending a login policy to a specific endpoint to create a temporary new authorization endpoint within the bridge. This has the advantage of reusing the bridge's existing verification and powerful policy system. A high-level flow looks like this:
sequenceDiagram
autonumber
actor User
participant Wallet
participant B as SSI-to-OIDC Bridge
participant SP as Service Provider
SP ->> B: POST /api/dynamic/createTempAuthorization
B-->>SP: Return UUID, qrCodeString
SP->>User: Send Auth. page containing QR code
SP->>B: GET /api/dynamic/getAuthResponse
User->>Wallet: Scan QR code
Wallet->>B: GET /api/dynamic/presentCredentialById
B-->>Wallet: Return metadata
Wallet-->>User: Prompt user
User ->>Wallet: Select VC(s)
Wallet->>B: POST /api/dynamic/presentCredentialById
B-->>Wallet: Success
B-->>SP: Return Auth Response
This documentation provides all the necessary information to interact with the dynamic API endpoints used for incremental authorization. The API is documented using Swagger, which provides a user-friendly interface to explore and test the API.
Note
To access the Swagger documentation, run the bridge in development mode and
navigate to http://localhost:5002/api-docs
.
To authenticate requests to the dynamic API in Swagger, an API secret must first
be set in the .env
file in the vclogin
folder with the key name
INCR_AUTH_API_SECRET
.
Note
To authenticate requests to the dynamic API in Swagger, first click on
the "Authorize" button in the top right corner of the Swagger UI. Then, enter
the API key in the "Value" field with the format INCR_AUTH_API_SECRET <api_key>
and click on the "Authorize" button.
A local deployment is a great way to test the bridge and to use it for
prototyping an OIDC client service you are developing. Note that running a full
deployment requires the same steps, but instead of using a tool like ngrok
, a
proper domain has to be set up.
Important
You need to use a tool like ngrok for testing so your smartphone
wallet can access the vclogin backend. However, it can lead to issues with
application/x-www-form-urlencoded
request bodies used in the flow
(https://ngrok.com/docs/ngrok-agent/changelog/#changes-in-22). But you can
manually replay that request on the ngrok interface, if you run into problems.
$ ngrok http 5002
, which will set up a randomly generated URL- enter the domain for the vclogin service into the env file
/vclogin/.env
with keyEXTERNAL_URL
- enter a JWK key (Ed25519) into the env file
./vclogin/.env
with keyDID_KEY_JWK
(example for quick testing:{"kty":"OKP","crv":"Ed25519","x":"cwa3dufHNLg8aQb2eEUqTyoM1cKQW3XnOkMkj_AAl5M","d":"me03qhLByT-NKrfXDeji-lpADSpVOKWoaMUzv5EyzKY"}
) - enter the path to a login policy file into the env file
/vclogin/.env
with keyLOGIN_POLICY
(example for quick testing:./__tests__/testdata/policies/acceptAnything.json
) - OPTIONAL: enter an override for a credential descriptor into the env file
/vclogin/.env
with keyPEX_DESCRIPTOR_OVERRIDE
if direct control over what wallets are asked for is desired (example for quick testing:./__tests__/testdata/pex/descriptorEmailFromAltme.json
) - OPTIONAL: to be able to use or test incremental authorization, set an API key
in the
.env
file in thevclogin
folder with the keyINCR_AUTH_API_SECRET
. - at this point, it needs to be ensured that the container for the vclogin
service is freshly built with the new env file:
docker compose down && docker compose build
$ docker compose up
To validate the running bridge with a simple OIDC client:
Note
You might run into a "Permisson denied" issue when running the shell
script ./test_client.sh
. You need to mark the file as executable using
chmod +x ./test_client.sh
.
$ ./test_client.sh
- go to
http://localhost:9010
in browser - download Altme wallet and go through the setup process
- to make sure you have a credential for testing, click on the "Discover" tab at the bottom and get a "Proof of email" credential
- in your browser, click on "Authorize" and scan the QR code with Altme wallet
- the wallet will prompt you with a list of possible credentials to present, from which you choose one and confirm
- the wallet will show a success message
- within seconds, the browser should redirect
- end up at
http://localhost:9010/callback
with metadata about the login being displayed
Tip
If you want to understand what the wallet is doing in the exchange, go to settings and toggle on "Developer Mode". After scanning a QR code, the wallet will now give you the option to see or save the interaction data. If you just want to continue the sign-in, tap "skip".
Running for development means that all components, apart from the vclogin service, will run containerized. The vclogin service can be edited and run locally with hot-reload for fast testing and iteration.
If you are using VSCode, you may need to prepare configuration files to ensure
eslint
and prettier
work properly:
./.vscode/settings.json
contains:
{
"eslint.workingDirectories": ["./vclogin"]
}
./vclogin/.vscode/settings.json
contains:
{
"prettier.prettierPath": "./node_modules/prettier"
}
To develop the vclogin service, follow these steps:
$ ngrok http 5002
, which will set up a randomly generated URL- create the file
./vclogin/.env
DID_KEY_JWK=<Ed25519 JWK>
EXTERNAL_URL=<ngrok URL>
LOGIN_POLICY=./__tests__/testdata/policies/acceptAnything.json
PEX_DESCRIPTOR_OVERRIDE=./__tests__/testdata/pex/descriptorAnything.json
HYDRA_ADMIN_URL=http://localhost:5001
REDIS_HOST=localhost
REDIS_PORT=6379
API_KEY=<api-key>
Note: The PEX_DESCRIPTOR_OVERRIDE is optional and provides a way to override the automatic descriptor generation.
$ docker compose up --build
$ docker compose stop vclogin
- in
vclogin
directory upon first checkout:$ npm i
- in
vclogin
directory:$ npm run dev
Now you can develop and it will hot-reload. For testing with a client, you may refer to the end of the previous section.
This repository includes unit tests with vitest
and end-to-end tests with
playwright
. You may run them as follows:
npm run test
npm run test:e2e
The login policy is the one configuration file that configures the bridge's
behavior. The most simple example of one looks like this and accepts any
credential, while forwarding all subject fields to the id_token
:
[
{
"credentialID": "1",
"patterns": [
{
"issuer": "*",
"claims": [
{
"claimPath": "$.credentialSubject.*",
"newPath": "$.subjectData",
"required": false
}
]
}
]
}
]
A login policy is always an array of objects that represent expected Verifiable Credentials. For each expected credential, we have to specify a unique ID used for internal tracking. We also need to provide an array of pattern objects, describing what the credential looks like and how its values are used.
A pattern object has the following fields:
claimPath
is a JSONPath that points to one or more values in the credential. If it points to multiple values, they will be aggregated into a new object and indexed by just their final JSONPath component. This is generally convenient, but can lead to values being overwritten if not careful, and working with a credential that uses the same path components in different depths.newPath
is the new path of the value relative to the root of the token it will be written into. This value is optional, as long asclaimPath
points to exactly one value. In that case, it defaults to$.<final claimPath component>
.token
optionally defines if the claim value ends up either in"id_token"
or"access_token"
, with the former being the default.required
is optional and defaults tofalse
A policy pattern can additionally have a constraint field. A full policy making use of this could look like this:
[
{
"credentialId": "1",
"patterns": [
{
"issuer": "did:web:app.altme.io:issuer",
"claims": [
{
"claimPath": "$.credentialSubject.email"
}
],
"constraint": {
"op": "equalsDID",
"a": "$VP.proof.verificationMethod",
"b": "$.credentialSubject.id"
}
}
]
}
]
This example expects a verified Email from the Altme wallet and ensures that the Verifiable Presentation is signed with the same key that is the subject of the Verifiable Credential. That enforces a simple holder binding.
A simple constraint always consists of two operands a
and b
and an operator
op
. The following operators are currently supported:
- equals Strictly compares two string values. Takes two JSONPaths or a JSONPath and a string.
- startsWith Evaluates whether
a
starts withb
. Takes two JSONPaths or a JSONPath and a string. - endsWith Evaluates whether
a
ends withb
. Takes two JSONPaths or a JSONPath and a string. - matches Evaluates whether
a
has a match for regular expressionb
. Takes a JSONPath and a string. - equalsDID Compares two DIDs or DID URLs by only their base DID. Takes two JSONPaths or a JSONPath and a string.
Sometimes, constraints may need to access fields from other Verifiable Credentials in a Verifiable Presentation, or from the Verifiable Presentation itself. For this purpose, we introduce two custom JSONPath roots:
$VP
is the root of the VP.$credentialId
is the root of the Verifiable Credential mapped to the corresponding presentation.
Note that the credentialId
refers to an expected credential, which can be seen
as a "role to fulfill" (e.g., any type of confirmed email credential), not a
specific pattern. Thus, a value that another pattern constraint refers to may
not always be there if the targeted credentialId
has more than one pattern. In
cases where a constraint evaluation error occurs (e.g., unresolvable JSONPath),
the constraint returns false
, but does not immediately cause the top-level
constraint to fail.
A pattern can only have exactly one top-level constraint object, but there are logical operators that can combine multiple constraints:
- and Takes two constraint objects
a
andb
. - or Takes two constraint objects
a
andb
. - not Takes one constraint object
a
The bridge supports multiple expected credentials in a policy file. This allows for more complex scenarios where multiple credentials are needed to perform authorization. An example of such a policy file is:
[
{
"credentialId": "cred_email",
"type": "EmailPass",
"patterns": [
{
"issuer": "did:web:app.altme.io:issuer",
"claims": [
{
"claimPath": "$.credentialSubject.email"
}
]
}
]
},
{
"credentialId": "cred_id",
"type": "VerifiableId",
"patterns": [
{
"issuer": "did:web:app.altme.io:issuer",
"claims": [
{
"claimPath": "$.credentialSubject.id"
}
]
}
]
}
]
Important
Each credentialId
should be unique across all expected credentials in a policy,
and should only consist of alphanumeric characters and underscores. Similarly,
claim values should never be given colliding newPath
values to avoid overwriting
token data.
Note
The expected credentials are matched to the submitted VCs solely on the basis of required fields and constraints. If very similar VCs are required by a policy, it should have constraints that make the matching unambiguous. Otherwise, extracted claims may be switched up.
When having multiple expected credentials, it is possible to define constraints for each as defined in Constraints. This is especially powerful since it is possible to refer to other expected credentials:
[
{
"credentialId": "1",
"type": "EmailPass",
"patterns": [
{
"issuer": "did:web:app.altme.io:issuer",
"claims": [
{
"claimPath": "$.credentialSubject.email"
}
],
"constraint": {
"op": "equalsDID",
"a": "$VP.proof.verificationMethod",
"b": "$1.credentialSubject.id"
}
}
]
},
{
"credentialId": "2",
"type": "VerifiableId",
"patterns": [
{
"issuer": "did:web:app.altme.io:issuer",
"claims": [
{
"claimPath": "$.credentialSubject.id"
}
],
"constraint": {
"op": "equals",
"a": "$2.credentialSubject.id",
"b": "$1.credentialSubject.id"
}
}
]
}
]
Look into the Ory Hydra access token like this:
$ docker run --rm -it \
--network ory-hydra-net \
oryd/hydra:v2.2.0 \
introspect token --skip-tls-verify \
--format json-pretty \
-e http://hydra:4445 \
TOKEN
The vclogin server uses the pino
library for logging. Due to the peculiarities
of Next.js, http events are only logged for API routes.
Hydra is set to a minimal log output. To expand log output, edit the hydra
service in compose.yaml
:
- LOG_LEVEL=debug
- LOG_FORMAT=json
- LOG_LEAK_SENSITIVE_VALUES=true