Skip to content

Commit 7e8b773

Browse files
committed
draft issuance flow
- fixes nonce endpoint - fixes credential endpoint - fixes/improvements
1 parent 3308901 commit 7e8b773

10 files changed

Lines changed: 174 additions & 87 deletions

File tree

pyeudiw/oauth2/dpop/verifier.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import logging
44
from typing import Optional
55

6+
from pyeudiw.jwk import JWK
67
from pyeudiw.jwk.exceptions import KidError
78
from pyeudiw.jwk.schemas.public import JwkSchema
89
from pyeudiw.jwt.jws_helper import JWSHelper
@@ -105,8 +106,13 @@ def validate(self) -> bool:
105106
if self.dpop_authz_token:
106107
_ath = hashlib.sha256(self.dpop_authz_token.encode())
107108
_ath_b64 = base64.urlsafe_b64encode(_ath.digest()).rstrip(b"=").decode()
108-
proof_valid = _ath_b64 == payload["ath"]
109-
110-
return proof_valid
111-
109+
if _ath_b64 != payload["ath"]:
110+
logger.warning("ath validaton failed")
111+
return False
112+
113+
token_payload = decode_jwt_payload(self.dpop_authz_token) #dpop/token binding
114+
b64_thumbprint = base64.urlsafe_b64encode(JWK(key=self.public_jwk).thumbprint).decode("utf-8").rstrip("=")
115+
if b64_thumbprint != token_payload.get("cnf", {}).get("jkt"):
116+
logger.warning("dpop/token binding failed")
117+
return False
112118
return True

pyeudiw/satosa/frontends/openid4vci/endpoints/base_credential_endpoint.py

Lines changed: 79 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import datetime
22
import json
3+
import time
34
from abc import ABC, abstractmethod
45
from datetime import timedelta
56
from typing import Any
67
from uuid import uuid4
78

9+
from cryptojwt.jwk.jwk import key_from_jwk_dict
810
from jinja2 import Template
911
from pydantic import ValidationError
1012
from pymdoccbor.mdoc.issuer import MdocCborIssuer
13+
14+
from pyeudiw.jwt.utils import decode_jwt_header, decode_jwt_payload
1115
from satosa.context import Context
1216
from satosa.response import Response
1317

@@ -18,11 +22,8 @@
1822
POST_ACCEPTED_METHODS,
1923
VCIBaseEndpoint,
2024
)
21-
from pyeudiw.satosa.frontends.openid4vci.models.credential_endpoint_request import (
22-
CredentialEndpointRequest,
23-
)
2425
from pyeudiw.satosa.frontends.openid4vci.models.openid4vci_basemodel import (
25-
OpenId4VciBaseModel,
26+
OpenId4VciBaseModel, ENDPOINT_CTX, CONFIG_CTX,
2627
)
2728
from pyeudiw.satosa.frontends.openid4vci.storage.engine import OpenId4VciDBEngineHandler
2829
from pyeudiw.satosa.frontends.openid4vci.storage.entity import AuthorizationSession
@@ -37,10 +38,9 @@
3738
CredentialConfiguration,
3839
CredentialConfigurationFormatEnum,
3940
)
40-
from pyeudiw.satosa.utils.session import get_session_id
4141
from pyeudiw.satosa.utils.validation import (
4242
validate_content_type,
43-
validate_request_method,
43+
validate_request_method, DPOP_HEADER, AUTHORIZATION_HEADER,
4444
)
4545
from pyeudiw.sd_jwt.issuer import SDJWTIssuer
4646
from pyeudiw.sd_jwt.utils.yaml_specification import (
@@ -63,6 +63,8 @@
6363

6464
class BaseCredentialEndpoint(ABC, VCIBaseEndpoint):
6565

66+
_ENDPOINT_NAME = "credential"
67+
6668
def __init__(
6769
self,
6870
config: dict,
@@ -111,15 +113,17 @@ def endpoint(self, context: Context) -> Response:
111113
if self.dpop_required:
112114
if (
113115
not context.http_headers
114-
or ("DPoP" not in context.http_headers)
115-
or ("Authorization" not in context.http_headers)
116+
or (DPOP_HEADER not in context.http_headers)
117+
or (AUTHORIZATION_HEADER not in context.http_headers)
116118
):
117119
raise InvalidRequestException(
118120
"Missing DPoP and/or Authorization header"
119121
)
120122

121-
dpop = context.http_headers.get("DPoP")
122-
authz = context.http_headers.get("Authorization")
123+
dpop = context.http_headers.get(DPOP_HEADER)
124+
authz = context.http_headers.get(AUTHORIZATION_HEADER)
125+
if not dpop or not authz:
126+
raise InvalidRequestException("Invalid headers")
123127

124128
try:
125129
dpop_verifier = DPoPVerifier(
@@ -134,14 +138,54 @@ def endpoint(self, context: Context) -> Response:
134138
)
135139
return self._handle_400(context, str(e), e)
136140

137-
entity = self.db_engine.get_by_session_id(get_session_id(context))
138-
req = self.validate_request(context, entity)
139-
credential_id = None
140-
if isinstance(req, CredentialEndpointRequest):
141-
credential_id = (
142-
req.credential_identifier or req.credential_configuration_id
143-
)
144-
return self.to_response(context, entity, credential_id)
141+
auth_token = decode_jwt_payload(dpop_verifier.dpop_authz_token)
142+
entity = self.db_engine.search_session_by_field("access_token_jti", auth_token.get("jti"))
143+
auth_session = AuthorizationSession.model_validate(entity, context={
144+
ENDPOINT_CTX: self._ENDPOINT_NAME,
145+
CONFIG_CTX: self.config
146+
})
147+
148+
data = self._get_body(context) or {}
149+
credential_identifier = data.get("credential_identifier") or ""
150+
# TODO: check/validate scope
151+
if data.get("credential_configuration_id"):
152+
credential_configuration_id = data
153+
else: # validate credential_identifier with authorization_details of token
154+
if auth_session.authorization_details:
155+
for auth_details in auth_session.authorization_details:
156+
if auth_details.credential_identifiers:
157+
if credential_identifier in auth_details.credential_identifiers:
158+
credential_configuration_id = "_".join(credential_identifier.split("_")[:-1])
159+
break
160+
else:
161+
raise InvalidRequestException(
162+
"credential_identifier not match with token authorization_details")
163+
else:
164+
raise InvalidRequestException("Invalid credential_configuration_id")
165+
166+
self.validate_request(context, entity)
167+
168+
proof_jwt = data.get("proof", {}).get("jwt") or ""
169+
request_header = decode_jwt_header(proof_jwt)
170+
request_payload = decode_jwt_payload(proof_jwt)
171+
client_id = request_payload.get("iss")
172+
173+
#validate nonce
174+
self._consume_nonce(request_payload.get("nonce"))
175+
176+
#validate client --> todo: move to self.validate_request
177+
if not (key_attestation := request_header.get("key_attestation")):
178+
return self._handle_400(context, "invalid key_attestation", InvalidRequestException("invalid_proof"))
179+
180+
k_payload = decode_jwt_payload(key_attestation)
181+
for _k in k_payload.get("attested_keys") or []:
182+
t_print = key_from_jwk_dict(_k).thumbprint("SHA-256").decode()
183+
if t_print == client_id:
184+
break
185+
else:
186+
return self._handle_400(context, "client_id mismatch", InvalidRequestException("invalid_proof"))
187+
188+
return self.to_response(context, auth_session, credential_configuration_id)
145189

146190
except (
147191
InvalidRequestException,
@@ -160,6 +204,17 @@ def endpoint(self, context: Context) -> Response:
160204
context, "error during invoke credential endpoint", e
161205
)
162206

207+
def _consume_nonce(self, nonce):
208+
if not (found_nonce := self.db_engine.get("get_nonce", nonce)):
209+
raise InvalidRequestException("Invalid nonce")
210+
211+
now = round(time.time() * 1000)
212+
if found_nonce["created_at"] + found_nonce["expires_in"] <= now:
213+
raise InvalidRequestException("Expired nonce")
214+
215+
if self.db_engine.write("consume_nonce", nonce, now) < 1:
216+
raise Exception("Unable to consume nonce, storage error")
217+
163218
@abstractmethod
164219
def validate_request(self, context: Context, entity: dict) -> OpenId4VciBaseModel:
165220
pass
@@ -171,20 +226,16 @@ def to_response(
171226
pass
172227

173228
def build_credential(
174-
self, context: Context, credential_id: str | None
229+
self, vci_entity: AuthorizationSession, credential_id: str | None
175230
) -> list[str]:
176231
credential_list = []
177-
entity = self.db_engine.get_by_session_id(get_session_id(context))
178-
179-
if not entity:
232+
if not vci_entity:
180233
self._log_error(
181234
self.__class__.__name__, "No entity found for the current session."
182235
)
183236
return credential_list
184237

185-
vci_entity = AuthorizationSession(**entity)
186-
187-
user = self._db_user_engine.get_by_fields(
238+
user = self._db_user_engine.get("get_by_fields",
188239
self._extract_lookup_identifiers(vci_entity.attributes or {})
189240
)
190241
if credential_id:
@@ -315,11 +366,11 @@ def _loader(
315366
)
316367

317368
def _build_status_list_payload(self, user_id: str):
318-
credential = self._db_credential_engine.get_credential_by_user_id(user_id)
369+
# credential = self._db_credential_engine.get("get_credential_by_user_id", user_id) # todo: store credential
319370
return {
320371
"status_list": {
321-
"idx": credential.incremental_id,
322-
"uri": f"{self.status_endpoint}/{credential.incremental_id}",
372+
"idx": "credential.incremental_id",
373+
"uri": f"{self.status_endpoint}/{"credential.incremental_id"}",
323374
}
324375
}
325376

pyeudiw/satosa/frontends/openid4vci/endpoints/credential_endpoint.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -146,21 +146,22 @@ def validate_request(self, context: Context, entity: dict) -> OpenId4VciBaseMode
146146
if not c_req.proof or not c_req.proof.jwt:
147147
return c_req
148148

149-
proof_jws_helper = JWSHelper(self.config["metadata_jwks"])
149+
proof_header = decode_jwt_header(c_req.proof.jwt)
150+
proof_jws_helper = JWSHelper(proof_header.get("jwk"))
150151
proof_payload = proof_jws_helper.verify(c_req.proof.jwt)
151-
_verify_key_attestation(c_req.proof.jwt, proof_payload, self._trust_evaluator)
152+
153+
# _verify_key_attestation(c_req.proof.jwt, proof_payload, self._trust_evaluator) #todo check it
152154
ProofJWT.model_validate(
153-
proof_payload,
155+
(proof_payload | proof_header), #todo split header and payload for Proof model
154156
context={
155157
CLIENT_ID_CTX: entity["client_id"],
156-
ENTITY_ID_CTX: self.entity_id,
157-
NONCE_CTX: entity["c_nonce"],
158+
ENTITY_ID_CTX: self.entity_id
158159
},
159160
)
160161
return c_req
161162

162163
def to_response(
163-
self, context: Context, entity: AuthorizationSession, credential_id: str | None
164+
self, context: Context, auth_session: AuthorizationSession, credential_id: str | None
164165
) -> Response:
165166
"""
166167
Generate a response containing the issued credential.
@@ -171,7 +172,7 @@ def to_response(
171172
172173
Args:
173174
context (Context): The SATOSA context.
174-
entity (AuthorizationSession): The entity containing stateful session data.
175+
auth_session (AuthorizationSession): session data.
175176
176177
Returns:
177178
Response: A SATOSA HTTP response with the issued credential.
@@ -180,6 +181,6 @@ def to_response(
180181
return CredentialEndpointResponse.to_response(
181182
[
182183
CredentialItem(credential=cred)
183-
for cred in self.build_credential(context, credential_id)
184+
for cred in self.build_credential(auth_session, credential_id)
184185
]
185186
)

pyeudiw/satosa/frontends/openid4vci/endpoints/deferred_credential_endpoint.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def validate_request(
4545
)
4646

4747
def to_response(
48-
self, context: Context, entity: AuthorizationSession, credential_id: str | None
48+
self, context: Context, auth_session: AuthorizationSession, credential_id: str | None
4949
) -> Response:
5050
"""
5151
Generate a response containing the issued credential.
@@ -56,7 +56,7 @@ def to_response(
5656
5757
Args:
5858
context (Context): The SATOSA context.
59-
entity (AuthorizationSession): The entity containing stateful session data.
59+
auth_session (AuthorizationSession): The entity containing stateful session data.
6060
6161
Returns:
6262
Response: A SATOSA HTTP response with the issued credential.
@@ -65,6 +65,6 @@ def to_response(
6565
return DeferredCredentialEndpointResponse.to_response(
6666
[
6767
CredentialItem(credential=cred)
68-
for cred in self.build_credential(context, credential_id)
68+
for cred in self.build_credential(auth_session, credential_id)
6969
]
7070
)

pyeudiw/satosa/frontends/openid4vci/endpoints/nonce_endpoint.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import time
12
from uuid import uuid4
23

34
from satosa.context import Context
@@ -21,6 +22,8 @@
2122

2223
class NonceHandler(VCIBaseEndpoint):
2324

25+
_DEFAULT_DURATION = 300
26+
2427
def __init__(
2528
self,
2629
config: dict,
@@ -43,6 +46,7 @@ def __init__(
4346
super().__init__(config, internal_attributes, base_url, name)
4447
self.jws_helper = JWSHelper(self.config["metadata_jwks"])
4548
self.db_engine = OpenId4VciDBEngineHandler(config).db_engine
49+
self.expired_sec = self.config.get("nonce_duration") or self._DEFAULT_DURATION
4650

4751
def endpoint(self, context: Context) -> Response:
4852
"""
@@ -64,8 +68,11 @@ def endpoint(self, context: Context) -> Response:
6468
context, "Request body must be empty for nonce endpoint"
6569
)
6670
c_nonce = str(uuid4())
67-
#todo cipher text + cache
71+
ts = round(time.time() * 1000)
72+
if self.db_engine.write("insert_nonce", nonce=c_nonce, created_at=ts, expires_in=self.expired_sec) < 1:
73+
return self._handle_500(context, "error during nonce generating", Exception("Nonce error"))
6874
return NonceResponse.to_response(c_nonce)
75+
6976
except (InvalidRequestException, InvalidScopeException) as e:
7077
return self._handle_400(context, e.message, e)
7178
except Exception as e:

pyeudiw/satosa/frontends/openid4vci/endpoints/token_endpoint.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import base64
2+
import secrets
23
from enum import Enum
34

45
from cryptojwt.jwk.jwk import key_from_jwk_dict
@@ -156,14 +157,32 @@ def endpoint(self, context: Context):
156157
)
157158
iat = iat_now()
158159
authorization_details = vci_entity.authorization_details
160+
159161
if authorization_details or len(authorization_details) > 0:
160162
for ad in authorization_details:
161-
ad.credential_identifiers = [] #todo fix it
163+
if ad.credential_identifiers is None:
164+
ad.credential_identifiers = []
165+
166+
# TODO: review dataset credential identifier
167+
dataset_cred_id = secrets.token_hex(16) #authentic source dataset identifier
168+
cred_type = ad.credential_configuration_id + "_" + dataset_cred_id
169+
ad.credential_identifiers.append(cred_type)
162170

163171
cnf = self._build_dpop_cnf(dpop_verifier) if dpop_verifier else {}
172+
173+
access_token = self._to_token(iat, vci_entity, TokenTypsEnum.ACCESS_TOKEN_TYP, cnf)
174+
refresh_token = self._to_token(iat, vci_entity, TokenTypsEnum.REFRESH_TOKEN_TYP, cnf)
175+
176+
vci_entity.access_token_jti = access_token.jti
177+
vci_entity.refresh_token_jti = refresh_token.jti
178+
vci_entity.dpop_jkt = cnf.get("jkt")
179+
180+
self.db_engine.upsert_session(vci_entity.session_id, vci_entity.model_dump())
181+
182+
164183
return TokenResponse.to_created_response(
165-
self._to_token(iat, vci_entity, TokenTypsEnum.ACCESS_TOKEN_TYP, cnf),
166-
self._to_token(iat, vci_entity, TokenTypsEnum.REFRESH_TOKEN_TYP, cnf),
184+
self._sign_token(access_token, TokenTypsEnum.ACCESS_TOKEN_TYP.value),
185+
self._sign_token(refresh_token, TokenTypsEnum.REFRESH_TOKEN_TYP.value),
167186
iat + self.config_utils.get_jwt().access_token_exp,
168187
authorization_details,
169188
)
@@ -192,7 +211,7 @@ def _build_dpop_cnf(self, dpop_verifier: DPoPVerifier) -> dict:
192211

193212
def _to_token(
194213
self, iat: int, entity: AuthorizationSession, typ: TokenTypsEnum, cnf: dict = None
195-
) -> str:
214+
) -> AccessToken:
196215

197216
if isinstance(entity, dict):
198217
entity = AuthorizationSession.model_validate(entity, context={
@@ -223,8 +242,7 @@ def _to_token(
223242
)
224243
if typ == TokenTypsEnum.REFRESH_TOKEN_TYP:
225244
token = RefreshToken(**token.model_dump())
226-
227-
return self._sign_token(token, typ.value)
245+
return token
228246

229247
def _sign_token(self, token: BaseModel, typ: str) -> str:
230248
jws_headers = {

0 commit comments

Comments
 (0)