From 50cc13d432ee9808349eab89bc6b2f9713dd8745 Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Tue, 11 Feb 2025 09:25:58 +0300 Subject: [PATCH 01/38] chore: update project progress --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6082774..d98d174 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,9 @@ Here is an outline of the project implementation roadmap and release timelines |Item | Description| Start Date | Duration |End Date |Status |-----|------------|-------|---------|--------|-------| -|Project Planning |This will involve conducting detailed research about open payments, planning the project execution and generating any pre project requirements | 10th Jan 2025| 1 month|10th Feb 2025 |In Progress 🟡| -|Designing the SDK while benchmarking on Typescript OP SDK| This will involve designing of the SDK based on the already existing Typescript Open Payments SDK. The milestone for this phase will be a design documentation for the SDK. Make design decisions regarding the `openapi`, `http-signature` and `py-openpayments-sdk` libraries and implementation plans |10th Feb 2025 | 1 month | 10th March 2025| Not Started ⚫️| -| Setup folder structure and initialize project | This will involve setting up the project by adding code folder structure, linting, tests, pre commit checks and pipeline configuration. Creating a PR to the upstream and getting feedback | 10th March 2025 | 3 weeks | 31st March 2025 | Not Started ⚫️ | +|Project Planning |This will involve conducting detailed research about open payments, planning the project execution and generating any pre project requirements | 10th Jan 2025| 1 month|10th Feb 2025 |Done 🟢| +|Designing the SDK while benchmarking on Typescript OP SDK| This will involve designing of the SDK based on the already existing Typescript Open Payments SDK. The milestone for this phase will be a design documentation for the SDK. Make design decisions regarding the `openapi`, `http-signature` and `py-openpayments-sdk` libraries and implementation plans |10th Feb 2025 | 1 month | 10th March 2025| In Progress 🟡| +| Setup folder structure and initialize project | This will involve setting up the project by adding code folder structure, linting, tests, pre commit checks and pipeline configuration. Creating a PR to the upstream and getting feedback | 10th March 2025 | 3 weeks | 31st March 2025 | In Progress 🟡 | |Implementing auth functions | Implementing the Authentication and Authorization functions of the SDK including creating any required libraries based on the design adopted. Creating a PR and getting feedback from the community and any maintainers. | 31st March 2025 | 1.5 months | 15th May 2025 | Not Started ⚫️ | |Implementing payments functions | Implementing the payments functions of the SDK including creating or updating any required libraries based on the design adopted. Creating a PR and getting feedback from the community and any maintainers. | 15th May 2025 | 2 months | 15th July 2025 | Not Started ⚫️ | | Implementing feedback from Pull Requests | Working on implementing PR feedback to ensure quality of the SDK. | TBD | TBD |TDB| Not Started ⚫️ | From 2130dd88a73fb15c55807701d804043ea893e97b Mon Sep 17 00:00:00 2001 From: kasweka1 Date: Tue, 3 Jun 2025 15:33:40 +0300 Subject: [PATCH 02/38] Make fetch_spec.sh runnable from any directory --- scripts/fetch_spec.sh | 3 +- spec/auth-server.yaml | 192 ++++++++++++++++++++++++++++---- spec/resource-server.yaml | 121 ++++++++++++++------ spec/wallet-address-server.yaml | 8 ++ 4 files changed, 265 insertions(+), 59 deletions(-) diff --git a/scripts/fetch_spec.sh b/scripts/fetch_spec.sh index e203cd2..842911a 100644 --- a/scripts/fetch_spec.sh +++ b/scripts/fetch_spec.sh @@ -7,6 +7,7 @@ fi REPO_URL="https://github.com/interledger/open-payments" REPO_PATH=$(mktemp -d) +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" git clone $REPO_URL "$REPO_PATH" -cp -r "$REPO_PATH"/openapi/* ./spec \ No newline at end of file +cp -r "$REPO_PATH"/openapi/* "$SCRIPT_DIR/../spec" \ No newline at end of file diff --git a/spec/auth-server.yaml b/spec/auth-server.yaml index c275f62..be70301 100644 --- a/spec/auth-server.yaml +++ b/spec/auth-server.yaml @@ -74,10 +74,24 @@ paths: uri: 'https://auth.interledger-test.dev/continue/4CF492MLVMSW9MKMXKHQ' '400': description: Bad Request + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/error-invalid-request' + - $ref: '#/components/schemas/error-invalid-client' '401': description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/error-invalid-client' '500': description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/error-request-denied' requestBody: content: application/json: @@ -135,6 +149,7 @@ paths: identifier: 'http://ilp.interledger-test.dev/bob' client: 'https://webmonize.com/.well-known/pay' description: '' + required: true description: Make a new grant request security: [] tags: @@ -198,10 +213,29 @@ paths: wait: 30 '400': description: Bad Request + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/error-too-fast' + - $ref: '#/components/schemas/error-invalid-client' '401': description: Unauthorized + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/error-invalid-client' + - $ref: '#/components/schemas/error-invalid-continuation' + - $ref: '#/components/schemas/error-request-denied' '404': description: Not Found + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/error-invalid-continuation' + - $ref: '#/components/schemas/error-invalid-request' requestBody: content: application/json: @@ -226,12 +260,21 @@ paths: responses: '204': description: No Content - '400': - description: Bad Request '401': description: Unauthorized + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/error-invalid-client' + - $ref: '#/components/schemas/error-invalid-continuation' + - $ref: '#/components/schemas/error-invalid-request' '404': description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/error-invalid-request' description: Cancel a grant request or delete a grant client side. tags: - grant @@ -279,10 +322,28 @@ paths: assetScale: 2 '400': description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/error-invalid-rotation' '401': description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/error-invalid-client' '404': description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/error-invalid-rotation' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/error-request-denied' description: Management endpoint to rotate access token. tags: - token @@ -293,10 +354,18 @@ paths: responses: '204': description: No Content - '400': - description: Bad Request '401': description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/error-invalid-client' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/error-request-denied' tags: - token components: @@ -512,26 +581,107 @@ components: limits-outgoing: title: limits-outgoing description: Open Payments specific property that defines the limits under which outgoing payments can be created. - type: object - properties: - receiver: - $ref: ./schemas.yaml#/components/schemas/receiver - debitAmount: - description: 'All amounts are maxima, i.e. multiple payments can be created under a grant as long as the total amounts of these payments do not exceed the maximum amount per interval as specified in the grant.' - $ref: ./schemas.yaml#/components/schemas/amount - receiveAmount: - description: 'All amounts are maxima, i.e. multiple payments can be created under a grant as long as the total amounts of these payments do not exceed the maximum amount per interval as specified in the grant.' - $ref: ./schemas.yaml#/components/schemas/amount - interval: - $ref: '#/components/schemas/interval' anyOf: - - not: - required: - - interval - - required: + - type: object + properties: + receiver: + $ref: ./schemas.yaml#/components/schemas/receiver + interval: + $ref: '#/components/schemas/interval' + - type: object + properties: + receiver: + $ref: ./schemas.yaml#/components/schemas/receiver + interval: + $ref: '#/components/schemas/interval' + debitAmount: + description: 'All amounts are maxima, i.e. multiple payments can be created under a grant as long as the total amounts of these payments do not exceed the maximum amount per interval as specified in the grant.' + $ref: ./schemas.yaml#/components/schemas/amount + required: - debitAmount - - required: + - type: object + properties: + receiver: + $ref: ./schemas.yaml#/components/schemas/receiver + interval: + $ref: '#/components/schemas/interval' + receiveAmount: + description: 'All amounts are maxima, i.e. multiple payments can be created under a grant as long as the total amounts of these payments do not exceed the maximum amount per interval as specified in the grant.' + $ref: ./schemas.yaml#/components/schemas/amount + required: - receiveAmount + error-invalid-client: + type: object + properties: + error: + type: object + properties: + description: + type: string + code: + type: string + enum: + - invalid_client + error-invalid-request: + type: object + properties: + error: + type: object + properties: + description: + type: string + code: + type: string + enum: + - invalid_request + error-request-denied: + type: object + properties: + error: + type: object + properties: + description: + type: string + code: + type: string + enum: + - request_denied + error-too-fast: + type: object + properties: + error: + type: object + properties: + description: + type: string + code: + type: string + enum: + - too_fast + error-invalid-continuation: + type: object + properties: + error: + type: object + properties: + description: + type: string + code: + type: string + enum: + - invalid_continuation + error-invalid-rotation: + type: object + properties: + error: + type: object + properties: + description: + type: string + code: + type: string + enum: + - invalid_rotation securitySchemes: GNAP: name: Authorization diff --git a/spec/resource-server.yaml b/spec/resource-server.yaml index fb2cb99..5f11d15 100644 --- a/spec/resource-server.yaml +++ b/spec/resource-server.yaml @@ -16,11 +16,11 @@ info: A quote is a commitment from the Account Servicing Entity to deliver a particular amount to a receiver when sending a particular amount from the wallet address. It is only valid for a limited time. - All resource and collection resource representations use JSON and the media-type `application/json`. + All resource and collection resource representations use JSON and the media-type `application/json`. The `wallet address` resource has three collections of sub-resources: - 1. `/incoming-payments` contains the **incoming payment** sub-resources - 2. `/outgoing-payments` contains the **outgoing payment** sub-resources + 1. `/incoming-payments` contains the **incoming payment** sub-resources + 2. `/outgoing-payments` contains the **outgoing payment** sub-resources 3. `/quotes` contains the **quote** sub-resources Access to resources and permission to execute the methods exposed by the API is determined by the grants given to the client represented by an access token used in API requests. @@ -73,7 +73,6 @@ paths: metadata: externalRef: INV2022-02-0137 createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' methods: - type: ilp ilpAddress: g.ilp.iwuyge987y.98y08y @@ -101,6 +100,7 @@ paths: writeOnly: true metadata: type: object + additionalProperties: true description: Additional metadata associated with the incoming payment. (Optional) required: - walletAddress @@ -176,7 +176,6 @@ paths: externalRef: Coffee w/ Mo on 10 March 22 expiresAt: '2022-04-12T23:20:50.52Z' createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' completed: true - id: 'https://ilp.interledger-test.dev/incoming-payments/32abc219-3dc3-44ec-a225-790cacfca8fa' walletAddress: 'https://ilp.interledger-test.dev/alice/' @@ -186,7 +185,6 @@ paths: assetScale: 2 expiresAt: '2022-04-12T23:20:50.52Z' createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: 'I love your website, Alice! Thanks for the great content' completed: false @@ -207,7 +205,6 @@ paths: completed: true expiresAt: '2022-04-12T23:20:50.52Z' createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: 'I love your website, Alice! Thanks for the great content' - id: 'https://ilp.interledger-test.dev/incoming-payments/016da9d5-c9a4-4c80-a354-86b915a04ff8' @@ -223,7 +220,6 @@ paths: completed: true expiresAt: '2022-04-12T23:20:50.52Z' createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: 'Hi Mo, this is for the cappuccino I bought for you the other day.' externalRef: Coffee w/ Mo on 10 March 22 @@ -284,7 +280,6 @@ paths: metadata: description: Thank you for the shoes. createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' '401': $ref: '#/components/responses/401' '403': @@ -405,7 +400,6 @@ paths: assetCode: USD assetScale: 2 createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: APlusVideo subscription externalRef: 'customer: 847458475' @@ -426,7 +420,6 @@ paths: assetCode: USD assetScale: 2 createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: Thank you for your purchase at ShoeShop! externalRef: INV2022-8943756 @@ -455,7 +448,6 @@ paths: assetCode: USD assetScale: 2 createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: Thank you for your purchase at ShoeShop! externalRef: INV2022-8943756 @@ -476,7 +468,6 @@ paths: assetCode: USD assetScale: 2 createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: APlusVideo subscription externalRef: 'customer: 847458475' @@ -526,6 +517,7 @@ paths: expiresAt: '2022-04-12T23:20:50.52Z' '400': description: No amount was provided and no amount could be inferred from the receiver. + $ref: '#/components/responses/400' '401': $ref: '#/components/responses/401' '403': @@ -647,7 +639,6 @@ paths: completed: false expiresAt: '2022-04-12T23:20:50.52Z' createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: Thanks for the flowers! externalRef: INV-12876 @@ -660,6 +651,7 @@ paths: '403': $ref: '#/components/responses/403' '404': + $ref: '#/components/responses/404' description: Incoming Payment Not Found parameters: - $ref: '#/components/parameters/optional-signature-input' @@ -697,7 +689,6 @@ paths: completed: true expiresAt: '2022-04-12T23:20:50.52Z' createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: 'Hi Mo, this is for the cappuccino I bought for you the other day.' externalRef: Coffee w/ Mo on 10 March 2 @@ -706,6 +697,7 @@ paths: '403': $ref: '#/components/responses/403' '404': + $ref: '#/components/responses/404' description: Incoming Payment Not Found description: |- A client with the appropriate permissions MAY mark a non-expired **incoming payment** as `completed` indicating that the client is not going to make any further payments toward this **incoming payment**, even though the full `incomingAmount` may not have been received. @@ -749,7 +741,6 @@ paths: assetCode: USD assetScale: 2 createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: Thanks for the flowers! externalRef: INV-12876 @@ -758,6 +749,7 @@ paths: '403': $ref: '#/components/responses/403' '404': + $ref: '#/components/responses/404' description: Outgoing Payment Not Found description: A client can fetch the latest state of an outgoing payment. parameters: @@ -800,6 +792,7 @@ paths: '403': $ref: '#/components/responses/403' '404': + $ref: '#/components/responses/404' description: Quote Not Found description: A client can fetch the latest state of a quote. parameters: @@ -827,7 +820,6 @@ components: completed: true expiresAt: '2022-04-12T23:20:50.52Z' createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: 'Hi Mo, this is for the cappuccino I bought for you the other day.' externalRef: Coffee w/ Mo on 10 March 22 @@ -843,7 +835,6 @@ components: assetScale: 2 expiresAt: '2022-04-12T23:20:50.52Z' createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-03-12T23:20:50.52Z' properties: id: type: string @@ -871,22 +862,18 @@ components: format: date-time metadata: type: object + additionalProperties: true description: Additional metadata associated with the incoming payment. (Optional) createdAt: type: string format: date-time description: The date and time when the incoming payment was created. - updatedAt: - type: string - format: date-time - description: The date and time when the incoming payment was updated. required: - id - walletAddress - completed - receivedAmount - createdAt - - updatedAt incoming-payment-with-methods: title: Incoming Payment with payment methods description: An **incoming payment** resource with public details. @@ -946,7 +933,6 @@ components: assetCode: USD assetScale: 2 createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: APlusVideo subscription externalRef: 'customer: 847458475' @@ -963,7 +949,6 @@ components: assetCode: USD assetScale: 2 createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: Thank you for your purchase at ShoeShop! externalRef: INV2022-8943756 @@ -1002,15 +987,12 @@ components: description: The total amount that has been sent under this outgoing payment. metadata: type: object + additionalProperties: true description: Additional metadata associated with the outgoing payment. (Optional) createdAt: type: string format: date-time description: The date and time when the outgoing payment was created. - updatedAt: - type: string - format: date-time - description: The date and time when the outgoing payment was updated. required: - id - walletAddress @@ -1019,7 +1001,6 @@ components: - debitAmount - sentAmount - createdAt - - updatedAt outgoing-payment-with-spent-amounts: title: Outgoing Payment With Grant Spent Amounts description: 'An **outgoing payment** resource represents a payment that will be, is currently being, or has previously been, sent from the wallet address.' @@ -1046,7 +1027,6 @@ components: assetCode: USD assetScale: 2 createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: APlusVideo subscription externalRef: 'customer: 847458475' @@ -1071,7 +1051,6 @@ components: assetCode: USD assetScale: 2 createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: APlusVideo subscription externalRef: 'customer: 847458475' @@ -1115,15 +1094,12 @@ components: description: The total amount successfully received (by all receivers) using the current outgoing payment grant. metadata: type: object + additionalProperties: true description: Additional metadata associated with the outgoing payment. (Optional) createdAt: type: string format: date-time description: The date and time when the outgoing payment was created. - updatedAt: - type: string - format: date-time - description: The date and time when the outgoing payment was updated. required: - id - walletAddress @@ -1132,7 +1108,6 @@ components: - debitAmount - sentAmount - createdAt - - updatedAt quote: title: Quote description: A **quote** resource represents the quoted amount details with which an Outgoing Payment may be created. @@ -1265,6 +1240,38 @@ components: - type: string ilpAddress: string sharedSecret: string + error-response: + type: object + properties: + error: + type: object + properties: + code: + type: string + description: + type: string + details: + type: object + additionalProperties: true + description: Additional details about the error. + required: + - description + - code + required: + - error + examples: + error-response-minimal: + value: + error: + code: 'invalid_request' + description: Error description + error-response-full: + value: + error: + code: 'invalid_request' + description: Error description + details: + anyKey: anyValue securitySchemes: GNAP: name: Authorization @@ -1275,6 +1282,17 @@ components: All requests must also be signed using a client key over some select headers and a digest of the request body. responses: + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/error-response' + examples: + Detailed error response: + $ref: '#/components/examples/error-response-full' + Standard error response: + $ref: '#/components/examples/error-response-minimal' '401': description: Authorization required headers: @@ -1282,8 +1300,37 @@ components: schema: type: string description: The address of the authorization server for grant requests in the format `GNAP as_uri=` + content: + application/json: + schema: + $ref: '#/components/schemas/error-response' + examples: + Detailed error response: + $ref: '#/components/examples/error-response-full' + Standard error response: + $ref: '#/components/examples/error-response-minimal' '403': description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/error-response' + examples: + Detailed error response: + $ref: '#/components/examples/error-response-full' + Standard error response: + $ref: '#/components/examples/error-response-minimal' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/error-response' + examples: + Detailed error response: + $ref: '#/components/examples/error-response-full' + Standard error response: + $ref: '#/components/examples/error-response-minimal' parameters: cursor: schema: diff --git a/spec/wallet-address-server.yaml b/spec/wallet-address-server.yaml index f849600..e55e156 100644 --- a/spec/wallet-address-server.yaml +++ b/spec/wallet-address-server.yaml @@ -39,6 +39,13 @@ paths: assetScale: 2 authServer: 'https://ilp.interledger-test.dev/auth' resourceServer: 'https://ilp.interledger-test.dev/op' + '302': + description: If the `Accept` header is `text/html` in the request, the server may choose to redirect to an HTML page for the given wallet address. + headers: + Location: + description: 'The URL of the wallet address webpage.' + schema: + type: string '404': description: Wallet Address Not Found operationId: get-wallet-address @@ -197,5 +204,6 @@ components: x: oy0L_vTygNE4IogRyn_F5GmHXdqYVjIXkWs2jky7zsI did-document: type: object + additionalProperties: true title: DID Document description: A DID Document using JSON encoding From baa2e9f6a3d862d97fee4d752abfe4a0b12fc0c2 Mon Sep 17 00:00:00 2001 From: kasweka1 Date: Mon, 16 Jun 2025 22:27:27 +0300 Subject: [PATCH 03/38] created requirements.txt --- requirements.txt | 45 +++++++++++++++++++ src/open_payments_sdk/api/auth.py | 17 +++---- src/open_payments_sdk/gnap-utils/__init__.py | 0 .../gnap-utils/http-signatures.py | 1 + 4 files changed, 55 insertions(+), 8 deletions(-) create mode 100644 requirements.txt create mode 100644 src/open_payments_sdk/gnap-utils/__init__.py create mode 100644 src/open_payments_sdk/gnap-utils/http-signatures.py diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..21b59f3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,45 @@ +annotated-types==0.7.0 +anyio==4.8.0 +argcomplete==3.5.3 +asttokens==3.0.0 +black==25.1.0 +certifi==2025.1.31 +click==8.1.8 +colorama==0.4.6 +datamodel-code-generator==0.28.1 +decorator==5.1.1 +executing==2.2.0 +genson==1.3.0 +h11==0.14.0 +httpcore==1.0.7 +httpx==0.28.1 +idna==3.10 +inflect==5.6.2 +iniconfig==2.0.0 +ipdb==0.13.13 +ipython==8.32.0 +isort==6.0.0 +jedi==0.19.2 +Jinja2==3.1.5 +MarkupSafe==3.0.2 +matplotlib-inline==0.1.7 +mypy-extensions==1.0.0 +-e git+https://github.com/Kasweka1/open-payments-python-sdk.git@2130dd88a73fb15c55807701d804043ea893e97b#egg=open_payments_sdk +packaging==24.2 +parso==0.8.4 +pathspec==0.12.1 +platformdirs==4.3.6 +pluggy==1.5.0 +prompt_toolkit==3.0.50 +pure_eval==0.2.3 +pydantic==2.10.6 +pydantic_core==2.27.2 +pyflakes==3.2.0 +Pygments==2.19.1 +pytest==8.3.4 +PyYAML==6.0.2 +sniffio==1.3.1 +stack-data==0.6.3 +traitlets==5.14.3 +typing_extensions==4.12.2 +wcwidth==0.2.13 diff --git a/src/open_payments_sdk/api/auth.py b/src/open_payments_sdk/api/auth.py index 11d429b..9a7685c 100644 --- a/src/open_payments_sdk/api/auth.py +++ b/src/open_payments_sdk/api/auth.py @@ -34,14 +34,6 @@ def delete_grant(self, req_id: str) -> None: url = f"{base_url}/continue/{req_id}" self.http_client.delete(url) - -class AccessTokens: - def __init__(self, auth_server_endpoint: str, http_client: HttpClient = None): - if not http_client: - cfg = Configuration() - http_client = HttpClient(cfg) - - self.http_client = http_client self.auth_server_endpoint = auth_server_endpoint def post_rotate_access_token(self, token_id: str) -> AccessToken: @@ -54,3 +46,12 @@ def delete_access_token(self, token_id: str) -> None: base_url = self.auth_server_endpoint.rstrip("/") url = f"{base_url}/token/{token_id}" self.http_client.delete(url) + + +class AccessTokens: + def __init__(self, auth_server_endpoint: str, http_client: HttpClient = None): + if not http_client: + cfg = Configuration() + http_client = HttpClient(cfg) + + self.http_client = http_client \ No newline at end of file diff --git a/src/open_payments_sdk/gnap-utils/__init__.py b/src/open_payments_sdk/gnap-utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/open_payments_sdk/gnap-utils/http-signatures.py b/src/open_payments_sdk/gnap-utils/http-signatures.py new file mode 100644 index 0000000..9a25b9e --- /dev/null +++ b/src/open_payments_sdk/gnap-utils/http-signatures.py @@ -0,0 +1 @@ +class HttpSignature \ No newline at end of file From 9b725c362e23089f17e6f6988639b789ca4ee7ec Mon Sep 17 00:00:00 2001 From: kasweka1 Date: Tue, 17 Jun 2025 10:56:10 +0300 Subject: [PATCH 04/38] feat: http signature implementations --- poetry.lock | 172 +++++++++++++++++- pyproject.toml | 3 +- .../gnap-utils/http-signatures.py | 1 - .../{gnap-utils => gnap_utils}/__init__.py | 0 .../gnap_utils/http_signatures.py | 55 ++++++ test/unit/test_http_signatures.py | 65 +++++++ 6 files changed, 285 insertions(+), 11 deletions(-) delete mode 100644 src/open_payments_sdk/gnap-utils/http-signatures.py rename src/open_payments_sdk/{gnap-utils => gnap_utils}/__init__.py (100%) create mode 100644 src/open_payments_sdk/gnap_utils/http_signatures.py create mode 100644 test/unit/test_http_signatures.py diff --git a/poetry.lock b/poetry.lock index 5e6f75a..d2b075a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "annotated-types" @@ -31,7 +31,7 @@ typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21)"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] trio = ["trio (>=0.26.1)"] [[package]] @@ -122,6 +122,87 @@ files = [ {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, ] +[[package]] +name = "cffi" +version = "1.17.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, + {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, + {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, + {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, + {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, + {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, + {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, + {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, + {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, + {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, + {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, + {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, + {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, + {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, + {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, + {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, +] + +[package.dependencies] +pycparser = "*" + [[package]] name = "click" version = "8.1.8" @@ -150,6 +231,66 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "cryptography" +version = "45.0.4" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.7" +groups = ["main"] +files = [ + {file = "cryptography-45.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:425a9a6ac2823ee6e46a76a21a4e8342d8fa5c01e08b823c1f19a8b74f096069"}, + {file = "cryptography-45.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:680806cf63baa0039b920f4976f5f31b10e772de42f16310a6839d9f21a26b0d"}, + {file = "cryptography-45.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4ca0f52170e821bc8da6fc0cc565b7bb8ff8d90d36b5e9fdd68e8a86bdf72036"}, + {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f3fe7a5ae34d5a414957cc7f457e2b92076e72938423ac64d215722f6cf49a9e"}, + {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:25eb4d4d3e54595dc8adebc6bbd5623588991d86591a78c2548ffb64797341e2"}, + {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ce1678a2ccbe696cf3af15a75bb72ee008d7ff183c9228592ede9db467e64f1b"}, + {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:49fe9155ab32721b9122975e168a6760d8ce4cffe423bcd7ca269ba41b5dfac1"}, + {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2882338b2a6e0bd337052e8b9007ced85c637da19ef9ecaf437744495c8c2999"}, + {file = "cryptography-45.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:23b9c3ea30c3ed4db59e7b9619272e94891f8a3a5591d0b656a7582631ccf750"}, + {file = "cryptography-45.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0a97c927497e3bc36b33987abb99bf17a9a175a19af38a892dc4bbb844d7ee2"}, + {file = "cryptography-45.0.4-cp311-abi3-win32.whl", hash = "sha256:e00a6c10a5c53979d6242f123c0a97cff9f3abed7f064fc412c36dc521b5f257"}, + {file = "cryptography-45.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:817ee05c6c9f7a69a16200f0c90ab26d23a87701e2a284bd15156783e46dbcc8"}, + {file = "cryptography-45.0.4-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:964bcc28d867e0f5491a564b7debb3ffdd8717928d315d12e0d7defa9e43b723"}, + {file = "cryptography-45.0.4-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6a5bf57554e80f75a7db3d4b1dacaa2764611ae166ab42ea9a72bcdb5d577637"}, + {file = "cryptography-45.0.4-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:46cf7088bf91bdc9b26f9c55636492c1cce3e7aaf8041bbf0243f5e5325cfb2d"}, + {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7bedbe4cc930fa4b100fc845ea1ea5788fcd7ae9562e669989c11618ae8d76ee"}, + {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:eaa3e28ea2235b33220b949c5a0d6cf79baa80eab2eb5607ca8ab7525331b9ff"}, + {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:7ef2dde4fa9408475038fc9aadfc1fb2676b174e68356359632e980c661ec8f6"}, + {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6a3511ae33f09094185d111160fd192c67aa0a2a8d19b54d36e4c78f651dc5ad"}, + {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:06509dc70dd71fa56eaa138336244e2fbaf2ac164fc9b5e66828fccfd2b680d6"}, + {file = "cryptography-45.0.4-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5f31e6b0a5a253f6aa49be67279be4a7e5a4ef259a9f33c69f7d1b1191939872"}, + {file = "cryptography-45.0.4-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:944e9ccf67a9594137f942d5b52c8d238b1b4e46c7a0c2891b7ae6e01e7c80a4"}, + {file = "cryptography-45.0.4-cp37-abi3-win32.whl", hash = "sha256:c22fe01e53dc65edd1945a2e6f0015e887f84ced233acecb64b4daadb32f5c97"}, + {file = "cryptography-45.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:627ba1bc94f6adf0b0a2e35d87020285ead22d9f648c7e75bb64f367375f3b22"}, + {file = "cryptography-45.0.4-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a77c6fb8d76e9c9f99f2f3437c1a4ac287b34eaf40997cfab1e9bd2be175ac39"}, + {file = "cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7aad98a25ed8ac917fdd8a9c1e706e5a0956e06c498be1f713b61734333a4507"}, + {file = "cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3530382a43a0e524bc931f187fc69ef4c42828cf7d7f592f7f249f602b5a4ab0"}, + {file = "cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:6b613164cb8425e2f8db5849ffb84892e523bf6d26deb8f9bb76ae86181fa12b"}, + {file = "cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:96d4819e25bf3b685199b304a0029ce4a3caf98947ce8a066c9137cc78ad2c58"}, + {file = "cryptography-45.0.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b97737a3ffbea79eebb062eb0d67d72307195035332501722a9ca86bab9e3ab2"}, + {file = "cryptography-45.0.4-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4828190fb6c4bcb6ebc6331f01fe66ae838bb3bd58e753b59d4b22eb444b996c"}, + {file = "cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:03dbff8411206713185b8cebe31bc5c0eb544799a50c09035733716b386e61a4"}, + {file = "cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51dfbd4d26172d31150d84c19bbe06c68ea4b7f11bbc7b3a5e146b367c311349"}, + {file = "cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:0339a692de47084969500ee455e42c58e449461e0ec845a34a6a9b9bf7df7fb8"}, + {file = "cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:0cf13c77d710131d33e63626bd55ae7c0efb701ebdc2b3a7952b9b23a0412862"}, + {file = "cryptography-45.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bbc505d1dc469ac12a0a064214879eac6294038d6b24ae9f71faae1448a9608d"}, + {file = "cryptography-45.0.4.tar.gz", hash = "sha256:7405ade85c83c37682c8fe65554759800a4a8c54b2d96e0f8ad114d31b808d57"}, +] + +[package.dependencies] +cffi = {version = ">=1.14", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs ; python_full_version >= \"3.8.0\"", "sphinx-rtd-theme (>=3.0.0) ; python_full_version >= \"3.8.0\""] +docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] +nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_full_version >= \"3.8.0\""] +pep8test = ["check-sdist ; python_full_version >= \"3.8.0\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] +sdist = ["build (>=1.0.0)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi (>=2024)", "cryptography-vectors (==45.0.4)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +test-randomorder = ["pytest-randomly"] + [[package]] name = "datamodel-code-generator" version = "0.28.1" @@ -206,7 +347,7 @@ files = [ ] [package.extras] -tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich"] +tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich ; python_version >= \"3.11\""] [[package]] name = "genson" @@ -273,7 +414,7 @@ httpcore = "==1.*" idna = "*" [package.extras] -brotli = ["brotli", "brotlicffi"] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] @@ -308,7 +449,7 @@ files = [ [package.extras] docs = ["jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx"] -testing = ["pygments", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] +testing = ["pygments", "pytest (>=6)", "pytest-black (>=0.3.7) ; platform_python_implementation != \"PyPy\"", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1) ; platform_python_implementation != \"PyPy\""] [[package]] name = "iniconfig" @@ -365,7 +506,7 @@ typing_extensions = {version = ">=4.6", markers = "python_version < \"3.12\""} [package.extras] all = ["ipython[black,doc,kernel,matplotlib,nbconvert,nbformat,notebook,parallel,qtconsole]", "ipython[test,test-extra]"] black = ["black"] -doc = ["docrepr", "exceptiongroup", "intersphinx_registry", "ipykernel", "ipython[test]", "matplotlib", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "sphinxcontrib-jquery", "tomli", "typing_extensions"] +doc = ["docrepr", "exceptiongroup", "intersphinx_registry", "ipykernel", "ipython[test]", "matplotlib", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "sphinxcontrib-jquery", "tomli ; python_version < \"3.11\"", "typing_extensions"] kernel = ["ipykernel"] matplotlib = ["matplotlib"] nbconvert = ["nbconvert"] @@ -660,6 +801,19 @@ files = [ [package.extras] tests = ["pytest"] +[[package]] +name = "pycparser" +version = "2.22" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, +] + [[package]] name = "pydantic" version = "2.10.6" @@ -679,7 +833,7 @@ typing-extensions = ">=4.12.2" [package.extras] email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] [[package]] name = "pydantic-core" @@ -944,7 +1098,7 @@ description = "A lil' TOML parser" optional = false python-versions = ">=3.8" groups = ["dev"] -markers = "python_version <= \"3.11\"" +markers = "python_version == \"3.11\"" files = [ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, @@ -1023,4 +1177,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">=3.11" -content-hash = "6c8e2abb6d1036ec42bc0a67a634bcbef586b44a6b34946e92f44c2f73c30ac1" +content-hash = "00ebbcef62c2ae9758433586af70f5a34f94a808eb18a27e838c8d743fd4854e" diff --git a/pyproject.toml b/pyproject.toml index ced0687..33856b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,8 @@ license = {text = "Apache-2.0"} requires-python = ">=3.11" dependencies = [ "httpx (>=0.28.1,<0.29.0)", - "pydantic (>=2.10.6,<3.0.0)" + "pydantic (>=2.10.6,<3.0.0)", + "cryptography (>=45.0.4,<46.0.0)" ] [build-system] diff --git a/src/open_payments_sdk/gnap-utils/http-signatures.py b/src/open_payments_sdk/gnap-utils/http-signatures.py deleted file mode 100644 index 9a25b9e..0000000 --- a/src/open_payments_sdk/gnap-utils/http-signatures.py +++ /dev/null @@ -1 +0,0 @@ -class HttpSignature \ No newline at end of file diff --git a/src/open_payments_sdk/gnap-utils/__init__.py b/src/open_payments_sdk/gnap_utils/__init__.py similarity index 100% rename from src/open_payments_sdk/gnap-utils/__init__.py rename to src/open_payments_sdk/gnap_utils/__init__.py diff --git a/src/open_payments_sdk/gnap_utils/http_signatures.py b/src/open_payments_sdk/gnap_utils/http_signatures.py new file mode 100644 index 0000000..175b504 --- /dev/null +++ b/src/open_payments_sdk/gnap_utils/http_signatures.py @@ -0,0 +1,55 @@ +import time +import hashlib +import base64 +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +class HTTPSignatureClient: + @staticmethod + def build_signature_base(headers: dict, method: str, target_uri: str, key_id="eddsa_key_1", algorithm="ed25519") -> str: + created = int(time.time()) + covered_components = [ + "content-type", + "authorization", + "content-digest", + "content-length", + "@method", + "@target-uri" + ] + + signature_lines = [] + + for field in covered_components: + if field.startswith("@"): + if field == "@method": + signature_lines.append(f'"@method": {method.upper()}') + elif field == "@target-uri": + signature_lines.append(f'"@target-uri": {target_uri}') + else: + value = headers.get(field) + if value is not None: + signature_lines.append(f'"{field}": {value}') + + sig_params = f'("content-type" "authorization" "content-digest" "content-length" "@method" "@target-uri");alg="{algorithm}";keyid="{key_id}";created={created}' + signature_lines.append(f'"@signature-params": {sig_params}') + + signature_base = "\n".join(signature_lines) + return signature_base + + @staticmethod + def hash_signature_base(signature_base: str) -> bytes: + """ + Hash the signature base string using SHA-512 and return the digest (bytes) + """ + sha512_hasher = hashlib.sha512() + sha512_hasher.update(signature_base.encode('utf-8')) + return sha512_hasher.digest() + + @staticmethod + def build_signature(hashed_signature_base: str, private_key: str) -> str: + key_bytes = bytes.fromhex(private_key) + key = Ed25519PrivateKey.from_private_bytes(key_bytes) + + signature = key.sign(hashed_signature_base) + + # Return the Base64-encoded signature string + return base64.b64encode(signature).decode("utf-8") \ No newline at end of file diff --git a/test/unit/test_http_signatures.py b/test/unit/test_http_signatures.py new file mode 100644 index 0000000..add7a0f --- /dev/null +++ b/test/unit/test_http_signatures.py @@ -0,0 +1,65 @@ +import pytest +import time +import hashlib +import base64 +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from open_payments_sdk.gnap_utils.http_signatures import HTTPSignatureClient +from cryptography.hazmat.primitives import serialization + + +@pytest.fixture +def sample_headers(): + return { + "content-type": "application/json", + "authorization": "GNAP 123454321", + "content-digest": "sha-512=:abc123xyz=", + "content-length": "18" + } + + +def test_build_signature_base(sample_headers): + base = HTTPSignatureClient.build_signature_base( + headers=sample_headers, + method="POST", + target_uri="https://example.com/payments" + ) + + # It should include all expected lines + assert '"content-type": application/json' in base + assert '"authorization": GNAP 123454321' in base + assert '"@method": POST' in base + assert '"@target-uri": https://example.com/payments' in base + assert "@signature-params" in base + + +def test_hash_signature_base_consistency(): + sig_base = '"authorization": test\n"@method": POST' + digest = HTTPSignatureClient.hash_signature_base(sig_base) + + expected = hashlib.sha512(sig_base.encode("utf-8")).digest() + assert digest == expected + assert isinstance(digest, bytes) + + + +def test_build_signature(): + hashed_signature_base = b'\x00' * 64 # Example hash + private_key = Ed25519PrivateKey.generate() + + private_key_bytes = private_key.private_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PrivateFormat.Raw, + encryption_algorithm=serialization.NoEncryption() + ) + + # Convert to hex string + private_key_hex = private_key_bytes.hex() + + # Call the method to test + signature = HTTPSignatureClient.build_signature(hashed_signature_base, private_key_hex) + + # Decode signature and check + signature_bytes = base64.b64decode(signature) + assert isinstance(signature, str) + assert len(signature_bytes) == 64 + \ No newline at end of file From 61ba6c9bd25aae25bf1f8c4ecd5bd2e5290395a9 Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Tue, 17 Jun 2025 09:59:44 +0200 Subject: [PATCH 05/38] feat: implemented gnap functionality --- poetry.lock | 172 ++++++++++++++++- pyproject.toml | 3 +- scripts/fetch_spec.sh | 0 spec/auth-server.yaml | 192 +++++++++++++++++-- spec/resource-server.yaml | 121 ++++++++---- spec/wallet-address-server.yaml | 8 + src/open_payments_sdk/gnap_utils/__init__.py | 0 src/open_payments_sdk/gnap_utils/hash.py | 12 ++ src/open_payments_sdk/gnap_utils/keys.py | 47 +++++ src/open_payments_sdk/models/keys.py | 22 +++ test/unit/test_hash_verification.py | 15 ++ test/unit/test_key.py | 25 +++ 12 files changed, 549 insertions(+), 68 deletions(-) mode change 100644 => 100755 scripts/fetch_spec.sh create mode 100644 src/open_payments_sdk/gnap_utils/__init__.py create mode 100644 src/open_payments_sdk/gnap_utils/hash.py create mode 100644 src/open_payments_sdk/gnap_utils/keys.py create mode 100644 src/open_payments_sdk/models/keys.py create mode 100644 test/unit/test_hash_verification.py create mode 100644 test/unit/test_key.py diff --git a/poetry.lock b/poetry.lock index 5e6f75a..d2b075a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. [[package]] name = "annotated-types" @@ -31,7 +31,7 @@ typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21)"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] trio = ["trio (>=0.26.1)"] [[package]] @@ -122,6 +122,87 @@ files = [ {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, ] +[[package]] +name = "cffi" +version = "1.17.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, + {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, + {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, + {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, + {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, + {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, + {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, + {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, + {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, + {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, + {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, + {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, + {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, + {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, + {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, + {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, +] + +[package.dependencies] +pycparser = "*" + [[package]] name = "click" version = "8.1.8" @@ -150,6 +231,66 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "cryptography" +version = "45.0.4" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.7" +groups = ["main"] +files = [ + {file = "cryptography-45.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:425a9a6ac2823ee6e46a76a21a4e8342d8fa5c01e08b823c1f19a8b74f096069"}, + {file = "cryptography-45.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:680806cf63baa0039b920f4976f5f31b10e772de42f16310a6839d9f21a26b0d"}, + {file = "cryptography-45.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4ca0f52170e821bc8da6fc0cc565b7bb8ff8d90d36b5e9fdd68e8a86bdf72036"}, + {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f3fe7a5ae34d5a414957cc7f457e2b92076e72938423ac64d215722f6cf49a9e"}, + {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:25eb4d4d3e54595dc8adebc6bbd5623588991d86591a78c2548ffb64797341e2"}, + {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ce1678a2ccbe696cf3af15a75bb72ee008d7ff183c9228592ede9db467e64f1b"}, + {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:49fe9155ab32721b9122975e168a6760d8ce4cffe423bcd7ca269ba41b5dfac1"}, + {file = "cryptography-45.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2882338b2a6e0bd337052e8b9007ced85c637da19ef9ecaf437744495c8c2999"}, + {file = "cryptography-45.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:23b9c3ea30c3ed4db59e7b9619272e94891f8a3a5591d0b656a7582631ccf750"}, + {file = "cryptography-45.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0a97c927497e3bc36b33987abb99bf17a9a175a19af38a892dc4bbb844d7ee2"}, + {file = "cryptography-45.0.4-cp311-abi3-win32.whl", hash = "sha256:e00a6c10a5c53979d6242f123c0a97cff9f3abed7f064fc412c36dc521b5f257"}, + {file = "cryptography-45.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:817ee05c6c9f7a69a16200f0c90ab26d23a87701e2a284bd15156783e46dbcc8"}, + {file = "cryptography-45.0.4-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:964bcc28d867e0f5491a564b7debb3ffdd8717928d315d12e0d7defa9e43b723"}, + {file = "cryptography-45.0.4-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6a5bf57554e80f75a7db3d4b1dacaa2764611ae166ab42ea9a72bcdb5d577637"}, + {file = "cryptography-45.0.4-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:46cf7088bf91bdc9b26f9c55636492c1cce3e7aaf8041bbf0243f5e5325cfb2d"}, + {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7bedbe4cc930fa4b100fc845ea1ea5788fcd7ae9562e669989c11618ae8d76ee"}, + {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:eaa3e28ea2235b33220b949c5a0d6cf79baa80eab2eb5607ca8ab7525331b9ff"}, + {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:7ef2dde4fa9408475038fc9aadfc1fb2676b174e68356359632e980c661ec8f6"}, + {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6a3511ae33f09094185d111160fd192c67aa0a2a8d19b54d36e4c78f651dc5ad"}, + {file = "cryptography-45.0.4-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:06509dc70dd71fa56eaa138336244e2fbaf2ac164fc9b5e66828fccfd2b680d6"}, + {file = "cryptography-45.0.4-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5f31e6b0a5a253f6aa49be67279be4a7e5a4ef259a9f33c69f7d1b1191939872"}, + {file = "cryptography-45.0.4-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:944e9ccf67a9594137f942d5b52c8d238b1b4e46c7a0c2891b7ae6e01e7c80a4"}, + {file = "cryptography-45.0.4-cp37-abi3-win32.whl", hash = "sha256:c22fe01e53dc65edd1945a2e6f0015e887f84ced233acecb64b4daadb32f5c97"}, + {file = "cryptography-45.0.4-cp37-abi3-win_amd64.whl", hash = "sha256:627ba1bc94f6adf0b0a2e35d87020285ead22d9f648c7e75bb64f367375f3b22"}, + {file = "cryptography-45.0.4-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a77c6fb8d76e9c9f99f2f3437c1a4ac287b34eaf40997cfab1e9bd2be175ac39"}, + {file = "cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7aad98a25ed8ac917fdd8a9c1e706e5a0956e06c498be1f713b61734333a4507"}, + {file = "cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:3530382a43a0e524bc931f187fc69ef4c42828cf7d7f592f7f249f602b5a4ab0"}, + {file = "cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:6b613164cb8425e2f8db5849ffb84892e523bf6d26deb8f9bb76ae86181fa12b"}, + {file = "cryptography-45.0.4-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:96d4819e25bf3b685199b304a0029ce4a3caf98947ce8a066c9137cc78ad2c58"}, + {file = "cryptography-45.0.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b97737a3ffbea79eebb062eb0d67d72307195035332501722a9ca86bab9e3ab2"}, + {file = "cryptography-45.0.4-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:4828190fb6c4bcb6ebc6331f01fe66ae838bb3bd58e753b59d4b22eb444b996c"}, + {file = "cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:03dbff8411206713185b8cebe31bc5c0eb544799a50c09035733716b386e61a4"}, + {file = "cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51dfbd4d26172d31150d84c19bbe06c68ea4b7f11bbc7b3a5e146b367c311349"}, + {file = "cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:0339a692de47084969500ee455e42c58e449461e0ec845a34a6a9b9bf7df7fb8"}, + {file = "cryptography-45.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:0cf13c77d710131d33e63626bd55ae7c0efb701ebdc2b3a7952b9b23a0412862"}, + {file = "cryptography-45.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bbc505d1dc469ac12a0a064214879eac6294038d6b24ae9f71faae1448a9608d"}, + {file = "cryptography-45.0.4.tar.gz", hash = "sha256:7405ade85c83c37682c8fe65554759800a4a8c54b2d96e0f8ad114d31b808d57"}, +] + +[package.dependencies] +cffi = {version = ">=1.14", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs ; python_full_version >= \"3.8.0\"", "sphinx-rtd-theme (>=3.0.0) ; python_full_version >= \"3.8.0\""] +docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] +nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_full_version >= \"3.8.0\""] +pep8test = ["check-sdist ; python_full_version >= \"3.8.0\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] +sdist = ["build (>=1.0.0)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi (>=2024)", "cryptography-vectors (==45.0.4)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +test-randomorder = ["pytest-randomly"] + [[package]] name = "datamodel-code-generator" version = "0.28.1" @@ -206,7 +347,7 @@ files = [ ] [package.extras] -tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich"] +tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich ; python_version >= \"3.11\""] [[package]] name = "genson" @@ -273,7 +414,7 @@ httpcore = "==1.*" idna = "*" [package.extras] -brotli = ["brotli", "brotlicffi"] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] @@ -308,7 +449,7 @@ files = [ [package.extras] docs = ["jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx"] -testing = ["pygments", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] +testing = ["pygments", "pytest (>=6)", "pytest-black (>=0.3.7) ; platform_python_implementation != \"PyPy\"", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1) ; platform_python_implementation != \"PyPy\""] [[package]] name = "iniconfig" @@ -365,7 +506,7 @@ typing_extensions = {version = ">=4.6", markers = "python_version < \"3.12\""} [package.extras] all = ["ipython[black,doc,kernel,matplotlib,nbconvert,nbformat,notebook,parallel,qtconsole]", "ipython[test,test-extra]"] black = ["black"] -doc = ["docrepr", "exceptiongroup", "intersphinx_registry", "ipykernel", "ipython[test]", "matplotlib", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "sphinxcontrib-jquery", "tomli", "typing_extensions"] +doc = ["docrepr", "exceptiongroup", "intersphinx_registry", "ipykernel", "ipython[test]", "matplotlib", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "sphinxcontrib-jquery", "tomli ; python_version < \"3.11\"", "typing_extensions"] kernel = ["ipykernel"] matplotlib = ["matplotlib"] nbconvert = ["nbconvert"] @@ -660,6 +801,19 @@ files = [ [package.extras] tests = ["pytest"] +[[package]] +name = "pycparser" +version = "2.22" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, +] + [[package]] name = "pydantic" version = "2.10.6" @@ -679,7 +833,7 @@ typing-extensions = ">=4.12.2" [package.extras] email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] [[package]] name = "pydantic-core" @@ -944,7 +1098,7 @@ description = "A lil' TOML parser" optional = false python-versions = ">=3.8" groups = ["dev"] -markers = "python_version <= \"3.11\"" +markers = "python_version == \"3.11\"" files = [ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, @@ -1023,4 +1177,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">=3.11" -content-hash = "6c8e2abb6d1036ec42bc0a67a634bcbef586b44a6b34946e92f44c2f73c30ac1" +content-hash = "00ebbcef62c2ae9758433586af70f5a34f94a808eb18a27e838c8d743fd4854e" diff --git a/pyproject.toml b/pyproject.toml index ced0687..33856b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,8 @@ license = {text = "Apache-2.0"} requires-python = ">=3.11" dependencies = [ "httpx (>=0.28.1,<0.29.0)", - "pydantic (>=2.10.6,<3.0.0)" + "pydantic (>=2.10.6,<3.0.0)", + "cryptography (>=45.0.4,<46.0.0)" ] [build-system] diff --git a/scripts/fetch_spec.sh b/scripts/fetch_spec.sh old mode 100644 new mode 100755 diff --git a/spec/auth-server.yaml b/spec/auth-server.yaml index c275f62..be70301 100644 --- a/spec/auth-server.yaml +++ b/spec/auth-server.yaml @@ -74,10 +74,24 @@ paths: uri: 'https://auth.interledger-test.dev/continue/4CF492MLVMSW9MKMXKHQ' '400': description: Bad Request + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/error-invalid-request' + - $ref: '#/components/schemas/error-invalid-client' '401': description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/error-invalid-client' '500': description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/error-request-denied' requestBody: content: application/json: @@ -135,6 +149,7 @@ paths: identifier: 'http://ilp.interledger-test.dev/bob' client: 'https://webmonize.com/.well-known/pay' description: '' + required: true description: Make a new grant request security: [] tags: @@ -198,10 +213,29 @@ paths: wait: 30 '400': description: Bad Request + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/error-too-fast' + - $ref: '#/components/schemas/error-invalid-client' '401': description: Unauthorized + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/error-invalid-client' + - $ref: '#/components/schemas/error-invalid-continuation' + - $ref: '#/components/schemas/error-request-denied' '404': description: Not Found + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/error-invalid-continuation' + - $ref: '#/components/schemas/error-invalid-request' requestBody: content: application/json: @@ -226,12 +260,21 @@ paths: responses: '204': description: No Content - '400': - description: Bad Request '401': description: Unauthorized + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/error-invalid-client' + - $ref: '#/components/schemas/error-invalid-continuation' + - $ref: '#/components/schemas/error-invalid-request' '404': description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/error-invalid-request' description: Cancel a grant request or delete a grant client side. tags: - grant @@ -279,10 +322,28 @@ paths: assetScale: 2 '400': description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/error-invalid-rotation' '401': description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/error-invalid-client' '404': description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/error-invalid-rotation' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/error-request-denied' description: Management endpoint to rotate access token. tags: - token @@ -293,10 +354,18 @@ paths: responses: '204': description: No Content - '400': - description: Bad Request '401': description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/error-invalid-client' + '500': + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/error-request-denied' tags: - token components: @@ -512,26 +581,107 @@ components: limits-outgoing: title: limits-outgoing description: Open Payments specific property that defines the limits under which outgoing payments can be created. - type: object - properties: - receiver: - $ref: ./schemas.yaml#/components/schemas/receiver - debitAmount: - description: 'All amounts are maxima, i.e. multiple payments can be created under a grant as long as the total amounts of these payments do not exceed the maximum amount per interval as specified in the grant.' - $ref: ./schemas.yaml#/components/schemas/amount - receiveAmount: - description: 'All amounts are maxima, i.e. multiple payments can be created under a grant as long as the total amounts of these payments do not exceed the maximum amount per interval as specified in the grant.' - $ref: ./schemas.yaml#/components/schemas/amount - interval: - $ref: '#/components/schemas/interval' anyOf: - - not: - required: - - interval - - required: + - type: object + properties: + receiver: + $ref: ./schemas.yaml#/components/schemas/receiver + interval: + $ref: '#/components/schemas/interval' + - type: object + properties: + receiver: + $ref: ./schemas.yaml#/components/schemas/receiver + interval: + $ref: '#/components/schemas/interval' + debitAmount: + description: 'All amounts are maxima, i.e. multiple payments can be created under a grant as long as the total amounts of these payments do not exceed the maximum amount per interval as specified in the grant.' + $ref: ./schemas.yaml#/components/schemas/amount + required: - debitAmount - - required: + - type: object + properties: + receiver: + $ref: ./schemas.yaml#/components/schemas/receiver + interval: + $ref: '#/components/schemas/interval' + receiveAmount: + description: 'All amounts are maxima, i.e. multiple payments can be created under a grant as long as the total amounts of these payments do not exceed the maximum amount per interval as specified in the grant.' + $ref: ./schemas.yaml#/components/schemas/amount + required: - receiveAmount + error-invalid-client: + type: object + properties: + error: + type: object + properties: + description: + type: string + code: + type: string + enum: + - invalid_client + error-invalid-request: + type: object + properties: + error: + type: object + properties: + description: + type: string + code: + type: string + enum: + - invalid_request + error-request-denied: + type: object + properties: + error: + type: object + properties: + description: + type: string + code: + type: string + enum: + - request_denied + error-too-fast: + type: object + properties: + error: + type: object + properties: + description: + type: string + code: + type: string + enum: + - too_fast + error-invalid-continuation: + type: object + properties: + error: + type: object + properties: + description: + type: string + code: + type: string + enum: + - invalid_continuation + error-invalid-rotation: + type: object + properties: + error: + type: object + properties: + description: + type: string + code: + type: string + enum: + - invalid_rotation securitySchemes: GNAP: name: Authorization diff --git a/spec/resource-server.yaml b/spec/resource-server.yaml index fb2cb99..5f11d15 100644 --- a/spec/resource-server.yaml +++ b/spec/resource-server.yaml @@ -16,11 +16,11 @@ info: A quote is a commitment from the Account Servicing Entity to deliver a particular amount to a receiver when sending a particular amount from the wallet address. It is only valid for a limited time. - All resource and collection resource representations use JSON and the media-type `application/json`. + All resource and collection resource representations use JSON and the media-type `application/json`. The `wallet address` resource has three collections of sub-resources: - 1. `/incoming-payments` contains the **incoming payment** sub-resources - 2. `/outgoing-payments` contains the **outgoing payment** sub-resources + 1. `/incoming-payments` contains the **incoming payment** sub-resources + 2. `/outgoing-payments` contains the **outgoing payment** sub-resources 3. `/quotes` contains the **quote** sub-resources Access to resources and permission to execute the methods exposed by the API is determined by the grants given to the client represented by an access token used in API requests. @@ -73,7 +73,6 @@ paths: metadata: externalRef: INV2022-02-0137 createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' methods: - type: ilp ilpAddress: g.ilp.iwuyge987y.98y08y @@ -101,6 +100,7 @@ paths: writeOnly: true metadata: type: object + additionalProperties: true description: Additional metadata associated with the incoming payment. (Optional) required: - walletAddress @@ -176,7 +176,6 @@ paths: externalRef: Coffee w/ Mo on 10 March 22 expiresAt: '2022-04-12T23:20:50.52Z' createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' completed: true - id: 'https://ilp.interledger-test.dev/incoming-payments/32abc219-3dc3-44ec-a225-790cacfca8fa' walletAddress: 'https://ilp.interledger-test.dev/alice/' @@ -186,7 +185,6 @@ paths: assetScale: 2 expiresAt: '2022-04-12T23:20:50.52Z' createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: 'I love your website, Alice! Thanks for the great content' completed: false @@ -207,7 +205,6 @@ paths: completed: true expiresAt: '2022-04-12T23:20:50.52Z' createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: 'I love your website, Alice! Thanks for the great content' - id: 'https://ilp.interledger-test.dev/incoming-payments/016da9d5-c9a4-4c80-a354-86b915a04ff8' @@ -223,7 +220,6 @@ paths: completed: true expiresAt: '2022-04-12T23:20:50.52Z' createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: 'Hi Mo, this is for the cappuccino I bought for you the other day.' externalRef: Coffee w/ Mo on 10 March 22 @@ -284,7 +280,6 @@ paths: metadata: description: Thank you for the shoes. createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' '401': $ref: '#/components/responses/401' '403': @@ -405,7 +400,6 @@ paths: assetCode: USD assetScale: 2 createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: APlusVideo subscription externalRef: 'customer: 847458475' @@ -426,7 +420,6 @@ paths: assetCode: USD assetScale: 2 createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: Thank you for your purchase at ShoeShop! externalRef: INV2022-8943756 @@ -455,7 +448,6 @@ paths: assetCode: USD assetScale: 2 createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: Thank you for your purchase at ShoeShop! externalRef: INV2022-8943756 @@ -476,7 +468,6 @@ paths: assetCode: USD assetScale: 2 createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: APlusVideo subscription externalRef: 'customer: 847458475' @@ -526,6 +517,7 @@ paths: expiresAt: '2022-04-12T23:20:50.52Z' '400': description: No amount was provided and no amount could be inferred from the receiver. + $ref: '#/components/responses/400' '401': $ref: '#/components/responses/401' '403': @@ -647,7 +639,6 @@ paths: completed: false expiresAt: '2022-04-12T23:20:50.52Z' createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: Thanks for the flowers! externalRef: INV-12876 @@ -660,6 +651,7 @@ paths: '403': $ref: '#/components/responses/403' '404': + $ref: '#/components/responses/404' description: Incoming Payment Not Found parameters: - $ref: '#/components/parameters/optional-signature-input' @@ -697,7 +689,6 @@ paths: completed: true expiresAt: '2022-04-12T23:20:50.52Z' createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: 'Hi Mo, this is for the cappuccino I bought for you the other day.' externalRef: Coffee w/ Mo on 10 March 2 @@ -706,6 +697,7 @@ paths: '403': $ref: '#/components/responses/403' '404': + $ref: '#/components/responses/404' description: Incoming Payment Not Found description: |- A client with the appropriate permissions MAY mark a non-expired **incoming payment** as `completed` indicating that the client is not going to make any further payments toward this **incoming payment**, even though the full `incomingAmount` may not have been received. @@ -749,7 +741,6 @@ paths: assetCode: USD assetScale: 2 createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: Thanks for the flowers! externalRef: INV-12876 @@ -758,6 +749,7 @@ paths: '403': $ref: '#/components/responses/403' '404': + $ref: '#/components/responses/404' description: Outgoing Payment Not Found description: A client can fetch the latest state of an outgoing payment. parameters: @@ -800,6 +792,7 @@ paths: '403': $ref: '#/components/responses/403' '404': + $ref: '#/components/responses/404' description: Quote Not Found description: A client can fetch the latest state of a quote. parameters: @@ -827,7 +820,6 @@ components: completed: true expiresAt: '2022-04-12T23:20:50.52Z' createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: 'Hi Mo, this is for the cappuccino I bought for you the other day.' externalRef: Coffee w/ Mo on 10 March 22 @@ -843,7 +835,6 @@ components: assetScale: 2 expiresAt: '2022-04-12T23:20:50.52Z' createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-03-12T23:20:50.52Z' properties: id: type: string @@ -871,22 +862,18 @@ components: format: date-time metadata: type: object + additionalProperties: true description: Additional metadata associated with the incoming payment. (Optional) createdAt: type: string format: date-time description: The date and time when the incoming payment was created. - updatedAt: - type: string - format: date-time - description: The date and time when the incoming payment was updated. required: - id - walletAddress - completed - receivedAmount - createdAt - - updatedAt incoming-payment-with-methods: title: Incoming Payment with payment methods description: An **incoming payment** resource with public details. @@ -946,7 +933,6 @@ components: assetCode: USD assetScale: 2 createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: APlusVideo subscription externalRef: 'customer: 847458475' @@ -963,7 +949,6 @@ components: assetCode: USD assetScale: 2 createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: Thank you for your purchase at ShoeShop! externalRef: INV2022-8943756 @@ -1002,15 +987,12 @@ components: description: The total amount that has been sent under this outgoing payment. metadata: type: object + additionalProperties: true description: Additional metadata associated with the outgoing payment. (Optional) createdAt: type: string format: date-time description: The date and time when the outgoing payment was created. - updatedAt: - type: string - format: date-time - description: The date and time when the outgoing payment was updated. required: - id - walletAddress @@ -1019,7 +1001,6 @@ components: - debitAmount - sentAmount - createdAt - - updatedAt outgoing-payment-with-spent-amounts: title: Outgoing Payment With Grant Spent Amounts description: 'An **outgoing payment** resource represents a payment that will be, is currently being, or has previously been, sent from the wallet address.' @@ -1046,7 +1027,6 @@ components: assetCode: USD assetScale: 2 createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: APlusVideo subscription externalRef: 'customer: 847458475' @@ -1071,7 +1051,6 @@ components: assetCode: USD assetScale: 2 createdAt: '2022-03-12T23:20:50.52Z' - updatedAt: '2022-04-01T10:24:36.11Z' metadata: description: APlusVideo subscription externalRef: 'customer: 847458475' @@ -1115,15 +1094,12 @@ components: description: The total amount successfully received (by all receivers) using the current outgoing payment grant. metadata: type: object + additionalProperties: true description: Additional metadata associated with the outgoing payment. (Optional) createdAt: type: string format: date-time description: The date and time when the outgoing payment was created. - updatedAt: - type: string - format: date-time - description: The date and time when the outgoing payment was updated. required: - id - walletAddress @@ -1132,7 +1108,6 @@ components: - debitAmount - sentAmount - createdAt - - updatedAt quote: title: Quote description: A **quote** resource represents the quoted amount details with which an Outgoing Payment may be created. @@ -1265,6 +1240,38 @@ components: - type: string ilpAddress: string sharedSecret: string + error-response: + type: object + properties: + error: + type: object + properties: + code: + type: string + description: + type: string + details: + type: object + additionalProperties: true + description: Additional details about the error. + required: + - description + - code + required: + - error + examples: + error-response-minimal: + value: + error: + code: 'invalid_request' + description: Error description + error-response-full: + value: + error: + code: 'invalid_request' + description: Error description + details: + anyKey: anyValue securitySchemes: GNAP: name: Authorization @@ -1275,6 +1282,17 @@ components: All requests must also be signed using a client key over some select headers and a digest of the request body. responses: + '400': + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/error-response' + examples: + Detailed error response: + $ref: '#/components/examples/error-response-full' + Standard error response: + $ref: '#/components/examples/error-response-minimal' '401': description: Authorization required headers: @@ -1282,8 +1300,37 @@ components: schema: type: string description: The address of the authorization server for grant requests in the format `GNAP as_uri=` + content: + application/json: + schema: + $ref: '#/components/schemas/error-response' + examples: + Detailed error response: + $ref: '#/components/examples/error-response-full' + Standard error response: + $ref: '#/components/examples/error-response-minimal' '403': description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/error-response' + examples: + Detailed error response: + $ref: '#/components/examples/error-response-full' + Standard error response: + $ref: '#/components/examples/error-response-minimal' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/error-response' + examples: + Detailed error response: + $ref: '#/components/examples/error-response-full' + Standard error response: + $ref: '#/components/examples/error-response-minimal' parameters: cursor: schema: diff --git a/spec/wallet-address-server.yaml b/spec/wallet-address-server.yaml index f849600..e55e156 100644 --- a/spec/wallet-address-server.yaml +++ b/spec/wallet-address-server.yaml @@ -39,6 +39,13 @@ paths: assetScale: 2 authServer: 'https://ilp.interledger-test.dev/auth' resourceServer: 'https://ilp.interledger-test.dev/op' + '302': + description: If the `Accept` header is `text/html` in the request, the server may choose to redirect to an HTML page for the given wallet address. + headers: + Location: + description: 'The URL of the wallet address webpage.' + schema: + type: string '404': description: Wallet Address Not Found operationId: get-wallet-address @@ -197,5 +204,6 @@ components: x: oy0L_vTygNE4IogRyn_F5GmHXdqYVjIXkWs2jky7zsI did-document: type: object + additionalProperties: true title: DID Document description: A DID Document using JSON encoding diff --git a/src/open_payments_sdk/gnap_utils/__init__.py b/src/open_payments_sdk/gnap_utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/open_payments_sdk/gnap_utils/hash.py b/src/open_payments_sdk/gnap_utils/hash.py new file mode 100644 index 0000000..55edca3 --- /dev/null +++ b/src/open_payments_sdk/gnap_utils/hash.py @@ -0,0 +1,12 @@ +import base64 +import hashlib + + +class HashManager: + + @staticmethod + def verify_hash(client_nonce, interact_nonce, interact_ref, auth_server_url, received_hash)->bool: + data = f"{client_nonce}\n{interact_nonce}\n{interact_ref}\n{auth_server_url}/" + hash_bytes = hashlib.sha256(data.encode("utf-8")).digest() + computed_hash = base64.b64encode(hash_bytes).decode("utf-8") + return computed_hash == received_hash diff --git a/src/open_payments_sdk/gnap_utils/keys.py b/src/open_payments_sdk/gnap_utils/keys.py new file mode 100644 index 0000000..8261335 --- /dev/null +++ b/src/open_payments_sdk/gnap_utils/keys.py @@ -0,0 +1,47 @@ +import base64 +import uuid +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives import serialization + +from open_payments_sdk.models.keys import Key, KeyJwks, KeyPair + +class KeyManager: + + @staticmethod + def generate_key_pair() -> KeyPair: + private_key = Ed25519PrivateKey.generate() + public_key = private_key.public_key() + private_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption() + ) + private_key_pem = private_pem.decode('utf-8') + raw_public_bytes = public_key.public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw + ) + x = base64.urlsafe_b64encode(raw_public_bytes).rstrip(b'=').decode() + kid = str(uuid.uuid4()) + key = Key( + kid=kid, + x=x, + alg="EdDSA", + kty="OKP", + crv="Ed25519" + ) + key_jwks = KeyJwks(keys=[key]) + return KeyPair(jwks=key_jwks, private_key_pem=private_key_pem) + + @staticmethod + def load_ed25519_private_key_from_pem(pem_str: str) -> Ed25519PrivateKey: + private_key = serialization.load_pem_private_key( + data=pem_str.encode("utf-8"), + password=None + ) + + if not isinstance(private_key, Ed25519PrivateKey): + raise ValueError("Loaded key is not an Ed25519PrivateKey") + + return private_key + diff --git a/src/open_payments_sdk/models/keys.py b/src/open_payments_sdk/models/keys.py new file mode 100644 index 0000000..d166f82 --- /dev/null +++ b/src/open_payments_sdk/models/keys.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel, ConfigDict +from typing import List + + +class Key(BaseModel): + kid: str + x: str + alg: str + kty: str + crv: str + + model_config = ConfigDict(extra="forbid") + + +class KeyJwks(BaseModel): + keys: List[Key] + + model_config = ConfigDict(extra="forbid") + +class KeyPair(BaseModel): + jwks: KeyJwks + private_key_pem: str diff --git a/test/unit/test_hash_verification.py b/test/unit/test_hash_verification.py new file mode 100644 index 0000000..e4bcc7e --- /dev/null +++ b/test/unit/test_hash_verification.py @@ -0,0 +1,15 @@ +# test_hash_manager.py +import base64 +import hashlib +from open_payments_sdk.gnap_utils.hash import HashManager # Replace with actual module name or use relative import + +def test_verify_hash(): + client_nonce = "abc123" + interact_nonce = "xyz456" + ref = "ref789" + url = "https://auth.example.com" + data = f"{client_nonce}\n{interact_nonce}\n{ref}\n{url}/" + expected = base64.b64encode(hashlib.sha256(data.encode()).digest()).decode() + + assert HashManager.verify_hash(client_nonce, interact_nonce, ref, url, expected) + assert not HashManager.verify_hash(client_nonce, interact_nonce, ref, url, "wrong-hash") diff --git a/test/unit/test_key.py b/test/unit/test_key.py new file mode 100644 index 0000000..3c0e490 --- /dev/null +++ b/test/unit/test_key.py @@ -0,0 +1,25 @@ +from open_payments_sdk.gnap_utils.keys import KeyManager +from open_payments_sdk.models.keys import KeyJwks, KeyPair + + +def test_key_generation(): + key_result = KeyManager.generate_key_pair() + print(key_result) + assert isinstance(key_result, KeyPair) + assert isinstance(key_result.jwks, KeyJwks) + assert len(key_result.jwks.keys) == 1 + key = key_result.jwks.keys[0] + assert key.kty == "OKP" + assert key.crv == "Ed25519" + assert key.alg == "EdDSA" + assert isinstance(key.kid, str) + assert isinstance(key.x, str) + assert len(key.x) >= 43 + +def test_read_key_from_str(): + key_result = KeyManager.generate_key_pair() + private_key = key_result.private_key_pem + ed25519PrivateKey = KeyManager.load_ed25519_private_key_from_pem(private_key) + signature = ed25519PrivateKey.sign(b"my authenticated message") + public_key = ed25519PrivateKey.public_key() + public_key.verify(signature, b"my authenticated message") \ No newline at end of file From 6a2ccd3022938dd1655a0b577d507b7484ee25b5 Mon Sep 17 00:00:00 2001 From: kasweka1 Date: Tue, 17 Jun 2025 11:17:06 +0300 Subject: [PATCH 06/38] chore: removed requirements.txt --- requirements.txt | 45 --------------------------------------------- 1 file changed, 45 deletions(-) delete mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 21b59f3..0000000 --- a/requirements.txt +++ /dev/null @@ -1,45 +0,0 @@ -annotated-types==0.7.0 -anyio==4.8.0 -argcomplete==3.5.3 -asttokens==3.0.0 -black==25.1.0 -certifi==2025.1.31 -click==8.1.8 -colorama==0.4.6 -datamodel-code-generator==0.28.1 -decorator==5.1.1 -executing==2.2.0 -genson==1.3.0 -h11==0.14.0 -httpcore==1.0.7 -httpx==0.28.1 -idna==3.10 -inflect==5.6.2 -iniconfig==2.0.0 -ipdb==0.13.13 -ipython==8.32.0 -isort==6.0.0 -jedi==0.19.2 -Jinja2==3.1.5 -MarkupSafe==3.0.2 -matplotlib-inline==0.1.7 -mypy-extensions==1.0.0 --e git+https://github.com/Kasweka1/open-payments-python-sdk.git@2130dd88a73fb15c55807701d804043ea893e97b#egg=open_payments_sdk -packaging==24.2 -parso==0.8.4 -pathspec==0.12.1 -platformdirs==4.3.6 -pluggy==1.5.0 -prompt_toolkit==3.0.50 -pure_eval==0.2.3 -pydantic==2.10.6 -pydantic_core==2.27.2 -pyflakes==3.2.0 -Pygments==2.19.1 -pytest==8.3.4 -PyYAML==6.0.2 -sniffio==1.3.1 -stack-data==0.6.3 -traitlets==5.14.3 -typing_extensions==4.12.2 -wcwidth==0.2.13 From 87191936ef9f59e0c4977ae05db7629b919f8bd3 Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Tue, 17 Jun 2025 10:46:57 +0200 Subject: [PATCH 07/38] ci: added test workflow for github actions --- .github/workflows/test.yml | 44 ++++++++++++++++++++++++++++++++++++++ test/unit/test_key.py | 1 - 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..493a83f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,44 @@ +name: Test Python Library (Poetry) + +on: + push: + branches: + - '**' + pull_request: + branches: + - '**' + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + python-version: [3.8, 3.10, 3.12] + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Poetry + run: | + curl -sSL https://install.python-poetry.org | python3 - + echo "$HOME/.local/bin" >> $GITHUB_PATH + + - name: Configure Poetry + run: | + poetry config virtualenvs.create false + + - name: Install dependencies + run: | + poetry install --no-interaction --no-root + poetry install --only dev # optional: if your tests are under a dev group + + - name: Run tests + run: | + poetry run pytest diff --git a/test/unit/test_key.py b/test/unit/test_key.py index 3c0e490..b575c4b 100644 --- a/test/unit/test_key.py +++ b/test/unit/test_key.py @@ -4,7 +4,6 @@ def test_key_generation(): key_result = KeyManager.generate_key_pair() - print(key_result) assert isinstance(key_result, KeyPair) assert isinstance(key_result.jwks, KeyJwks) assert len(key_result.jwks.keys) == 1 From adc8124549d6687240996588c7cc3d89645eebb3 Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Tue, 17 Jun 2025 10:52:10 +0200 Subject: [PATCH 08/38] ci: fixed failing test workflow --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 493a83f..c4a64e5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,4 @@ -name: Test Python Library (Poetry) +name: Test Python open payments sdk f on: push: @@ -14,7 +14,7 @@ jobs: strategy: matrix: - python-version: [3.8, 3.10, 3.12] + python-version: [3.11, 3.12] steps: - name: Checkout code From 777b0190d6cf5f1b8f9c1c6e9d2d1c0e5e29196c Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Tue, 17 Jun 2025 10:53:54 +0200 Subject: [PATCH 09/38] ci: run unit tests only --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c4a64e5..3a396ce 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,4 +41,4 @@ jobs: - name: Run tests run: | - poetry run pytest + poetry run pytest unit/ From 0a20138071e31e9bf875c09f38166e80de771985 Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Tue, 17 Jun 2025 10:54:39 +0200 Subject: [PATCH 10/38] ci: run tests for python 3.13 --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3a396ce..9154f5f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,7 +14,7 @@ jobs: strategy: matrix: - python-version: [3.11, 3.12] + python-version: [3.11, 3.12, 3.13] steps: - name: Checkout code From 94cd07f9de2731283bd52fae2384e13e1e501832 Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Tue, 17 Jun 2025 10:55:45 +0200 Subject: [PATCH 11/38] ci: run unit tests only --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9154f5f..2e6650b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,4 +41,4 @@ jobs: - name: Run tests run: | - poetry run pytest unit/ + poetry run pytest test/unit/ From 188cc0d8ecaf959edbef582cb4a5ab63b1dc11a5 Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Tue, 17 Jun 2025 11:05:16 +0200 Subject: [PATCH 12/38] chore: added unit test files --- .github/workflows/test.yml | 4 ++-- test/unit/test_auth.py | 0 test/unit/test_grants.py | 0 test/unit/test_hash_verification.py | 8 ++++---- test/unit/test_resource.py | 0 test/unit/test_wallet.py | 0 6 files changed, 6 insertions(+), 6 deletions(-) create mode 100644 test/unit/test_auth.py create mode 100644 test/unit/test_grants.py create mode 100644 test/unit/test_resource.py create mode 100644 test/unit/test_wallet.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2e6650b..8310e74 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,8 +37,8 @@ jobs: - name: Install dependencies run: | poetry install --no-interaction --no-root - poetry install --only dev # optional: if your tests are under a dev group + poetry install --only dev - - name: Run tests + - name: Run Unit Tests run: | poetry run pytest test/unit/ diff --git a/test/unit/test_auth.py b/test/unit/test_auth.py new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/test_grants.py b/test/unit/test_grants.py new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/test_hash_verification.py b/test/unit/test_hash_verification.py index e4bcc7e..813fd8f 100644 --- a/test/unit/test_hash_verification.py +++ b/test/unit/test_hash_verification.py @@ -1,15 +1,15 @@ # test_hash_manager.py import base64 import hashlib -from open_payments_sdk.gnap_utils.hash import HashManager # Replace with actual module name or use relative import +from open_payments_sdk.gnap_utils.hash import HashManager as h # Replace with actual module name or use relative import def test_verify_hash(): client_nonce = "abc123" interact_nonce = "xyz456" ref = "ref789" - url = "https://auth.example.com" + url = "https://auth.interledger.com" data = f"{client_nonce}\n{interact_nonce}\n{ref}\n{url}/" expected = base64.b64encode(hashlib.sha256(data.encode()).digest()).decode() - assert HashManager.verify_hash(client_nonce, interact_nonce, ref, url, expected) - assert not HashManager.verify_hash(client_nonce, interact_nonce, ref, url, "wrong-hash") + assert h.verify_hash(client_nonce, interact_nonce, ref, url, expected) + assert not h.verify_hash(client_nonce, interact_nonce, ref, url, "wrong-hash") diff --git a/test/unit/test_resource.py b/test/unit/test_resource.py new file mode 100644 index 0000000..e69de29 diff --git a/test/unit/test_wallet.py b/test/unit/test_wallet.py new file mode 100644 index 0000000..e69de29 From 00885a395d00dd63d869598b718b2ceddebba3e1 Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Tue, 17 Jun 2025 11:16:24 +0200 Subject: [PATCH 13/38] chore: fixed whitespace issues --- src/open_payments_sdk/api/auth.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/open_payments_sdk/api/auth.py b/src/open_payments_sdk/api/auth.py index 9a7685c..ef8d996 100644 --- a/src/open_payments_sdk/api/auth.py +++ b/src/open_payments_sdk/api/auth.py @@ -34,6 +34,14 @@ def delete_grant(self, req_id: str) -> None: url = f"{base_url}/continue/{req_id}" self.http_client.delete(url) + +class AccessTokens: + def __init__(self, auth_server_endpoint: str, http_client: HttpClient = None): + if not http_client: + cfg = Configuration() + http_client = HttpClient(cfg) + + self.http_client = http_client self.auth_server_endpoint = auth_server_endpoint def post_rotate_access_token(self, token_id: str) -> AccessToken: @@ -45,13 +53,4 @@ def post_rotate_access_token(self, token_id: str) -> AccessToken: def delete_access_token(self, token_id: str) -> None: base_url = self.auth_server_endpoint.rstrip("/") url = f"{base_url}/token/{token_id}" - self.http_client.delete(url) - - -class AccessTokens: - def __init__(self, auth_server_endpoint: str, http_client: HttpClient = None): - if not http_client: - cfg = Configuration() - http_client = HttpClient(cfg) - - self.http_client = http_client \ No newline at end of file + self.http_client.delete(url) \ No newline at end of file From 250d03b04a6d6bee71f840e2e527f6540e3e3612 Mon Sep 17 00:00:00 2001 From: kasweka1 Date: Tue, 17 Jun 2025 14:28:03 +0300 Subject: [PATCH 14/38] feat: updated readme docs --- README.md | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4f8818c..36aae4a 100644 --- a/README.md +++ b/README.md @@ -19,15 +19,38 @@ Open Payments is an open API standard that can be implemented by account servici An Open Payments server runs two sub-systems, a resource server which exposes APIs for performing functions against the underlying accounts and authorization server which exposes APIs compliant with the GNAP standard for getting grants to access the resource server APIs. +### Dependencies + +- Interledger + +New to Interledger? +Never heard of Interledger before? Or would you like to learn more? Here are some excellent places to start: + +- [Interledger Website](https://interledger.org/) +- [Interledger Specification](https://interledger.org/developers/rfcs/interledger-protocol/) +- [Interledger Explainer Video](https://x.com/Interledger/status/1567916000074678272) +- [Open Payments](https://openpayments.dev/) +- [Web monetization](https://webmonetization.org/) + + ## Local development -### Dependencies + - Python >= 3.11 + + To install python visit [Python Download](https://www.python.org/downloads/) - Poetry + To install poetry visiit [Poetry Documentation](https://python-poetry.org/docs/) ### Installation +1. Activate your virtual emvironment. No need to create one, Poetry creates one. + [Read Poetry documentation](https://python-poetry.org/docs/managing-environments/) to read how to activate + + +2. Install the dependencies un the poetry.lock + ``` > poetry install ``` From 25b523c3515fa8025276a18480dd33d5995d6cb3 Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Tue, 17 Jun 2025 15:35:06 +0200 Subject: [PATCH 15/38] chore: added get signed headers function --- .../gnap_utils/http_signatures.py | 27 ++++++---- .../models/http_signatures.py | 14 ++++++ test/unit/test_auth.py | 0 test/unit/test_grants.py | 0 test/unit/test_http_signatures.py | 50 +++++++++++++------ test/unit/test_resource.py | 0 test/unit/test_wallet.py | 0 7 files changed, 68 insertions(+), 23 deletions(-) create mode 100644 src/open_payments_sdk/models/http_signatures.py delete mode 100644 test/unit/test_auth.py delete mode 100644 test/unit/test_grants.py delete mode 100644 test/unit/test_resource.py delete mode 100644 test/unit/test_wallet.py diff --git a/src/open_payments_sdk/gnap_utils/http_signatures.py b/src/open_payments_sdk/gnap_utils/http_signatures.py index 175b504..f91cb80 100644 --- a/src/open_payments_sdk/gnap_utils/http_signatures.py +++ b/src/open_payments_sdk/gnap_utils/http_signatures.py @@ -1,11 +1,13 @@ import time import hashlib import base64 -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from open_payments_sdk.gnap_utils.keys import KeyManager +from open_payments_sdk.models.http_signatures import SignatureBaseReturn, SignatureHeaders class HTTPSignatureClient: @staticmethod - def build_signature_base(headers: dict, method: str, target_uri: str, key_id="eddsa_key_1", algorithm="ed25519") -> str: + def build_signature_base(headers: dict, method: str, target_uri: str, key_id: str, algorithm="ed25519") -> SignatureBaseReturn: created = int(time.time()) covered_components = [ "content-type", @@ -29,11 +31,11 @@ def build_signature_base(headers: dict, method: str, target_uri: str, key_id="ed if value is not None: signature_lines.append(f'"{field}": {value}') - sig_params = f'("content-type" "authorization" "content-digest" "content-length" "@method" "@target-uri");alg="{algorithm}";keyid="{key_id}";created={created}' + sig_params = f'("content-type" "content-digest" "content-length" "authorization" "@method" "@target-uri");alg="{algorithm}";keyid="{key_id}";created={created}' signature_lines.append(f'"@signature-params": {sig_params}') signature_base = "\n".join(signature_lines) - return signature_base + return SignatureBaseReturn(signature_params=sig_params,signature_base=signature_base) @staticmethod def hash_signature_base(signature_base: str) -> bytes: @@ -46,10 +48,17 @@ def hash_signature_base(signature_base: str) -> bytes: @staticmethod def build_signature(hashed_signature_base: str, private_key: str) -> str: - key_bytes = bytes.fromhex(private_key) - key = Ed25519PrivateKey.from_private_bytes(key_bytes) - + key = KeyManager.load_ed25519_private_key_from_pem(private_key) signature = key.sign(hashed_signature_base) - # Return the Base64-encoded signature string - return base64.b64encode(signature).decode("utf-8") \ No newline at end of file + return base64.b64encode(signature).decode("utf-8") + + @staticmethod + def get_signature_headers(headers: dict, method: str, target_uri: str, key_id: str, private_key: str, algorithm="ed25519") -> SignatureHeaders: + """ + Returns signature and signature params as string to be used in headers + """ + signature_base_details = HTTPSignatureClient.build_signature_base(headers, method, target_uri, key_id, algorithm) + signature_base_hash = HTTPSignatureClient.hash_signature_base(signature_base_details.signature_base) + signature = HTTPSignatureClient.build_signature(signature_base_hash,private_key=private_key) + return SignatureHeaders(signature_input=signature_base_details.signature_params,signature=signature) \ No newline at end of file diff --git a/src/open_payments_sdk/models/http_signatures.py b/src/open_payments_sdk/models/http_signatures.py new file mode 100644 index 0000000..1d24a7f --- /dev/null +++ b/src/open_payments_sdk/models/http_signatures.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel, ConfigDict + +class SignatureBaseReturn(BaseModel): + signature_params: str + signature_base: str + + model_config = ConfigDict(extra='forbid') + +class SignatureHeaders(BaseModel): + signature_input: str + signature: str + + model_config = ConfigDict(extra="forbid") + \ No newline at end of file diff --git a/test/unit/test_auth.py b/test/unit/test_auth.py deleted file mode 100644 index e69de29..0000000 diff --git a/test/unit/test_grants.py b/test/unit/test_grants.py deleted file mode 100644 index e69de29..0000000 diff --git a/test/unit/test_http_signatures.py b/test/unit/test_http_signatures.py index add7a0f..857195c 100644 --- a/test/unit/test_http_signatures.py +++ b/test/unit/test_http_signatures.py @@ -1,5 +1,5 @@ +import uuid import pytest -import time import hashlib import base64 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey @@ -16,20 +16,31 @@ def sample_headers(): "content-length": "18" } +@pytest.fixture +def private_key_str(): + privkey = Ed25519PrivateKey.generate() + private_pem = privkey.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption() + ) + return private_pem.decode("utf-8") + def test_build_signature_base(sample_headers): base = HTTPSignatureClient.build_signature_base( headers=sample_headers, method="POST", - target_uri="https://example.com/payments" + target_uri="https://example.com/payments", + key_id=uuid.uuid4() ) # It should include all expected lines - assert '"content-type": application/json' in base - assert '"authorization": GNAP 123454321' in base - assert '"@method": POST' in base - assert '"@target-uri": https://example.com/payments' in base - assert "@signature-params" in base + assert '"content-type": application/json' in base.signature_base + assert '"authorization": GNAP 123454321' in base.signature_base + assert '"@method": POST' in base.signature_base + assert '"@target-uri": https://example.com/payments' in base.signature_base + assert "@signature-params" in base.signature_base def test_hash_signature_base_consistency(): @@ -46,20 +57,31 @@ def test_build_signature(): hashed_signature_base = b'\x00' * 64 # Example hash private_key = Ed25519PrivateKey.generate() - private_key_bytes = private_key.private_bytes( - encoding=serialization.Encoding.Raw, - format=serialization.PrivateFormat.Raw, + private_key_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption() ) - # Convert to hex string - private_key_hex = private_key_bytes.hex() - # Call the method to test - signature = HTTPSignatureClient.build_signature(hashed_signature_base, private_key_hex) + signature = HTTPSignatureClient.build_signature(hashed_signature_base, private_key_pem.decode("utf-8")) # Decode signature and check signature_bytes = base64.b64decode(signature) assert isinstance(signature, str) assert len(signature_bytes) == 64 + +def test_get_signature_headers(private_key_str,sample_headers): + signature_headers = HTTPSignatureClient.get_signature_headers( + headers=sample_headers, + method="POST", + target_uri="https://example.com/payments", + key_id=uuid.uuid4(), + private_key=private_key_str + ) + print(f"Signature-Input: {signature_headers.signature_input}") + print(f"Signature: {signature_headers.signature}") + assert "alg=\"ed25519\"" in signature_headers.signature_input + assert "keyid=" in signature_headers.signature_input + assert signature_headers.signature_input.startswith('("content-type"') \ No newline at end of file diff --git a/test/unit/test_resource.py b/test/unit/test_resource.py deleted file mode 100644 index e69de29..0000000 diff --git a/test/unit/test_wallet.py b/test/unit/test_wallet.py deleted file mode 100644 index e69de29..0000000 From a2aed05482d640c01d30e10c46fda5214e00786f Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Tue, 17 Jun 2025 15:38:18 +0200 Subject: [PATCH 16/38] docs: updated documentation --- README.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 36aae4a..ace2c4e 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,10 @@ Open Payments is an open API standard that can be implemented by account servici An Open Payments server runs two sub-systems, a resource server which exposes APIs for performing functions against the underlying accounts and authorization server which exposes APIs compliant with the GNAP standard for getting grants to access the resource server APIs. -### Dependencies -- Interledger +## Interledger -New to Interledger? -Never heard of Interledger before? Or would you like to learn more? Here are some excellent places to start: +If you would like to learn more about Interledger, here are some excellent resources: - [Interledger Website](https://interledger.org/) - [Interledger Specification](https://interledger.org/developers/rfcs/interledger-protocol/) From f56b5133b0de84885f995d34f4b12987ba4a4d86 Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Wed, 18 Jun 2025 01:40:53 +0200 Subject: [PATCH 17/38] feat: added http signatures to resource and grant classes --- README.md | 2 + src/client/__init__.py | 0 src/client/client.py | 47 ++++ src/open_payments_sdk/api/auth.py | 157 +++++++++-- src/open_payments_sdk/api/resource.py | 256 +++++++++++++++--- src/open_payments_sdk/api/wallet.py | 16 +- src/open_payments_sdk/gnap_utils/hash.py | 10 +- .../gnap_utils/http_signatures.py | 57 ++-- src/open_payments_sdk/gnap_utils/keys.py | 11 +- src/open_payments_sdk/gnap_utils/security.py | 60 ++++ src/open_payments_sdk/http.py | 8 +- src/open_payments_sdk/models/keys.py | 2 +- src/open_payments_sdk/models/resource.py | 16 +- test/integration/test_grants.py | 16 +- test/unit/test_hash_verification.py | 4 +- test/unit/test_http_signatures.py | 19 +- test/unit/test_key.py | 8 +- test/unit/test_op_client.py | 29 ++ 18 files changed, 593 insertions(+), 125 deletions(-) create mode 100644 src/client/__init__.py create mode 100644 src/client/client.py create mode 100644 src/open_payments_sdk/gnap_utils/security.py create mode 100644 test/unit/test_op_client.py diff --git a/README.md b/README.md index ace2c4e..f78ca8e 100644 --- a/README.md +++ b/README.md @@ -52,3 +52,5 @@ If you would like to learn more about Interledger, here are some excellent resou ``` > poetry install ``` +# Usage +To use the library, you will need to install it. \ No newline at end of file diff --git a/src/client/__init__.py b/src/client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/client/client.py b/src/client/client.py new file mode 100644 index 0000000..42bdfa4 --- /dev/null +++ b/src/client/client.py @@ -0,0 +1,47 @@ +""" +Open Payments API Client Module +""" + +from open_payments_sdk.api.auth import AccessTokens, Grants +from open_payments_sdk.api.resource import IncomingPayments, OutgoingPayments, Quotes +from open_payments_sdk.api.wallet import Wallet +from open_payments_sdk.http import HttpClient + + +class OpenPayemntsClient: + """ + Open Payments API Client + """ + def __init__(self, keyid: str, private_key: str, http_client: HttpClient = None): + if not http_client : + self.http_client = HttpClient() + self.keyid = keyid + self.private_key = private_key + self.http_client = http_client + self.grants = Grants( + keyid=keyid, + private_key=private_key, + http_client=self.http_client + ) + self.access_tokens = AccessTokens( + keyid=keyid, + private_key=private_key, + http_client=self.http_client + ) + self.wallet = Wallet(self.http_client) + self.incoming_payments = IncomingPayments( + keyid=keyid, + private_key=private_key, + http_client=self.http_client + ) + self.outgoing_payments = OutgoingPayments( + keyid=keyid, + private_key=private_key, + http_client=self.http_client + ) + self.quotes = Quotes( + keyid=keyid, + private_key=private_key, + http_client=self.http_client + ) + diff --git a/src/open_payments_sdk/api/auth.py b/src/open_payments_sdk/api/auth.py index ef8d996..1575dbf 100644 --- a/src/open_payments_sdk/api/auth.py +++ b/src/open_payments_sdk/api/auth.py @@ -1,56 +1,171 @@ +""" +Grants Module +""" + import json from open_payments_sdk.configuration import Configuration -from open_payments_sdk.http import Client as HttpClient +from open_payments_sdk.gnap_utils.security import SecurityBase +from open_payments_sdk.http import HttpClient from open_payments_sdk.models.auth import AccessToken from open_payments_sdk.models.auth import Grant as AuthGrant from open_payments_sdk.models.auth import (GrantContinueResponse, GrantRequest, InteractRef) -class Grants: - def __init__(self, auth_server_endpoint: str, http_client: HttpClient = None): + +class Grants(SecurityBase): + """ + Class to handle Grants in the sdk + """ + def __init__(self, keyid: str, private_key: str ,http_client: HttpClient = None): + super().__init__(keyid=keyid, private_key=private_key) if not http_client: cfg = Configuration() http_client = HttpClient(cfg) self.http_client = http_client - self.auth_server_endpoint = auth_server_endpoint - def post_grant_request(self, grant_request: GrantRequest) -> AuthGrant: + def post_grant_request( + self, + grant_request: GrantRequest, + auth_server_endpoint: str + ) -> AuthGrant: + """ + Grant Request + """ data = grant_request.model_dump(exclude_unset=True, mode="json") - response = self.http_client.post(self.auth_server_endpoint, json=data) + data_bytes = json.dumps(data).encode("utf-8") + + req_headers = { + **self.get_default_headers(), + "content-length":str(len(data_bytes)), + "content-digest":self.get_content_digest(data_bytes) + } + response = self.http_client.post(auth_server_endpoint, json=data_bytes,headers={ + **req_headers, + **self.get_signature_headers( + headers=req_headers, + method="POST", + target_uri=auth_server_endpoint + )} + ) return AuthGrant.model_validate(json.loads(response)) - def post_grant_continuation_request(self, req_id: str, interact_ref: InteractRef): - base_url = self.auth_server_endpoint.rstrip("/") + def post_grant_continuation_request( + self, + req_id: str, + interact_ref: InteractRef, + auth_server_endpoint: str, + access_token: str + ) -> GrantContinueResponse: + """ + Continue Grant Request + """ + base_url = auth_server_endpoint.rstrip("/") url = f"{base_url}/continue/{req_id}" data = interact_ref.model_dump(exclude_unset=True, mode="json") - response = self.http_client.post(url, json=data) + data_bytes = json.dumps(data).encode("utf-8") + req_headers = { + **self.get_default_headers(), + "content-length": str(len(data_bytes)), + "content-digest": self.get_content_digest(data_bytes), + **self.get_auth_header(access_token=access_token) + } + response = self.http_client.post( + url, + json=data, + headers={ + **req_headers, + **self.get_signature_headers( + headers=req_headers, + method="POST", + target_uri=auth_server_endpoint + ) + } + ) return GrantContinueResponse.model_validate(json.loads(response)) - def delete_grant(self, req_id: str) -> None: - base_url = self.auth_server_endpoint.rstrip("/") + def delete_grant( + self, + req_id: str, + auth_server_endpoint: str, + access_token: str + ) -> None: + """ + Delete Grant + """ + base_url = auth_server_endpoint.rstrip("/") url = f"{base_url}/continue/{req_id}" - self.http_client.delete(url) + req_headers = { + **self.get_auth_header(access_token=access_token) + } + self.http_client.delete(url,headers={ + **req_headers, + **self.get_signature_headers( + headers=req_headers, + method="DELETE", + target_uri=auth_server_endpoint + ) + }) + -class AccessTokens: - def __init__(self, auth_server_endpoint: str, http_client: HttpClient = None): +class AccessTokens(SecurityBase): + """ + Access Token Class + """ + def __init__(self, keyid: str , private_key: str, http_client: HttpClient = None): + super().__init__(keyid=keyid, private_key=private_key) if not http_client: cfg = Configuration() http_client = HttpClient(cfg) self.http_client = http_client - self.auth_server_endpoint = auth_server_endpoint - def post_rotate_access_token(self, token_id: str) -> AccessToken: - base_url = self.auth_server_endpoint.rstrip("/") + def post_rotate_access_token( + self, + token_id: str, + auth_server_endpoint: str, + access_token: str + ) -> AccessToken: + """ + Rotate Access Token + """ + base_url = auth_server_endpoint.rstrip("/") url = f"{base_url}/token/{token_id}" - response = self.http_client.post(url) + req_headers = { + **self.get_auth_header(access_token=access_token) + } + response = self.http_client.post(url,headers={ + **req_headers, + **self.get_signature_headers( + headers=req_headers, + method="POST", + target_uri=auth_server_endpoint + ) + }) return AccessToken.model_validate(json.loads(response)) - def delete_access_token(self, token_id: str) -> None: - base_url = self.auth_server_endpoint.rstrip("/") + def delete_access_token( + self, + token_id: str, + auth_server_endpoint: str, + access_token: str + ) -> None: + """ + Delete Access Token + """ + base_url = auth_server_endpoint.rstrip("/") url = f"{base_url}/token/{token_id}" - self.http_client.delete(url) \ No newline at end of file + req_headers = { + **self.get_auth_header(access_token=access_token) + } + self.http_client.delete(url, headers={ + **req_headers, + **self.get_signature_headers( + headers=req_headers, + method="DELETE", + target_uri=auth_server_endpoint + ) + }) \ No newline at end of file diff --git a/src/open_payments_sdk/api/resource.py b/src/open_payments_sdk/api/resource.py index 4779517..b2425eb 100644 --- a/src/open_payments_sdk/api/resource.py +++ b/src/open_payments_sdk/api/resource.py @@ -1,5 +1,10 @@ +""" +Resource Server Module +""" +import json from open_payments_sdk.configuration import Configuration -from open_payments_sdk.http import Client as HttpClient +from open_payments_sdk.gnap_utils.security import SecurityBase +from open_payments_sdk.http import HttpClient from open_payments_sdk.models.resource import (IncomingPayment, IncomingPaymentRequest, IncomingPaymentResponse, @@ -11,94 +16,269 @@ QuoteRequest) -class IncomingPayments: - def __init__(self, resource_server_endpoint: str, http_client: HttpClient = None): +class IncomingPayments(SecurityBase): + """ + Class for handling incoming payments resources + """ + def __init__(self, keyid: str, private_key: str, http_client: HttpClient = None): + super().__init__(keyid=keyid,private_key=private_key) if not http_client: cfg = Configuration() http_client = HttpClient(cfg) self.http_client = http_client - self.resource_server_endpoint = resource_server_endpoint.rstrip("/") - def post_create_payment(self, payment: IncomingPaymentRequest) -> IncomingPayment: - base_url = self.resource_server_endpoint + def post_create_payment( + self, + payment: IncomingPaymentRequest, + resource_server_endpoint: str, + access_token: str + ) -> IncomingPayment: + """ + Create Incoming Payment + """ + base_url = resource_server_endpoint.rstrip("/") url = f"{base_url}/incoming-payments" data = payment.model_dump(exclude_unset=True, mode="json") - response = self.http_client.post(url, json=data) + data_bytes = json.dumps(data).encode("utf-8") + req_headers = { + **self.get_default_headers(), + **self.get_auth_header(access_token=access_token), + "content-length": str(len(data_bytes)), + "content-digest":self.get_content_digest(data_bytes) + } + response = self.http_client.post(url, json=data_bytes, headers={ + **req_headers, + **self.get_signature_headers( + headers=req_headers, + method="POST", + target_uri=resource_server_endpoint + ) + }) return IncomingPayment.model_validate(response.json()) def get_incoming_payments( - self, query: PaymentListQuery - ) -> PaginatedIncomingPayments: - base_url = self.resource_server_endpoint + self, query: PaymentListQuery, + resource_server_endpoint: str, + access_token: str + ) -> PaginatedIncomingPayments: + """ + Get Incoming Payment + """ + base_url = resource_server_endpoint.rstrip("/") url = f"{base_url}/incoming-payments" query_params = query.model_dump(exclude_unset=True, mode="json") - response = self.http_client.get(url, params=query_params) + req_headers = { + **self.get_auth_header(access_token=access_token) + } + response = self.http_client.get(url, params=query_params, headers={ + **req_headers, + **self.get_signature_headers( + headers=req_headers, + method="GET", + target_uri=resource_server_endpoint + ) + }) return PaginatedIncomingPayments.model_validate(response.json()) - def get_incoming_payment(self, payment_id: str) -> IncomingPayment: - base_url = self.resource_server_endpoint + def get_incoming_payment( + self, + payment_id: str, + resource_server_endpoint: str, + access_token: str + ) -> IncomingPayment: + """ + Get Incoming Payment + """ + base_url = resource_server_endpoint.rstrip("/") url = f"{base_url}/incoming-payments/{payment_id}" - response = self.http_client.get(url) + req_headers = { + **self.get_auth_header(access_token=access_token) + } + response = self.http_client.get(url,headers={ + **req_headers, + **self.get_signature_headers( + headers=req_headers, + method="GET", + target_uri=resource_server_endpoint + ) + }) return IncomingPaymentResponse.model_validate(response.json()) - def post_complete_incoming_payment(self, payment_id: str) -> IncomingPayment: - base_url = self.resource_server_endpoint + def post_complete_incoming_payment( + self, + payment_id: str, + resource_server_endpoint: str, + access_token: str + ) -> IncomingPayment: + """ + Complete Incoming Payment + """ + base_url = resource_server_endpoint.rstrip("/") url = f"{base_url}/incoming-payments/{payment_id}/complete" - response = self.http_client.post(url) + req_headers = { + **self.get_auth_header(access_token=access_token) + } + response = self.http_client.post(url,headers={ + **req_headers, + **self.get_signature_headers( + headers=req_headers, + method="GET", + target_uri=resource_server_endpoint + ) + }) return IncomingPayment.model_validate(response.json()) -class OutgoingPayments: - def __init__(self, resource_server_endpoint: str, http_client: HttpClient = None): +class OutgoingPayments(SecurityBase): + """ + Class for handling outgoing payments resources + """ + def __init__(self, keyid: str, private_key: str, http_client: HttpClient = None): + super().__init__(keyid=keyid,private_key=private_key) if not http_client: cfg = Configuration() http_client = HttpClient(cfg) self.http_client = http_client - self.resource_server_endpoint = resource_server_endpoint.rstrip("/") - def post_create_payment(self, payment: OutgoingPaymentRequest) -> OutgoingPayment: - base_url = self.resource_server_endpoint + def post_create_payment( + self, payment: OutgoingPaymentRequest, + resource_server_endpoint: str, + access_token: str + ) -> OutgoingPayment: + """ + Create an Outgoing Payment Resource + """ + base_url = resource_server_endpoint.rstrip("/") url = f"{base_url}/outgoing-payments" data = payment.model_dump(exclude_unset=True, mode="json") - response = self.http_client.post(url, json=data) + data_bytes = json.dumps(data).encode("utf-8") + req_headers = { + **self.get_default_headers(), + **self.get_auth_header(access_token=access_token), + "content-length": str(len(data_bytes)), + "content-digest":self.get_content_digest(data_bytes) + } + response = self.http_client.post(url, json=data_bytes, headers={ + **req_headers, + **self.get_signature_headers( + headers=req_headers, + method="POST", + target_uri=resource_server_endpoint + ) + }) return OutgoingPayment.model_validate(response.json()) def get_outgoing_payments( - self, query: PaymentListQuery + self, + query: PaymentListQuery, + resource_server_endpoint: str, + access_token: str ) -> PaginatedOutgoingPayments: - base_url = self.resource_server_endpoint + """ + Get Outgoing Payments + """ + base_url = resource_server_endpoint.rstrip("/") url = f"{base_url}/outgoing-payments" query_params = query.model_dump(exclude_unset=True, mode="json") - response = self.http_client.get(url, params=query_params) + req_headers = { + **self.get_auth_header(access_token=access_token) + } + response = self.http_client.get(url, params=query_params,headers={ + **req_headers, + **self.get_signature_headers( + headers=req_headers, + method="GET", + target_uri=resource_server_endpoint + ) + }) return PaginatedOutgoingPayments.model_validate(response.json()) - def get_outgoing_payment(self, payment_id: str) -> OutgoingPayment: - base_url = self.resource_server_endpoint + def get_outgoing_payment( + self, payment_id: str, + resource_server_endpoint: str, + access_token: str + ) -> OutgoingPayment: + """ + Get Outgoing Payment + """ + base_url = resource_server_endpoint url = f"{base_url}/outgoing-payments/{payment_id}" - response = self.http_client.get(url) + req_headers = { + **self.get_auth_header(access_token=access_token) + } + response = self.http_client.get(url,headers={ + **req_headers, + **self.get_signature_headers( + headers=req_headers, + method="GET", + target_uri=resource_server_endpoint + ) + }) return OutgoingPayment.model_validate(response.json()) -class Quotes: - def __init__(self, resource_server_endpoint: str, http_client: HttpClient = None): +class Quotes(SecurityBase): + """ + Class for handling Quote resources + """ + def __init__(self, keyid: str, private_key: str, http_client: HttpClient = None): + super().__init__(keyid=keyid,private_key=private_key) if not http_client: cfg = Configuration() http_client = HttpClient(cfg) self.http_client = http_client - self.resource_server_endpoint = resource_server_endpoint.rstrip("/") - def post_create_quote(self, quote: QuoteRequest) -> Quote: - base_url = self.resource_server_endpoint + def post_create_quote( + self, quote: QuoteRequest, + resource_server_endpoint: str, + access_token: str + ) -> Quote: + """ + Create a Quote + """ + base_url = resource_server_endpoint.rstrip("/") url = f"{base_url}/quotes" data = quote.model_dump(exclude_unset=True, mode="json") - response = self.http_client.post(url, json=data) + data_bytes = json.dumps(data).encode("utf-8") + req_headers = { + **self.get_default_headers(), + **self.get_auth_header(access_token=access_token), + "content-length": str(len(data_bytes)), + "content-digest":self.get_content_digest(data_bytes) + } + response = self.http_client.post(url, json=data_bytes,headers={ + **req_headers, + **self.get_signature_headers( + headers=req_headers, + method="POST", + target_uri=resource_server_endpoint + ) + }) return Quote.model_validate(response.json()) - def get_quote(self, quote_id: str) -> Quote: - base_url = self.resource_server_endpoint + def get_quote( + self, + quote_id: str, + resource_server_endpoint: str, + access_token: str + ) -> Quote: + """ + Get a Quote + """ + base_url = resource_server_endpoint.strip("/") url = f"{base_url}/quotes/{quote_id}" - response = self.http_client.get(url) + req_headers = { + **self.get_auth_header(access_token=access_token) + } + response = self.http_client.get(url, headers={ + **req_headers, + **self.get_signature_headers( + headers=req_headers, + method="GET", + target_uri=resource_server_endpoint + ) + }) return Quote.model_validate(response.json()) diff --git a/src/open_payments_sdk/api/wallet.py b/src/open_payments_sdk/api/wallet.py index a8c2687..7d122e2 100644 --- a/src/open_payments_sdk/api/wallet.py +++ b/src/open_payments_sdk/api/wallet.py @@ -1,29 +1,31 @@ import json from open_payments_sdk.configuration import Configuration -from open_payments_sdk.http import Client as HttpClient +from open_payments_sdk.http import HttpClient as HttpClient from open_payments_sdk.models.wallet import JsonWebKeySet, WalletAddress class Wallet: + """ + Class for handling Wallet resource + """ def __init__( - self, wallet_address_server_endpoint: str, http_client: HttpClient = None + self, http_client: HttpClient = None ): if not http_client: cfg = Configuration() http_client = HttpClient(cfg) self.http_client = http_client - self.wallet_address_server_endpoint = wallet_address_server_endpoint - def get_wallet_address(self) -> WalletAddress: + def get_wallet_address(self, wallet_address_server_endpoint: str) -> WalletAddress: """Get wallet address from address server""" - response = self.http_client.get(self.wallet_address_server_endpoint) + response = self.http_client.get(wallet_address_server_endpoint) return WalletAddress.model_validate(json.loads(response)) - def get_keys(self) -> JsonWebKeySet: + def get_keys(self, wallet_address_server_endpoint: str) -> JsonWebKeySet: """Get keys from address server""" - base_url = self.wallet_address_server_endpoint.rstrip("/") + base_url = wallet_address_server_endpoint.rstrip("/") url = f"{base_url}/jwks.json" response = self.http_client.get(url) return JsonWebKeySet.model_validate(json.loads(response)) diff --git a/src/open_payments_sdk/gnap_utils/hash.py b/src/open_payments_sdk/gnap_utils/hash.py index 55edca3..721bdd1 100644 --- a/src/open_payments_sdk/gnap_utils/hash.py +++ b/src/open_payments_sdk/gnap_utils/hash.py @@ -3,9 +3,13 @@ class HashManager: - - @staticmethod - def verify_hash(client_nonce, interact_nonce, interact_ref, auth_server_url, received_hash)->bool: + """ + Class for verifying a returned hash + """ + def verify_hash(self, client_nonce, interact_nonce, interact_ref, auth_server_url, received_hash)->bool: + """ + Method for verifying a returned hash + """ data = f"{client_nonce}\n{interact_nonce}\n{interact_ref}\n{auth_server_url}/" hash_bytes = hashlib.sha256(data.encode("utf-8")).digest() computed_hash = base64.b64encode(hash_bytes).decode("utf-8") diff --git a/src/open_payments_sdk/gnap_utils/http_signatures.py b/src/open_payments_sdk/gnap_utils/http_signatures.py index f91cb80..aa2f8a0 100644 --- a/src/open_payments_sdk/gnap_utils/http_signatures.py +++ b/src/open_payments_sdk/gnap_utils/http_signatures.py @@ -6,17 +6,22 @@ from open_payments_sdk.models.http_signatures import SignatureBaseReturn, SignatureHeaders class HTTPSignatureClient: - @staticmethod - def build_signature_base(headers: dict, method: str, target_uri: str, key_id: str, algorithm="ed25519") -> SignatureBaseReturn: + """ + Class for http signature work flows + """ + def __init__(self, key_manager : KeyManager ): + self.key_manager = key_manager + + def build_signature_base(self,headers: dict, method: str, target_uri: str, key_id: str, algorithm="ed25519") -> SignatureBaseReturn: + """ + Method to build an http signature base + """ created = int(time.time()) - covered_components = [ - "content-type", - "authorization", - "content-digest", - "content-length", - "@method", - "@target-uri" - ] + pseudo_headers = ["@method", "@target-uri"] + allowed_headers = {"content-type", "authorization", "content-digest", "content-length"} + included_headers = [h for h in allowed_headers if h in headers] + + covered_components = included_headers + pseudo_headers signature_lines = [] @@ -31,14 +36,16 @@ def build_signature_base(headers: dict, method: str, target_uri: str, key_id: st if value is not None: signature_lines.append(f'"{field}": {value}') - sig_params = f'("content-type" "content-digest" "content-length" "authorization" "@method" "@target-uri");alg="{algorithm}";keyid="{key_id}";created={created}' - signature_lines.append(f'"@signature-params": {sig_params}') + quoted_fields = " ".join(f'"{field}"' for field in covered_components) + sig_params = f'({quoted_fields});alg="{algorithm}";keyid="{key_id}";created={created}' + signature_lines.append(f'"@signature-params": {sig_params}') signature_base = "\n".join(signature_lines) - return SignatureBaseReturn(signature_params=sig_params,signature_base=signature_base) - @staticmethod - def hash_signature_base(signature_base: str) -> bytes: + signature_base_return = SignatureBaseReturn(signature_params=sig_params,signature_base=signature_base) + return SignatureBaseReturn.model_validate(signature_base_return) + + def hash_signature_base(self, signature_base: str) -> bytes: """ Hash the signature base string using SHA-512 and return the digest (bytes) """ @@ -46,19 +53,21 @@ def hash_signature_base(signature_base: str) -> bytes: sha512_hasher.update(signature_base.encode('utf-8')) return sha512_hasher.digest() - @staticmethod - def build_signature(hashed_signature_base: str, private_key: str) -> str: - key = KeyManager.load_ed25519_private_key_from_pem(private_key) + def build_signature(self, hashed_signature_base: str, private_key: str) -> str: + """ + Function to build a signature base + """ + key = self.key_manager.load_ed25519_private_key_from_pem(private_key) signature = key.sign(hashed_signature_base) # Return the Base64-encoded signature string return base64.b64encode(signature).decode("utf-8") - @staticmethod - def get_signature_headers(headers: dict, method: str, target_uri: str, key_id: str, private_key: str, algorithm="ed25519") -> SignatureHeaders: + def get_signature_headers(self, headers: dict, method: str, target_uri: str, key_id: str, private_key: str, algorithm="ed25519") -> SignatureHeaders: """ Returns signature and signature params as string to be used in headers """ - signature_base_details = HTTPSignatureClient.build_signature_base(headers, method, target_uri, key_id, algorithm) - signature_base_hash = HTTPSignatureClient.hash_signature_base(signature_base_details.signature_base) - signature = HTTPSignatureClient.build_signature(signature_base_hash,private_key=private_key) - return SignatureHeaders(signature_input=signature_base_details.signature_params,signature=signature) \ No newline at end of file + signature_base_details = self.build_signature_base(headers, method, target_uri, key_id, algorithm) + signature_base_hash = self.hash_signature_base(signature_base_details.signature_base) + signature = self.build_signature(signature_base_hash,private_key=private_key) + signature_headers = SignatureHeaders(signature_input=signature_base_details.signature_params,signature=signature) + return SignatureHeaders.model_validate(signature_headers) \ No newline at end of file diff --git a/src/open_payments_sdk/gnap_utils/keys.py b/src/open_payments_sdk/gnap_utils/keys.py index 8261335..0341920 100644 --- a/src/open_payments_sdk/gnap_utils/keys.py +++ b/src/open_payments_sdk/gnap_utils/keys.py @@ -7,8 +7,7 @@ class KeyManager: - @staticmethod - def generate_key_pair() -> KeyPair: + def generate_key_pair(self) -> KeyPair: private_key = Ed25519PrivateKey.generate() public_key = private_key.public_key() private_pem = private_key.private_bytes( @@ -31,13 +30,13 @@ def generate_key_pair() -> KeyPair: crv="Ed25519" ) key_jwks = KeyJwks(keys=[key]) - return KeyPair(jwks=key_jwks, private_key_pem=private_key_pem) + keypair = KeyPair(jwks=key_jwks, private_key_pem=private_key_pem) + return KeyPair.model_validate(keypair) - @staticmethod - def load_ed25519_private_key_from_pem(pem_str: str) -> Ed25519PrivateKey: + def load_ed25519_private_key_from_pem(self,pem_str: str) -> Ed25519PrivateKey: private_key = serialization.load_pem_private_key( data=pem_str.encode("utf-8"), - password=None + password=None ) if not isinstance(private_key, Ed25519PrivateKey): diff --git a/src/open_payments_sdk/gnap_utils/security.py b/src/open_payments_sdk/gnap_utils/security.py new file mode 100644 index 0000000..64d51d0 --- /dev/null +++ b/src/open_payments_sdk/gnap_utils/security.py @@ -0,0 +1,60 @@ +""" +Shared class for making secure requests +""" +import base64 +import hashlib +from open_payments_sdk.gnap_utils.hash import HashManager +from open_payments_sdk.gnap_utils.http_signatures import HTTPSignatureClient +from open_payments_sdk.gnap_utils.keys import KeyManager + + +class SecurityBase(): + """ + Base class to provide shared functionality for making authenticated requests + """ + def __init__(self, keyid: str, private_key: str): + self.key_manager = KeyManager() + self.hash_manager = HashManager() + self.http_signatures = HTTPSignatureClient(self.key_manager) + self.keyid = keyid + self.private_key = private_key + + def get_auth_header(self, access_token: str) -> dict: + """ + Prepare Authorization GNAP header + """ + return { + "authorization": f"GNAP {access_token}" + } + + def get_signature_headers(self, headers: dict, method: str, target_uri: str)-> dict: + """ + Prepare http signature headers + """ + signature_headers = self.http_signatures.get_signature_headers( + headers=headers, + method=method, + target_uri=target_uri.rstrip("/"), + key_id=self.keyid, + private_key=self.private_key) + return { + "Signature-Input":signature_headers.signature_input, + "Signature": signature_headers.signature + } + + def get_default_headers(self) -> dict: + """ + Get default headers + """ + return { + "content-type": "application/json" + } + + def get_content_digest(self, data: bytes) -> str: + """ + Compute Digest + """ + sha512_hash = hashlib.sha512(data).digest() + b64_hash = base64.b64encode(sha512_hash).decode('ascii') + return f"sha-512=:{b64_hash}:" + \ No newline at end of file diff --git a/src/open_payments_sdk/http.py b/src/open_payments_sdk/http.py index cfea5e9..61ebf0e 100644 --- a/src/open_payments_sdk/http.py +++ b/src/open_payments_sdk/http.py @@ -5,8 +5,10 @@ from open_payments_sdk import configuration -class Client: - def __init__(self, cfg: configuration.Configuration): +class HttpClient: + def __init__(self, cfg: configuration.Configuration = None): + if not cfg: + cfg = configuration.Configuration() self.logger = logging.getLogger(__name__) self.logger.addHandler(cfg.get_log_handler()) self.user_agent = cfg.user_agent @@ -19,7 +21,7 @@ def get(url, params=None, headers=None): @staticmethod def post(url, json=None, headers=None): - res = httpx.post(url, json=json, headers=headers) + res = httpx.post(url, content=json, headers=headers) res.raise_for_status() return res.text diff --git a/src/open_payments_sdk/models/keys.py b/src/open_payments_sdk/models/keys.py index d166f82..6c935b5 100644 --- a/src/open_payments_sdk/models/keys.py +++ b/src/open_payments_sdk/models/keys.py @@ -1,7 +1,6 @@ from pydantic import BaseModel, ConfigDict from typing import List - class Key(BaseModel): kid: str x: str @@ -20,3 +19,4 @@ class KeyJwks(BaseModel): class KeyPair(BaseModel): jwks: KeyJwks private_key_pem: str + diff --git a/src/open_payments_sdk/models/resource.py b/src/open_payments_sdk/models/resource.py index b3ff0a1..278193f 100644 --- a/src/open_payments_sdk/models/resource.py +++ b/src/open_payments_sdk/models/resource.py @@ -277,10 +277,9 @@ class IncomingPaymentRequest(BaseModel): class IncomingPaymentResponse( - RootModel(Union[PublicIncomingPayment, IncomingPaymentWithMethods]) + RootModel[Union[PublicIncomingPayment, IncomingPaymentWithMethods]] ): - root: Union[PublicIncomingPayment, IncomingPaymentWithMethods] - + pass class PaymentListQuery(BaseModel): walletAddress: WalletAddress @@ -315,11 +314,9 @@ class OutgoingPaymentRequestWithIncoming(BaseModel): class OutgoingPaymentRequest( - RootModel( - Union[OutgoingPaymentRequestWithIncoming, OutgoingPaymentRequestWithQuote] - ) + RootModel[Union[OutgoingPaymentRequestWithIncoming, OutgoingPaymentRequestWithQuote]] ): - root: Union[OutgoingPaymentRequestWithIncoming, OutgoingPaymentRequestWithQuote] + pass class PaginatedOutgoingPayments(BaseModel): @@ -346,7 +343,8 @@ class QuoteFixedSent(QuoteRequestBase): debitAmount: Amount + class QuoteRequest( - RootModel(Union[QuoteRequestBase, QuoteFixedSent, QuoteFixedReceive]) + RootModel[Union[QuoteRequestBase, QuoteFixedSent, QuoteFixedReceive]] ): - root: Union[QuoteRequestBase, QuoteFixedSent, QuoteFixedReceive] + pass diff --git a/test/integration/test_grants.py b/test/integration/test_grants.py index 9f076b8..c77df2e 100644 --- a/test/integration/test_grants.py +++ b/test/integration/test_grants.py @@ -2,6 +2,7 @@ import yaml from open_payments_sdk.api.auth import Grants +from open_payments_sdk.gnap_utils.keys import KeyManager from open_payments_sdk.models.auth import GrantRequest @@ -24,9 +25,18 @@ def example_grant_requests(request): examples = path["requestBody"]["content"]["application/json"]["examples"] return examples.values() +@pytest.fixture +def keyid_private_key() -> dict: + key_manager = KeyManager() + key_pair = key_manager.generate_key_pair() + return { + "private_key": key_pair.private_key_pem, + "keyid": key_pair.jwks.keys[0].kid + } + -def test_post_grant_request(auth_server, example_grant_requests): - grant = Grants(auth_server) +def test_post_grant_request(auth_server, example_grant_requests,keyid_private_key): + grant = Grants(keyid=keyid_private_key["keyid"],private_key=keyid_private_key["private_key"]) for example in example_grant_requests: grant_request = GrantRequest.model_validate(example["value"]) - grant.post_grant_request(grant_request) + grant.post_grant_request(grant_request, auth_server) diff --git a/test/unit/test_hash_verification.py b/test/unit/test_hash_verification.py index 813fd8f..bf82e8d 100644 --- a/test/unit/test_hash_verification.py +++ b/test/unit/test_hash_verification.py @@ -1,7 +1,7 @@ # test_hash_manager.py import base64 import hashlib -from open_payments_sdk.gnap_utils.hash import HashManager as h # Replace with actual module name or use relative import +from open_payments_sdk.gnap_utils.hash import HashManager # Replace with actual module name or use relative import def test_verify_hash(): client_nonce = "abc123" @@ -10,6 +10,6 @@ def test_verify_hash(): url = "https://auth.interledger.com" data = f"{client_nonce}\n{interact_nonce}\n{ref}\n{url}/" expected = base64.b64encode(hashlib.sha256(data.encode()).digest()).decode() - + h = HashManager() assert h.verify_hash(client_nonce, interact_nonce, ref, url, expected) assert not h.verify_hash(client_nonce, interact_nonce, ref, url, "wrong-hash") diff --git a/test/unit/test_http_signatures.py b/test/unit/test_http_signatures.py index 857195c..174ff9b 100644 --- a/test/unit/test_http_signatures.py +++ b/test/unit/test_http_signatures.py @@ -6,6 +6,8 @@ from open_payments_sdk.gnap_utils.http_signatures import HTTPSignatureClient from cryptography.hazmat.primitives import serialization +from open_payments_sdk.gnap_utils.keys import KeyManager + @pytest.fixture def sample_headers(): @@ -28,7 +30,9 @@ def private_key_str(): def test_build_signature_base(sample_headers): - base = HTTPSignatureClient.build_signature_base( + key_manager = KeyManager() + signature_client = HTTPSignatureClient(key_manager=key_manager) + base = signature_client.build_signature_base( headers=sample_headers, method="POST", target_uri="https://example.com/payments", @@ -45,7 +49,9 @@ def test_build_signature_base(sample_headers): def test_hash_signature_base_consistency(): sig_base = '"authorization": test\n"@method": POST' - digest = HTTPSignatureClient.hash_signature_base(sig_base) + key_manager = KeyManager() + signature_client = HTTPSignatureClient(key_manager=key_manager) + digest = signature_client.hash_signature_base(sig_base) expected = hashlib.sha512(sig_base.encode("utf-8")).digest() assert digest == expected @@ -56,6 +62,8 @@ def test_hash_signature_base_consistency(): def test_build_signature(): hashed_signature_base = b'\x00' * 64 # Example hash private_key = Ed25519PrivateKey.generate() + key_manager = KeyManager() + signature_client = HTTPSignatureClient(key_manager=key_manager) private_key_pem = private_key.private_bytes( encoding=serialization.Encoding.PEM, @@ -64,7 +72,7 @@ def test_build_signature(): ) # Call the method to test - signature = HTTPSignatureClient.build_signature(hashed_signature_base, private_key_pem.decode("utf-8")) + signature = signature_client.build_signature(hashed_signature_base, private_key_pem.decode("utf-8")) # Decode signature and check signature_bytes = base64.b64decode(signature) @@ -72,7 +80,9 @@ def test_build_signature(): assert len(signature_bytes) == 64 def test_get_signature_headers(private_key_str,sample_headers): - signature_headers = HTTPSignatureClient.get_signature_headers( + key_manager = KeyManager() + signature_client = HTTPSignatureClient(key_manager=key_manager) + signature_headers = signature_client.get_signature_headers( headers=sample_headers, method="POST", target_uri="https://example.com/payments", @@ -83,5 +93,4 @@ def test_get_signature_headers(private_key_str,sample_headers): print(f"Signature: {signature_headers.signature}") assert "alg=\"ed25519\"" in signature_headers.signature_input assert "keyid=" in signature_headers.signature_input - assert signature_headers.signature_input.startswith('("content-type"') \ No newline at end of file diff --git a/test/unit/test_key.py b/test/unit/test_key.py index b575c4b..87c33e8 100644 --- a/test/unit/test_key.py +++ b/test/unit/test_key.py @@ -3,7 +3,8 @@ def test_key_generation(): - key_result = KeyManager.generate_key_pair() + key_manager = KeyManager() + key_result = key_manager.generate_key_pair() assert isinstance(key_result, KeyPair) assert isinstance(key_result.jwks, KeyJwks) assert len(key_result.jwks.keys) == 1 @@ -16,9 +17,10 @@ def test_key_generation(): assert len(key.x) >= 43 def test_read_key_from_str(): - key_result = KeyManager.generate_key_pair() + key_manager = KeyManager() + key_result = key_manager.generate_key_pair() private_key = key_result.private_key_pem - ed25519PrivateKey = KeyManager.load_ed25519_private_key_from_pem(private_key) + ed25519PrivateKey = key_manager.load_ed25519_private_key_from_pem(private_key) signature = ed25519PrivateKey.sign(b"my authenticated message") public_key = ed25519PrivateKey.public_key() public_key.verify(signature, b"my authenticated message") \ No newline at end of file diff --git a/test/unit/test_op_client.py b/test/unit/test_op_client.py new file mode 100644 index 0000000..84db368 --- /dev/null +++ b/test/unit/test_op_client.py @@ -0,0 +1,29 @@ +import pytest +from client.client import OpenPayemntsClient +from open_payments_sdk.api.auth import AccessTokens, Grants +from open_payments_sdk.api.resource import IncomingPayments, OutgoingPayments, Quotes +from open_payments_sdk.gnap_utils.keys import KeyManager + +@pytest.fixture +def keyid_private_key() -> dict: + key_manager = KeyManager() + key_pair = key_manager.generate_key_pair() + return { + "private_key": key_pair.private_key_pem, + "keyid": key_pair.jwks.keys[0].kid + } + +def test_create_op_client(keyid_private_key): + keyid = keyid_private_key["keyid"] + private_key = keyid_private_key["private_key"] + client = OpenPayemntsClient(keyid=keyid_private_key["keyid"],private_key=keyid_private_key["private_key"]) + + assert client.keyid == keyid + assert client.private_key == private_key + + assert isinstance(client.grants, Grants) + assert isinstance(client.access_tokens, AccessTokens) + assert isinstance(client.incoming_payments, IncomingPayments) + assert isinstance(client.outgoing_payments, OutgoingPayments) + assert isinstance(client.quotes, Quotes) + From 2ca863f58ba392de94479611135dc88dc10f3c8c Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Wed, 18 Jun 2025 08:06:59 +0200 Subject: [PATCH 18/38] fix: fixed failiing wallet integration tests --- test/integration/test_wallet.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/integration/test_wallet.py b/test/integration/test_wallet.py index e78f0b9..4bbcd95 100644 --- a/test/integration/test_wallet.py +++ b/test/integration/test_wallet.py @@ -14,10 +14,10 @@ def wallet_address_server(request): def test_get_wallet_address(wallet_address_server): - wallet = Wallet(wallet_address_server) - wallet.get_wallet_address() + wallet = Wallet() + wallet.get_wallet_address(wallet_address_server) def test_get_wallet_address_keys(wallet_address_server): - wallet = Wallet(wallet_address_server) - wallet.get_keys() + wallet = Wallet() + wallet.get_keys(wallet_address_server) From a81374766625bdbedcf7b06b2cfe48f736449b7d Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Wed, 18 Jun 2025 22:31:31 +0200 Subject: [PATCH 19/38] chore: put client in sdk folder --- src/{ => open_payments_sdk}/client/__init__.py | 0 src/{ => open_payments_sdk}/client/client.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/{ => open_payments_sdk}/client/__init__.py (100%) rename src/{ => open_payments_sdk}/client/client.py (100%) diff --git a/src/client/__init__.py b/src/open_payments_sdk/client/__init__.py similarity index 100% rename from src/client/__init__.py rename to src/open_payments_sdk/client/__init__.py diff --git a/src/client/client.py b/src/open_payments_sdk/client/client.py similarity index 100% rename from src/client/client.py rename to src/open_payments_sdk/client/client.py From de3c090b9188de449babe128ff4c5896d0b4fd14 Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Wed, 18 Jun 2025 22:31:47 +0200 Subject: [PATCH 20/38] docs: added documentation for sdk --- README.md | 103 ++++++++++++++++++++++++++++++++++++++++++------- pyproject.toml | 4 +- 2 files changed, 91 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index f78ca8e..accafb9 100644 --- a/README.md +++ b/README.md @@ -19,18 +19,6 @@ Open Payments is an open API standard that can be implemented by account servici An Open Payments server runs two sub-systems, a resource server which exposes APIs for performing functions against the underlying accounts and authorization server which exposes APIs compliant with the GNAP standard for getting grants to access the resource server APIs. - -## Interledger - -If you would like to learn more about Interledger, here are some excellent resources: - -- [Interledger Website](https://interledger.org/) -- [Interledger Specification](https://interledger.org/developers/rfcs/interledger-protocol/) -- [Interledger Explainer Video](https://x.com/Interledger/status/1567916000074678272) -- [Open Payments](https://openpayments.dev/) -- [Web monetization](https://webmonetization.org/) - - ## Local development @@ -47,10 +35,95 @@ If you would like to learn more about Interledger, here are some excellent resou [Read Poetry documentation](https://python-poetry.org/docs/managing-environments/) to read how to activate -2. Install the dependencies un the poetry.lock +2. Install the dependencies in the poetry.lock ``` > poetry install ``` -# Usage -To use the library, you will need to install it. \ No newline at end of file +## Usage +To use this sdk, you will first need to install it in your project. Currently you will need to build from source but once it is hosted on pypi you will be able to install it with pip + +```bash +python3 -m pip install open-payments-python-sdk #currently not setup +``` +## Installing from source + +Clone the repository +```bash +git clone https://github.com/interledger/open-payments-python-sdk.git +cd open-payments-python-sdk +``` + +Build the package +```bash +poetry build +``` +After running this command, the wheel package will be written to the `dist/` folder in the repo you just cloned + +Install it in your project + +```bash +pip install open-payments-python-sdk/dist/open_payments_sdk-0.1.0-py3-none-any.whl +``` + +# Initialising the Client +To create a client you can do so by importing the `OpenPaymentsClient` defined in the [`client`](./src/client/client.py) module and instantiating it. + +```python +from open_payments_sdk.client.client import OpenPayemntsClient + +with open("privkey.pem","r",encoding="utf_8") as privkey: + private_key = privkey.read() + +op_client = OpenPayemntsClient(keyid="27b4f8d2-746c-4522-b3f0-874ca15bfe65",private_key=private_key) +``` + +The client is to be created after you have created a key pair and have obtained the `kid` and `private_key` + +Some helper functions have been created to ease key pair creation. A class called `KeyManager` has been created and it provides functions to create a key pair and load a private key from UTF-8 string. It also returns an object that has information to be registered at the AS when registering the public key. + +```python +import json +from open_payments_sdk.gnap_utils.keys import KeyManager + +key_manager = KeyManager() +key_pair = key_manager.generate_key_pair() # generate key pair + +with open("privkey.pem", "w",encoding="utf_8") as pem_file: # save private key to file + pem_file.write(key_pair.private_key_pem) + + +with open("jwks.json","w", encoding="utf_8") as jwks_file: # save jwks.json file + jwks_file.write(json.dumps(key_pair.jwks.keys[0].__dict__)) + + +with open("privkey.pem", "r", encoding="utf_8") as privkey: # load private key from file system + private_key = privkey.read() + +private_key = key_manager.load_ed25519_private_key_from_pem( + private_key +) # load private key fron file utf_8 string + +public_key = private_key.public_key() # derive public key from private key +``` +## Wallets +You can use the created client to interact with the resource server. In this case we will use it to interact with a wallet address to get the wallet address details and jwks.json + +```python +#get wallet address +wallet_address_details = op_client.wallet.get_wallet_address("https://ilp.interledger-test.dev/elijahokellosalary") + +# Output +{'id': AnyUrl('https://ilp.interledger-test.dev/elijahokellosalary'), 'publicName': 'elijahokellosalary', 'assetCode': AssetCode(root='USD'), 'assetScale': AssetScale(root=2), 'authServer': AnyUrl('https://auth.interledger-test.dev/'), 'resourceServer': AnyUrl('https://ilp.interledger-test.dev/')} + +``` +Get Wallet jwks + +```python +#get wallet jwks +wallet_jwks = op_client.wallet.get_keys("https://ilp.interledger-test.dev/elijahokellosalary") + +# Output +{'keys': []} + +``` diff --git a/pyproject.toml b/pyproject.toml index 33856b0..5075ba1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,9 @@ name = "open-payments-sdk" version = "0.1.0" description = "Python SDK implementation for open-payments API" authors = [ - {name = "Yiannis Giannelos",email = "johngiannelos@gmail.com"} + {name = "Yiannis Giannelos",email = "johngiannelos@gmail.com"}, + {name = "Elijah Okello", email = "elijahokello90@gmail.com"}, + {name = "Kasweka Michael Mukoko", email = "kasweka.mukoko@izyane.com"} ] license = {text = "Apache-2.0"} requires-python = ">=3.11" From a43785c64ef633679692a85911dd6de246d32f3a Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Wed, 18 Jun 2025 22:35:35 +0200 Subject: [PATCH 21/38] chore: fix failing tests --- test/unit/test_op_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/test_op_client.py b/test/unit/test_op_client.py index 84db368..3bfea66 100644 --- a/test/unit/test_op_client.py +++ b/test/unit/test_op_client.py @@ -1,7 +1,7 @@ import pytest -from client.client import OpenPayemntsClient from open_payments_sdk.api.auth import AccessTokens, Grants from open_payments_sdk.api.resource import IncomingPayments, OutgoingPayments, Quotes +from open_payments_sdk.client.client import OpenPayemntsClient from open_payments_sdk.gnap_utils.keys import KeyManager @pytest.fixture From cb07c23718ea9e792447511732277b02ba2503be Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Thu, 19 Jun 2025 11:11:58 +0200 Subject: [PATCH 22/38] fix: fixed typo in OpenPaymentsClient class name --- src/open_payments_sdk/client/client.py | 2 +- test/unit/test_op_client.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/open_payments_sdk/client/client.py b/src/open_payments_sdk/client/client.py index 42bdfa4..b2cd252 100644 --- a/src/open_payments_sdk/client/client.py +++ b/src/open_payments_sdk/client/client.py @@ -8,7 +8,7 @@ from open_payments_sdk.http import HttpClient -class OpenPayemntsClient: +class OpenPaymentsClient: """ Open Payments API Client """ diff --git a/test/unit/test_op_client.py b/test/unit/test_op_client.py index 3bfea66..04f9b6c 100644 --- a/test/unit/test_op_client.py +++ b/test/unit/test_op_client.py @@ -1,7 +1,7 @@ import pytest from open_payments_sdk.api.auth import AccessTokens, Grants from open_payments_sdk.api.resource import IncomingPayments, OutgoingPayments, Quotes -from open_payments_sdk.client.client import OpenPayemntsClient +from open_payments_sdk.client.client import OpenPaymentsClient from open_payments_sdk.gnap_utils.keys import KeyManager @pytest.fixture @@ -16,7 +16,7 @@ def keyid_private_key() -> dict: def test_create_op_client(keyid_private_key): keyid = keyid_private_key["keyid"] private_key = keyid_private_key["private_key"] - client = OpenPayemntsClient(keyid=keyid_private_key["keyid"],private_key=keyid_private_key["private_key"]) + client = OpenPaymentsClient(keyid=keyid_private_key["keyid"],private_key=keyid_private_key["private_key"]) assert client.keyid == keyid assert client.private_key == private_key From f040f2a120d60f5822697c63cf55464b157088a8 Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Thu, 19 Jun 2025 11:12:14 +0200 Subject: [PATCH 23/38] docs: updated docs --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index accafb9..88cab5e 100644 --- a/README.md +++ b/README.md @@ -70,12 +70,12 @@ pip install open-payments-python-sdk/dist/open_payments_sdk-0.1.0-py3 To create a client you can do so by importing the `OpenPaymentsClient` defined in the [`client`](./src/client/client.py) module and instantiating it. ```python -from open_payments_sdk.client.client import OpenPayemntsClient +from open_payments_sdk.client.client import OpenPaymentsClient with open("privkey.pem","r",encoding="utf_8") as privkey: private_key = privkey.read() -op_client = OpenPayemntsClient(keyid="27b4f8d2-746c-4522-b3f0-874ca15bfe65",private_key=private_key) +op_client = OpenPaymentsClient(keyid="27b4f8d2-746c-4522-b3f0-874ca15bfe65",private_key=private_key) ``` The client is to be created after you have created a key pair and have obtained the `kid` and `private_key` From 75f77084fd0196900935141bca4061b61555add5 Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Sun, 22 Jun 2025 13:36:57 +0200 Subject: [PATCH 24/38] chore: updated test folder in ci --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8310e74..fe3cbb9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,4 +41,4 @@ jobs: - name: Run Unit Tests run: | - poetry run pytest test/unit/ + poetry run pytest tests/unit/ From 7b214896f2fb4043c93612c4447327bbdb909499 Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Sun, 22 Jun 2025 13:37:17 +0200 Subject: [PATCH 25/38] feat: fix failing grant requests --- src/open_payments_sdk/api/auth.py | 130 +++++------- src/open_payments_sdk/api/resource.py | 193 ++++++++---------- src/open_payments_sdk/api/wallet.py | 29 ++- src/open_payments_sdk/client/client.py | 24 ++- .../gnap_utils/http_signatures.py | 96 ++++----- src/open_payments_sdk/gnap_utils/keys.py | 20 +- src/open_payments_sdk/gnap_utils/security.py | 50 +++-- src/open_payments_sdk/http.py | 61 +++--- src/open_payments_sdk/models/auth.py | 6 +- 9 files changed, 296 insertions(+), 313 deletions(-) diff --git a/src/open_payments_sdk/api/auth.py b/src/open_payments_sdk/api/auth.py index 1575dbf..b308f1e 100644 --- a/src/open_payments_sdk/api/auth.py +++ b/src/open_payments_sdk/api/auth.py @@ -1,13 +1,11 @@ """ Grants Module """ +from logging import Logger -import json - -from open_payments_sdk.configuration import Configuration from open_payments_sdk.gnap_utils.security import SecurityBase from open_payments_sdk.http import HttpClient -from open_payments_sdk.models.auth import AccessToken +from open_payments_sdk.models.auth import AccessToken, GrantResponse, InteractionInstructionsResponse from open_payments_sdk.models.auth import Grant as AuthGrant from open_payments_sdk.models.auth import (GrantContinueResponse, GrantRequest, InteractRef) @@ -18,73 +16,59 @@ class Grants(SecurityBase): """ Class to handle Grants in the sdk """ - def __init__(self, keyid: str, private_key: str ,http_client: HttpClient = None): - super().__init__(keyid=keyid, private_key=private_key) - if not http_client: - cfg = Configuration() - http_client = HttpClient(cfg) - + def __init__(self, keyid: str, private_key: str ,logger: Logger,http_client: HttpClient): + super().__init__(keyid=keyid, private_key=private_key,logger=logger) + self.logger = logger self.http_client = http_client def post_grant_request( self, grant_request: GrantRequest, - auth_server_endpoint: str + auth_server_endpoint: str, ) -> AuthGrant: """ Grant Request """ data = grant_request.model_dump(exclude_unset=True, mode="json") - data_bytes = json.dumps(data).encode("utf-8") req_headers = { - **self.get_default_headers(), - "content-length":str(len(data_bytes)), - "content-digest":self.get_content_digest(data_bytes) + **self.get_default_headers() } - response = self.http_client.post(auth_server_endpoint, json=data_bytes,headers={ - **req_headers, - **self.get_signature_headers( - headers=req_headers, - method="POST", - target_uri=auth_server_endpoint - )} + request = self.http_client.build_request( + method="POST", + url=auth_server_endpoint, + json=data, + headers=req_headers ) - return AuthGrant.model_validate(json.loads(response)) + request = self.set_content_digest(request=request) + request = self.sign_request(request,("content-type","content-digest","content-length",*self.get_default_covered_components())) + response = self.http_client.send(request=request) + return response.json() def post_grant_continuation_request( self, - req_id: str, interact_ref: InteractRef, - auth_server_endpoint: str, + continue_uri: str, access_token: str ) -> GrantContinueResponse: """ Continue Grant Request """ - base_url = auth_server_endpoint.rstrip("/") - url = f"{base_url}/continue/{req_id}" data = interact_ref.model_dump(exclude_unset=True, mode="json") - data_bytes = json.dumps(data).encode("utf-8") req_headers = { **self.get_default_headers(), - "content-length": str(len(data_bytes)), - "content-digest": self.get_content_digest(data_bytes), **self.get_auth_header(access_token=access_token) } - response = self.http_client.post( - url, + request = self.http_client.build_request( + method="POST", + url=continue_uri, json=data, - headers={ - **req_headers, - **self.get_signature_headers( - headers=req_headers, - method="POST", - target_uri=auth_server_endpoint - ) - } + headers=req_headers ) - return GrantContinueResponse.model_validate(json.loads(response)) + request = self.set_content_digest(request=request) + request = self.sign_request(request,("content-type","content-digest","content-length","authorization",*self.get_default_covered_components())) + response = self.http_client.send(request=request) + return GrantContinueResponse.model_validate(response.json()) def delete_grant( self, @@ -100,33 +84,26 @@ def delete_grant( req_headers = { **self.get_auth_header(access_token=access_token) } - self.http_client.delete(url,headers={ - **req_headers, - **self.get_signature_headers( - headers=req_headers, - method="DELETE", - target_uri=auth_server_endpoint - ) - }) - - + request = self.http_client.build_request( + method="DELETE", + url=url, + headers=req_headers + ) + request = self.sign_request(request,("authorization",*self.get_default_covered_components())) + self.http_client.send(request=request) class AccessTokens(SecurityBase): """ Access Token Class """ - def __init__(self, keyid: str , private_key: str, http_client: HttpClient = None): - super().__init__(keyid=keyid, private_key=private_key) - if not http_client: - cfg = Configuration() - http_client = HttpClient(cfg) - + def __init__(self, keyid: str , private_key: str,logger: Logger, http_client: HttpClient ): + super().__init__(keyid=keyid, private_key=private_key, logger=logger) self.http_client = http_client def post_rotate_access_token( - self, - token_id: str, - auth_server_endpoint: str, + self, + token_id: str, + auth_server_endpoint: str, access_token: str ) -> AccessToken: """ @@ -137,15 +114,14 @@ def post_rotate_access_token( req_headers = { **self.get_auth_header(access_token=access_token) } - response = self.http_client.post(url,headers={ - **req_headers, - **self.get_signature_headers( - headers=req_headers, - method="POST", - target_uri=auth_server_endpoint - ) - }) - return AccessToken.model_validate(json.loads(response)) + request = self.http_client.build_request( + method="POST", + url=url, + headers=req_headers + ) + request = self.sign_request(request,("authorization",*self.get_default_covered_components())) + response = self.http_client.send(request=request) + return AccessToken.model_validate(response.json()) def delete_access_token( self, @@ -161,11 +137,11 @@ def delete_access_token( req_headers = { **self.get_auth_header(access_token=access_token) } - self.http_client.delete(url, headers={ - **req_headers, - **self.get_signature_headers( - headers=req_headers, - method="DELETE", - target_uri=auth_server_endpoint - ) - }) \ No newline at end of file + + request = self.http_client.build_request( + method="DELETE", + url=url, + headers=req_headers + ) + request = self.sign_request(request,("authorization",*self.get_default_covered_components())) + self.http_client.send(request=request) \ No newline at end of file diff --git a/src/open_payments_sdk/api/resource.py b/src/open_payments_sdk/api/resource.py index b2425eb..317e144 100644 --- a/src/open_payments_sdk/api/resource.py +++ b/src/open_payments_sdk/api/resource.py @@ -1,10 +1,9 @@ """ Resource Server Module """ -import json -from open_payments_sdk.configuration import Configuration +from logging import Logger from open_payments_sdk.gnap_utils.security import SecurityBase -from open_payments_sdk.http import HttpClient +from open_payments_sdk.http import HttpClient from open_payments_sdk.models.resource import (IncomingPayment, IncomingPaymentRequest, IncomingPaymentResponse, @@ -20,12 +19,8 @@ class IncomingPayments(SecurityBase): """ Class for handling incoming payments resources """ - def __init__(self, keyid: str, private_key: str, http_client: HttpClient = None): - super().__init__(keyid=keyid,private_key=private_key) - if not http_client: - cfg = Configuration() - http_client = HttpClient(cfg) - + def __init__(self, keyid: str, private_key: str,logger: Logger, http_client: HttpClient): + super().__init__(keyid=keyid,private_key=private_key,logger=logger) self.http_client = http_client def post_create_payment( @@ -40,21 +35,19 @@ def post_create_payment( base_url = resource_server_endpoint.rstrip("/") url = f"{base_url}/incoming-payments" data = payment.model_dump(exclude_unset=True, mode="json") - data_bytes = json.dumps(data).encode("utf-8") req_headers = { **self.get_default_headers(), - **self.get_auth_header(access_token=access_token), - "content-length": str(len(data_bytes)), - "content-digest":self.get_content_digest(data_bytes) + **self.get_auth_header(access_token=access_token) } - response = self.http_client.post(url, json=data_bytes, headers={ - **req_headers, - **self.get_signature_headers( - headers=req_headers, - method="POST", - target_uri=resource_server_endpoint - ) - }) + request = self.http_client.build_request( + method="POST", + url=url, + json=data, + headers=req_headers + ) + request = self.set_content_digest(request=request) + request = self.sign_request(request,("content-type","content-digest","content-length","authorization",*self.get_default_covered_components())) + response = self.http_client.send(request=request) return IncomingPayment.model_validate(response.json()) def get_incoming_payments( @@ -71,14 +64,14 @@ def get_incoming_payments( req_headers = { **self.get_auth_header(access_token=access_token) } - response = self.http_client.get(url, params=query_params, headers={ - **req_headers, - **self.get_signature_headers( - headers=req_headers, - method="GET", - target_uri=resource_server_endpoint - ) - }) + request = self.http_client.build_request( + method="GET", + url=url, + headers=req_headers, + params=query_params + ) + request = self.sign_request(request,("authorization",*self.get_default_covered_components())) + response = self.http_client.send(request=request) return PaginatedIncomingPayments.model_validate(response.json()) def get_incoming_payment( @@ -95,14 +88,13 @@ def get_incoming_payment( req_headers = { **self.get_auth_header(access_token=access_token) } - response = self.http_client.get(url,headers={ - **req_headers, - **self.get_signature_headers( - headers=req_headers, - method="GET", - target_uri=resource_server_endpoint - ) - }) + request = self.http_client.build_request( + method="GET", + url=url, + headers=req_headers + ) + request = self.sign_request(request,("authorization",*self.get_default_covered_components())) + response = self.http_client.send(request=request) return IncomingPaymentResponse.model_validate(response.json()) def post_complete_incoming_payment( @@ -119,14 +111,13 @@ def post_complete_incoming_payment( req_headers = { **self.get_auth_header(access_token=access_token) } - response = self.http_client.post(url,headers={ - **req_headers, - **self.get_signature_headers( - headers=req_headers, - method="GET", - target_uri=resource_server_endpoint - ) - }) + request = self.http_client.build_request( + method="POST", + url=url, + headers=req_headers + ) + request = self.sign_request(request,("authorization",*self.get_default_covered_components())) + response = self.http_client.send(request=request) return IncomingPayment.model_validate(response.json()) @@ -134,12 +125,8 @@ class OutgoingPayments(SecurityBase): """ Class for handling outgoing payments resources """ - def __init__(self, keyid: str, private_key: str, http_client: HttpClient = None): - super().__init__(keyid=keyid,private_key=private_key) - if not http_client: - cfg = Configuration() - http_client = HttpClient(cfg) - + def __init__(self, keyid: str, private_key: str, logger: Logger, http_client: HttpClient): + super().__init__(keyid=keyid,private_key=private_key,logger=logger) self.http_client = http_client def post_create_payment( @@ -153,25 +140,23 @@ def post_create_payment( base_url = resource_server_endpoint.rstrip("/") url = f"{base_url}/outgoing-payments" data = payment.model_dump(exclude_unset=True, mode="json") - data_bytes = json.dumps(data).encode("utf-8") req_headers = { **self.get_default_headers(), - **self.get_auth_header(access_token=access_token), - "content-length": str(len(data_bytes)), - "content-digest":self.get_content_digest(data_bytes) + **self.get_auth_header(access_token=access_token) } - response = self.http_client.post(url, json=data_bytes, headers={ - **req_headers, - **self.get_signature_headers( - headers=req_headers, - method="POST", - target_uri=resource_server_endpoint - ) - }) + request = self.http_client.build_request( + method="POST", + url=url, + json=data, + headers=req_headers + ) + request = self.set_content_digest(request=request) + request = self.sign_request(request,("content-type","content-digest","content-length","authorization",*self.get_default_covered_components())) + response = self.http_client.send(request=request) return OutgoingPayment.model_validate(response.json()) def get_outgoing_payments( - self, + self, query: PaymentListQuery, resource_server_endpoint: str, access_token: str @@ -185,14 +170,14 @@ def get_outgoing_payments( req_headers = { **self.get_auth_header(access_token=access_token) } - response = self.http_client.get(url, params=query_params,headers={ - **req_headers, - **self.get_signature_headers( - headers=req_headers, - method="GET", - target_uri=resource_server_endpoint - ) - }) + request = self.http_client.build_request( + method="GET", + url=url, + headers=req_headers, + params=query_params + ) + response = request = self.sign_request(request,("authorization",*self.get_default_covered_components())) + self.http_client.send(request=request) return PaginatedOutgoingPayments.model_validate(response.json()) def get_outgoing_payment( @@ -208,14 +193,13 @@ def get_outgoing_payment( req_headers = { **self.get_auth_header(access_token=access_token) } - response = self.http_client.get(url,headers={ - **req_headers, - **self.get_signature_headers( - headers=req_headers, - method="GET", - target_uri=resource_server_endpoint - ) - }) + request = self.http_client.build_request( + method="GET", + url=url, + headers=req_headers + ) + request = self.sign_request(request,("authorization",*self.get_default_covered_components())) + response = self.http_client.send(request=request) return OutgoingPayment.model_validate(response.json()) @@ -223,12 +207,8 @@ class Quotes(SecurityBase): """ Class for handling Quote resources """ - def __init__(self, keyid: str, private_key: str, http_client: HttpClient = None): - super().__init__(keyid=keyid,private_key=private_key) - if not http_client: - cfg = Configuration() - http_client = HttpClient(cfg) - + def __init__(self, keyid: str, private_key: str,logger: Logger ,http_client: HttpClient): + super().__init__(keyid=keyid,private_key=private_key,logger=logger) self.http_client = http_client def post_create_quote( @@ -242,26 +222,24 @@ def post_create_quote( base_url = resource_server_endpoint.rstrip("/") url = f"{base_url}/quotes" data = quote.model_dump(exclude_unset=True, mode="json") - data_bytes = json.dumps(data).encode("utf-8") req_headers = { **self.get_default_headers(), - **self.get_auth_header(access_token=access_token), - "content-length": str(len(data_bytes)), - "content-digest":self.get_content_digest(data_bytes) + **self.get_auth_header(access_token=access_token) } - response = self.http_client.post(url, json=data_bytes,headers={ - **req_headers, - **self.get_signature_headers( - headers=req_headers, - method="POST", - target_uri=resource_server_endpoint - ) - }) + request = self.http_client.build_request( + method="POST", + url=url, + headers=req_headers, + json=data + ) + request = self.set_content_digest(request=request) + request = self.sign_request(request,("content-type","content-digest","content-length","authorization",*self.get_default_covered_components())) + response = self.http_client.send(request=request) return Quote.model_validate(response.json()) def get_quote( - self, - quote_id: str, + self, + quote_id: str, resource_server_endpoint: str, access_token: str ) -> Quote: @@ -273,12 +251,11 @@ def get_quote( req_headers = { **self.get_auth_header(access_token=access_token) } - response = self.http_client.get(url, headers={ - **req_headers, - **self.get_signature_headers( - headers=req_headers, - method="GET", - target_uri=resource_server_endpoint - ) - }) + request = self.http_client.build_request( + method="GET", + url=url, + headers=req_headers + ) + request = self.sign_request(request,("authorization",*self.get_default_covered_components())) + response = self.http_client.send(request=request) return Quote.model_validate(response.json()) diff --git a/src/open_payments_sdk/api/wallet.py b/src/open_payments_sdk/api/wallet.py index 7d122e2..6f6577d 100644 --- a/src/open_payments_sdk/api/wallet.py +++ b/src/open_payments_sdk/api/wallet.py @@ -1,7 +1,4 @@ -import json - -from open_payments_sdk.configuration import Configuration -from open_payments_sdk.http import HttpClient as HttpClient +from open_payments_sdk.http import HttpClient from open_payments_sdk.models.wallet import JsonWebKeySet, WalletAddress @@ -9,23 +6,25 @@ class Wallet: """ Class for handling Wallet resource """ - def __init__( - self, http_client: HttpClient = None - ): - if not http_client: - cfg = Configuration() - http_client = HttpClient(cfg) - + def __init__(self, http_client: HttpClient): self.http_client = http_client def get_wallet_address(self, wallet_address_server_endpoint: str) -> WalletAddress: """Get wallet address from address server""" - response = self.http_client.get(wallet_address_server_endpoint) - return WalletAddress.model_validate(json.loads(response)) + request = self.http_client.build_request( + method="GET", + url=wallet_address_server_endpoint + ) + response = self.http_client.send(request=request) + return WalletAddress.model_validate(response.json()) def get_keys(self, wallet_address_server_endpoint: str) -> JsonWebKeySet: """Get keys from address server""" base_url = wallet_address_server_endpoint.rstrip("/") url = f"{base_url}/jwks.json" - response = self.http_client.get(url) - return JsonWebKeySet.model_validate(json.loads(response)) + request = self.http_client.build_request( + method="GET", + url=url + ) + response = self.http_client.send(request=request) + return JsonWebKeySet.model_validate(response.json()) diff --git a/src/open_payments_sdk/client/client.py b/src/open_payments_sdk/client/client.py index b2cd252..47ef48e 100644 --- a/src/open_payments_sdk/client/client.py +++ b/src/open_payments_sdk/client/client.py @@ -2,6 +2,8 @@ Open Payments API Client Module """ +import logging +from open_payments_sdk import configuration from open_payments_sdk.api.auth import AccessTokens, Grants from open_payments_sdk.api.resource import IncomingPayments, OutgoingPayments, Quotes from open_payments_sdk.api.wallet import Wallet @@ -12,36 +14,48 @@ class OpenPaymentsClient: """ Open Payments API Client """ - def __init__(self, keyid: str, private_key: str, http_client: HttpClient = None): + def __init__(self, keyid: str, private_key: str, client_wallet_address: str,cfg: configuration.Configuration = None, http_client: HttpClient = None): + if not cfg: + cfg = configuration.Configuration() if not http_client : - self.http_client = HttpClient() + http_client = HttpClient(http_timeout=10.0) # TODO: get from cfg + self.http_client = http_client + self.logger = logging.getLogger(__name__) + self.logger.addHandler(cfg.get_log_handler()) + self.user_agent = cfg.user_agent + self.client_wallet_address = client_wallet_address self.keyid = keyid self.private_key = private_key - self.http_client = http_client self.grants = Grants( - keyid=keyid, + keyid=keyid, private_key=private_key, + logger=self.logger, http_client=self.http_client ) self.access_tokens = AccessTokens( keyid=keyid, private_key=private_key, - http_client=self.http_client + logger=self.logger, + http_client=self.http_client, ) self.wallet = Wallet(self.http_client) self.incoming_payments = IncomingPayments( keyid=keyid, private_key=private_key, + logger=self.logger, http_client=self.http_client ) self.outgoing_payments = OutgoingPayments( keyid=keyid, private_key=private_key, + logger=self.logger, http_client=self.http_client ) self.quotes = Quotes( keyid=keyid, private_key=private_key, + logger=self.logger, http_client=self.http_client ) + diff --git a/src/open_payments_sdk/gnap_utils/http_signatures.py b/src/open_payments_sdk/gnap_utils/http_signatures.py index aa2f8a0..120811f 100644 --- a/src/open_payments_sdk/gnap_utils/http_signatures.py +++ b/src/open_payments_sdk/gnap_utils/http_signatures.py @@ -1,73 +1,55 @@ -import time -import hashlib -import base64 +""" +HTTP Signatures Helper functions +""" + +from http_message_signatures import HTTPSignatureKeyResolver +from http_message_signatures.resolvers import HTTPSignatureComponentResolver +from http_message_signatures.structures import CaseInsensitiveDict from open_payments_sdk.gnap_utils.keys import KeyManager -from open_payments_sdk.models.http_signatures import SignatureBaseReturn, SignatureHeaders -class HTTPSignatureClient: +class OPKeyResolver(HTTPSignatureKeyResolver): """ - Class for http signature work flows + Key Resolver Class """ - def __init__(self, key_manager : KeyManager ): - self.key_manager = key_manager + def __init__(self, keyid: str, private_key: str): + super().__init__() + self.keys = {keyid: private_key.encode("utf-8")} - def build_signature_base(self,headers: dict, method: str, target_uri: str, key_id: str, algorithm="ed25519") -> SignatureBaseReturn: + def resolve_public_key(self, key_id: str): """ - Method to build an http signature base + Get Public Key """ - created = int(time.time()) - pseudo_headers = ["@method", "@target-uri"] - allowed_headers = {"content-type", "authorization", "content-digest", "content-length"} - included_headers = [h for h in allowed_headers if h in headers] - - covered_components = included_headers + pseudo_headers - - signature_lines = [] - - for field in covered_components: - if field.startswith("@"): - if field == "@method": - signature_lines.append(f'"@method": {method.upper()}') - elif field == "@target-uri": - signature_lines.append(f'"@target-uri": {target_uri}') - else: - value = headers.get(field) - if value is not None: - signature_lines.append(f'"{field}": {value}') - - quoted_fields = " ".join(f'"{field}"' for field in covered_components) - sig_params = f'({quoted_fields});alg="{algorithm}";keyid="{key_id}";created={created}' - - signature_lines.append(f'"@signature-params": {sig_params}') - signature_base = "\n".join(signature_lines) + key_manager = KeyManager() + private_key = key_manager.load_ed25519_private_key_from_pem(self.keys[key_id]) + return private_key.public_key() - signature_base_return = SignatureBaseReturn(signature_params=sig_params,signature_base=signature_base) - return SignatureBaseReturn.model_validate(signature_base_return) - - def hash_signature_base(self, signature_base: str) -> bytes: + def resolve_private_key(self, key_id: str): """ - Hash the signature base string using SHA-512 and return the digest (bytes) + Get Private Key """ - sha512_hasher = hashlib.sha512() - sha512_hasher.update(signature_base.encode('utf-8')) - return sha512_hasher.digest() + return self.keys[key_id] + + +class PatchedHTTPSignatureComponentResolver(HTTPSignatureComponentResolver): + """ + Component Resolver to be used by http signing logic. The upstream resolver class has a bug which I fixed via a PR + https://github.com/pyauth/http-message-signatures/pull/18 - def build_signature(self, hashed_signature_base: str, private_key: str) -> str: + The new package is not yet deployed. In the meantime this class fixes the bug and it works in this package + """ + def __init__(self, message): """ - Function to build a signature base + Do not call upstream class constructor because it is buggy """ - key = self.key_manager.load_ed25519_private_key_from_pem(private_key) - signature = key.sign(hashed_signature_base) - # Return the Base64-encoded signature string - return base64.b64encode(signature).decode("utf-8") - - def get_signature_headers(self, headers: dict, method: str, target_uri: str, key_id: str, private_key: str, algorithm="ed25519") -> SignatureHeaders: + self.message = message + self.message_type = "request" + if hasattr(message, "status_code"): + self.message_type = "response" + self.url = str(message.url) + self.headers = CaseInsensitiveDict(message.headers) + + def get_request_response(self, *, key: str): """ - Returns signature and signature params as string to be used in headers + Required implementation from abstract class. Since it is not used in the lib. Just pass """ - signature_base_details = self.build_signature_base(headers, method, target_uri, key_id, algorithm) - signature_base_hash = self.hash_signature_base(signature_base_details.signature_base) - signature = self.build_signature(signature_base_hash,private_key=private_key) - signature_headers = SignatureHeaders(signature_input=signature_base_details.signature_params,signature=signature) - return SignatureHeaders.model_validate(signature_headers) \ No newline at end of file diff --git a/src/open_payments_sdk/gnap_utils/keys.py b/src/open_payments_sdk/gnap_utils/keys.py index 0341920..ee17528 100644 --- a/src/open_payments_sdk/gnap_utils/keys.py +++ b/src/open_payments_sdk/gnap_utils/keys.py @@ -1,4 +1,8 @@ +""" + Key Management module +""" import base64 +from typing import Union import uuid from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives import serialization @@ -6,8 +10,14 @@ from open_payments_sdk.models.keys import Key, KeyJwks, KeyPair class KeyManager: + """ + Key Management class + """ def generate_key_pair(self) -> KeyPair: + """ + Generate Key Pair + """ private_key = Ed25519PrivateKey.generate() public_key = private_key.public_key() private_pem = private_key.private_bytes( @@ -33,9 +43,15 @@ def generate_key_pair(self) -> KeyPair: keypair = KeyPair(jwks=key_jwks, private_key_pem=private_key_pem) return KeyPair.model_validate(keypair) - def load_ed25519_private_key_from_pem(self,pem_str: str) -> Ed25519PrivateKey: + def load_ed25519_private_key_from_pem(self,pem_str: Union[str, bytes]) -> Ed25519PrivateKey: + """ + Read private key from str or bytes string + """ + if isinstance(pem_str,str): + pem_str = pem_str.encode("utf-8") + private_key = serialization.load_pem_private_key( - data=pem_str.encode("utf-8"), + data=pem_str, password=None ) diff --git a/src/open_payments_sdk/gnap_utils/security.py b/src/open_payments_sdk/gnap_utils/security.py index 64d51d0..4d1a7ea 100644 --- a/src/open_payments_sdk/gnap_utils/security.py +++ b/src/open_payments_sdk/gnap_utils/security.py @@ -1,10 +1,15 @@ """ Shared class for making secure requests """ -import base64 + import hashlib +from logging import Logger +from typing import Sequence +from http_message_signatures import HTTPMessageSigner, algorithms +import http_sfv +from httpx import Request from open_payments_sdk.gnap_utils.hash import HashManager -from open_payments_sdk.gnap_utils.http_signatures import HTTPSignatureClient +from open_payments_sdk.gnap_utils.http_signatures import OPKeyResolver, PatchedHTTPSignatureComponentResolver from open_payments_sdk.gnap_utils.keys import KeyManager @@ -12,49 +17,52 @@ class SecurityBase(): """ Base class to provide shared functionality for making authenticated requests """ - def __init__(self, keyid: str, private_key: str): + def __init__(self, keyid: str, private_key: str, logger: Logger): self.key_manager = KeyManager() self.hash_manager = HashManager() - self.http_signatures = HTTPSignatureClient(self.key_manager) + self.http_signatures = HTTPMessageSigner(signature_algorithm=algorithms.ED25519, key_resolver=OPKeyResolver(keyid=keyid,private_key=private_key),component_resolver_class=PatchedHTTPSignatureComponentResolver) self.keyid = keyid self.private_key = private_key + self.logger = logger def get_auth_header(self, access_token: str) -> dict: """ Prepare Authorization GNAP header """ return { - "authorization": f"GNAP {access_token}" + "Authorization": f"GNAP {access_token}" } - def get_signature_headers(self, headers: dict, method: str, target_uri: str)-> dict: + def sign_request(self, message: Request, covered_component_ids: Sequence[str] )-> Request: """ Prepare http signature headers """ - signature_headers = self.http_signatures.get_signature_headers( - headers=headers, - method=method, - target_uri=target_uri.rstrip("/"), + self.http_signatures.sign( + message=message, key_id=self.keyid, - private_key=self.private_key) - return { - "Signature-Input":signature_headers.signature_input, - "Signature": signature_headers.signature - } + covered_component_ids=covered_component_ids, + label="sig1" + ) + return message def get_default_headers(self) -> dict: """ Get default headers """ return { - "content-type": "application/json" + "Content-Type": "application/json" } - - def get_content_digest(self, data: bytes) -> str: + + def get_default_covered_components(self) -> tuple: + """ + Return default covered components + """ + return ("@method","@target-uri") + + def set_content_digest(self, request: Request) -> Request: """ Compute Digest """ - sha512_hash = hashlib.sha512(data).digest() - b64_hash = base64.b64encode(sha512_hash).decode('ascii') - return f"sha-512=:{b64_hash}:" + request.headers["Content-Digest"] = str(http_sfv.Dictionary({"sha-512": hashlib.sha512(request.content).digest()})) + return request \ No newline at end of file diff --git a/src/open_payments_sdk/http.py b/src/open_payments_sdk/http.py index 61ebf0e..d17601c 100644 --- a/src/open_payments_sdk/http.py +++ b/src/open_payments_sdk/http.py @@ -1,32 +1,43 @@ -import logging - +""" +HTTP Client +""" import httpx -from open_payments_sdk import configuration - - class HttpClient: - def __init__(self, cfg: configuration.Configuration = None): - if not cfg: - cfg = configuration.Configuration() - self.logger = logging.getLogger(__name__) - self.logger.addHandler(cfg.get_log_handler()) - self.user_agent = cfg.user_agent + """ + HTTP Client + """ + http_timeout: float - @staticmethod - def get(url, params=None, headers=None): - res = httpx.get(url, params=params, headers=headers) - res.raise_for_status() - return res.text + def __init__(self, http_timeout: float): + self.http_timeout = http_timeout - @staticmethod - def post(url, json=None, headers=None): - res = httpx.post(url, content=json, headers=headers) - res.raise_for_status() - return res.text + def build_request( + self, + method: str, + url: str, + headers = None, + data = None, + json: dict = None, + params: dict = None + ) -> httpx.Request: + """ + Build httpx request + """ + return httpx.Request( + method=method, + url=url, + headers=headers, + json=json, + data=data, + params=params + ) - @staticmethod - def delete(url, params=None, headers=None): - res = httpx.delete(url, params=params, headers=headers) + def send(self, request: httpx.Request) -> httpx.Response: + """ + Make an http request + """ + with httpx.Client(timeout=self.http_timeout) as client: + res = client.send(request=request) res.raise_for_status() - return res.text + return res diff --git a/src/open_payments_sdk/models/auth.py b/src/open_payments_sdk/models/auth.py index 4b5c4f3..7f3a5e3 100644 --- a/src/open_payments_sdk/models/auth.py +++ b/src/open_payments_sdk/models/auth.py @@ -315,10 +315,10 @@ class Grant(RootModel[Union[InteractionInstructionsResponse, GrantResponse]]): ) -class InteractRef(RootModel): - root: str = Field( +class InteractRef(BaseModel): + interact_ref: str = Field( ..., - description="The interaction reference generated for this interaction by the AS.", + description="The interaction reference generated for this interaction by the AS." ) From 782ec63ee5227a46b07754364be9609586af824c Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Sun, 22 Jun 2025 13:37:55 +0200 Subject: [PATCH 26/38] chore: wrote unit and integration tests --- .gitignore | 1 + poetry.lock | 39 ++++++++++- pyproject.toml | 3 +- test/integration/test_grants.py | 42 ----------- test/integration/test_wallet.py | 23 ------ test/unit/test_hash_verification.py | 15 ---- test/unit/test_http_signatures.py | 96 -------------------------- test/unit/test_key.py | 26 ------- tests/conftest.py | 74 ++++++++++++++++++++ tests/integration/privkey.pem.example | 3 + tests/integration/test_grants.py | 11 +++ tests/integration/test_wallet.py | 15 ++++ {test => tests}/unit/test_op_client.py | 24 +++---- 13 files changed, 155 insertions(+), 217 deletions(-) delete mode 100644 test/integration/test_grants.py delete mode 100644 test/integration/test_wallet.py delete mode 100644 test/unit/test_hash_verification.py delete mode 100644 test/unit/test_http_signatures.py delete mode 100644 test/unit/test_key.py create mode 100644 tests/conftest.py create mode 100644 tests/integration/privkey.pem.example create mode 100644 tests/integration/test_grants.py create mode 100644 tests/integration/test_wallet.py rename {test => tests}/unit/test_op_client.py (62%) diff --git a/.gitignore b/.gitignore index 1e61ff7..e0e410d 100644 --- a/.gitignore +++ b/.gitignore @@ -170,3 +170,4 @@ poetry.toml pyrightconfig.json # End of https://www.toptal.com/developers/gitignore/api/python +privkey.pem \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index d2b075a..e2db086 100644 --- a/poetry.lock +++ b/poetry.lock @@ -373,6 +373,43 @@ files = [ {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, ] +[[package]] +name = "http-message-signatures" +version = "0.6.1" +description = "An implementation of the IETF HTTP Message Signatures draft standard" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "http_message_signatures-0.6.1-py3-none-any.whl", hash = "sha256:210346bfcf1e90c5877b90b4f0f7fd0e20128087d488b4b300e01daf1e002d75"}, + {file = "http_message_signatures-0.6.1.tar.gz", hash = "sha256:e0c409b1529826e90a75563d892db82eba1d1afae95b14e242f9221e8b9382e4"}, +] + +[package.dependencies] +cryptography = ">=36.0.2" +http-sfv = ">=0.9.3" + +[package.extras] +tests = ["build", "coverage", "flake8", "mypy", "requests", "ruff", "wheel"] + +[[package]] +name = "http-sfv" +version = "0.9.9" +description = "Parse and serialise HTTP Structured Field Values" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "http_sfv-0.9.9-py3-none-any.whl", hash = "sha256:5feed51c90e9a1dc797701662d044d936923cf0027255c75452b8240e33d6c82"}, + {file = "http_sfv-0.9.9.tar.gz", hash = "sha256:e132dc9bef990832bc01824f5fa9d4efc7d0f4271e4b227db35c8ef38540c739"}, +] + +[package.dependencies] +typing-extensions = "*" + +[package.extras] +dev = ["black", "mypy"] + [[package]] name = "httpcore" version = "1.0.7" @@ -1177,4 +1214,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = ">=3.11" -content-hash = "00ebbcef62c2ae9758433586af70f5a34f94a808eb18a27e838c8d743fd4854e" +content-hash = "31aa5dce166c2ac6b7581ee4b80386c306756ea07368184a423dffa2827e3be9" diff --git a/pyproject.toml b/pyproject.toml index 5075ba1..6d5cc28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,8 @@ requires-python = ">=3.11" dependencies = [ "httpx (>=0.28.1,<0.29.0)", "pydantic (>=2.10.6,<3.0.0)", - "cryptography (>=45.0.4,<46.0.0)" + "cryptography (>=45.0.4,<46.0.0)", + "http-message-signatures (>=0.6.1,<0.7.0)" ] [build-system] diff --git a/test/integration/test_grants.py b/test/integration/test_grants.py deleted file mode 100644 index c77df2e..0000000 --- a/test/integration/test_grants.py +++ /dev/null @@ -1,42 +0,0 @@ -import pytest -import yaml - -from open_payments_sdk.api.auth import Grants -from open_payments_sdk.gnap_utils.keys import KeyManager -from open_payments_sdk.models.auth import GrantRequest - - -@pytest.fixture -def auth_server(request): - root = request.config.rootpath - path = root / "spec" / "auth-server.yaml" - with open(path) as f: - spec = yaml.safe_load(f) - return spec["servers"][0]["url"] - - -@pytest.fixture -def example_grant_requests(request): - root = request.config.rootpath - spec_path = root / "spec" / "auth-server.yaml" - with open(spec_path) as f: - spec = yaml.safe_load(f) - path = spec["paths"]["/"]["post"] - examples = path["requestBody"]["content"]["application/json"]["examples"] - return examples.values() - -@pytest.fixture -def keyid_private_key() -> dict: - key_manager = KeyManager() - key_pair = key_manager.generate_key_pair() - return { - "private_key": key_pair.private_key_pem, - "keyid": key_pair.jwks.keys[0].kid - } - - -def test_post_grant_request(auth_server, example_grant_requests,keyid_private_key): - grant = Grants(keyid=keyid_private_key["keyid"],private_key=keyid_private_key["private_key"]) - for example in example_grant_requests: - grant_request = GrantRequest.model_validate(example["value"]) - grant.post_grant_request(grant_request, auth_server) diff --git a/test/integration/test_wallet.py b/test/integration/test_wallet.py deleted file mode 100644 index 4bbcd95..0000000 --- a/test/integration/test_wallet.py +++ /dev/null @@ -1,23 +0,0 @@ -import pytest -import yaml - -from open_payments_sdk.api.wallet import Wallet - - -@pytest.fixture -def wallet_address_server(request): - root = request.config.rootpath - path = root / "spec" / "wallet-address-server.yaml" - with open(path) as f: - spec = yaml.safe_load(f) - return spec["servers"][0]["url"] - - -def test_get_wallet_address(wallet_address_server): - wallet = Wallet() - wallet.get_wallet_address(wallet_address_server) - - -def test_get_wallet_address_keys(wallet_address_server): - wallet = Wallet() - wallet.get_keys(wallet_address_server) diff --git a/test/unit/test_hash_verification.py b/test/unit/test_hash_verification.py deleted file mode 100644 index bf82e8d..0000000 --- a/test/unit/test_hash_verification.py +++ /dev/null @@ -1,15 +0,0 @@ -# test_hash_manager.py -import base64 -import hashlib -from open_payments_sdk.gnap_utils.hash import HashManager # Replace with actual module name or use relative import - -def test_verify_hash(): - client_nonce = "abc123" - interact_nonce = "xyz456" - ref = "ref789" - url = "https://auth.interledger.com" - data = f"{client_nonce}\n{interact_nonce}\n{ref}\n{url}/" - expected = base64.b64encode(hashlib.sha256(data.encode()).digest()).decode() - h = HashManager() - assert h.verify_hash(client_nonce, interact_nonce, ref, url, expected) - assert not h.verify_hash(client_nonce, interact_nonce, ref, url, "wrong-hash") diff --git a/test/unit/test_http_signatures.py b/test/unit/test_http_signatures.py deleted file mode 100644 index 174ff9b..0000000 --- a/test/unit/test_http_signatures.py +++ /dev/null @@ -1,96 +0,0 @@ -import uuid -import pytest -import hashlib -import base64 -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey -from open_payments_sdk.gnap_utils.http_signatures import HTTPSignatureClient -from cryptography.hazmat.primitives import serialization - -from open_payments_sdk.gnap_utils.keys import KeyManager - - -@pytest.fixture -def sample_headers(): - return { - "content-type": "application/json", - "authorization": "GNAP 123454321", - "content-digest": "sha-512=:abc123xyz=", - "content-length": "18" - } - -@pytest.fixture -def private_key_str(): - privkey = Ed25519PrivateKey.generate() - private_pem = privkey.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption() - ) - return private_pem.decode("utf-8") - - -def test_build_signature_base(sample_headers): - key_manager = KeyManager() - signature_client = HTTPSignatureClient(key_manager=key_manager) - base = signature_client.build_signature_base( - headers=sample_headers, - method="POST", - target_uri="https://example.com/payments", - key_id=uuid.uuid4() - ) - - # It should include all expected lines - assert '"content-type": application/json' in base.signature_base - assert '"authorization": GNAP 123454321' in base.signature_base - assert '"@method": POST' in base.signature_base - assert '"@target-uri": https://example.com/payments' in base.signature_base - assert "@signature-params" in base.signature_base - - -def test_hash_signature_base_consistency(): - sig_base = '"authorization": test\n"@method": POST' - key_manager = KeyManager() - signature_client = HTTPSignatureClient(key_manager=key_manager) - digest = signature_client.hash_signature_base(sig_base) - - expected = hashlib.sha512(sig_base.encode("utf-8")).digest() - assert digest == expected - assert isinstance(digest, bytes) - - - -def test_build_signature(): - hashed_signature_base = b'\x00' * 64 # Example hash - private_key = Ed25519PrivateKey.generate() - key_manager = KeyManager() - signature_client = HTTPSignatureClient(key_manager=key_manager) - - private_key_pem = private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption() - ) - - # Call the method to test - signature = signature_client.build_signature(hashed_signature_base, private_key_pem.decode("utf-8")) - - # Decode signature and check - signature_bytes = base64.b64decode(signature) - assert isinstance(signature, str) - assert len(signature_bytes) == 64 - -def test_get_signature_headers(private_key_str,sample_headers): - key_manager = KeyManager() - signature_client = HTTPSignatureClient(key_manager=key_manager) - signature_headers = signature_client.get_signature_headers( - headers=sample_headers, - method="POST", - target_uri="https://example.com/payments", - key_id=uuid.uuid4(), - private_key=private_key_str - ) - print(f"Signature-Input: {signature_headers.signature_input}") - print(f"Signature: {signature_headers.signature}") - assert "alg=\"ed25519\"" in signature_headers.signature_input - assert "keyid=" in signature_headers.signature_input - \ No newline at end of file diff --git a/test/unit/test_key.py b/test/unit/test_key.py deleted file mode 100644 index 87c33e8..0000000 --- a/test/unit/test_key.py +++ /dev/null @@ -1,26 +0,0 @@ -from open_payments_sdk.gnap_utils.keys import KeyManager -from open_payments_sdk.models.keys import KeyJwks, KeyPair - - -def test_key_generation(): - key_manager = KeyManager() - key_result = key_manager.generate_key_pair() - assert isinstance(key_result, KeyPair) - assert isinstance(key_result.jwks, KeyJwks) - assert len(key_result.jwks.keys) == 1 - key = key_result.jwks.keys[0] - assert key.kty == "OKP" - assert key.crv == "Ed25519" - assert key.alg == "EdDSA" - assert isinstance(key.kid, str) - assert isinstance(key.x, str) - assert len(key.x) >= 43 - -def test_read_key_from_str(): - key_manager = KeyManager() - key_result = key_manager.generate_key_pair() - private_key = key_result.private_key_pem - ed25519PrivateKey = key_manager.load_ed25519_private_key_from_pem(private_key) - signature = ed25519PrivateKey.sign(b"my authenticated message") - public_key = ed25519PrivateKey.public_key() - public_key.verify(signature, b"my authenticated message") \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f5faa6e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,74 @@ +import pytest +from open_payments_sdk.client.client import OpenPaymentsClient +from open_payments_sdk.models.auth import GrantRequest + +@pytest.fixture +def keyid_private_key() -> dict: + """ + Get Private Key and Key Id + """ + with open("tests/integration/privkey.pem.example","r",encoding="utf_8") as privkey: + private_key = privkey.read() + return { + "private_key": private_key, + "keyid": "a96a5611-c5fa-49c0-8cb4-184763eca0b8" + } + +@pytest.fixture +def op_client(keyid_private_key) -> OpenPaymentsClient: + """ + Test OP client creation + """ + client = OpenPaymentsClient( + keyid=keyid_private_key["keyid"], + private_key=keyid_private_key["private_key"], + client_wallet_address="https://ilp.interledger-test.dev/elijahokellosalary" + ) + return client + +@pytest.fixture +def wallet_address_server()-> str: + """ + Get Wallet Address + """ + return "https://ilp.interledger-test.dev/elijahokellosalary" + +@pytest.fixture +def grant() -> str: + """ + get access token + """ + wallet = op_client.wallet.get_wallet_address("https://ilp.interledger-test.dev/5c327379") + return op_client.grants.post_grant_request(grant_request=grant_req_dto,auth_server_endpoint=str(wallet.authServer)) + +@pytest.fixture +def grant_req_dto() -> GrantRequest: + """ + Grant Request DTO + """ + grant_req = { + "access_token": { + "access": [ + { + "type": "incoming-payment", + "actions": [ + "create", + "read" + ], + "identifier": "https://ilp.interledger-test.dev/5c327379" + } + ] + }, + "client": "https://ilp.interledger-test.dev/elijahokellosalary", + "interact": { + "start": [ + "redirect" + ], + "finish": { + "method": "redirect", + "uri": "https://webmonize.com/return/876FGRD8VC", + "nonce": "4edb2194-dbdf-46bb-9397-d5fd57b7c8a7" + } + } + } + return GrantRequest(**grant_req) diff --git a/tests/integration/privkey.pem.example b/tests/integration/privkey.pem.example new file mode 100644 index 0000000..081020f --- /dev/null +++ b/tests/integration/privkey.pem.example @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIJcnQm2kGthtlZvFof+0bC9EvyNBEf+QNffr8sols5ah +-----END PRIVATE KEY----- diff --git a/tests/integration/test_grants.py b/tests/integration/test_grants.py new file mode 100644 index 0000000..cc45ec0 --- /dev/null +++ b/tests/integration/test_grants.py @@ -0,0 +1,11 @@ +from open_payments_sdk.client.client import OpenPaymentsClient +from open_payments_sdk.models.auth import GrantRequest + + +def test_createtest_grant_request(op_client: OpenPaymentsClient, grant_req_dto: GrantRequest): + """ + Test create grant request and get back access token + """ + wallet = op_client.wallet.get_wallet_address("https://ilp.interledger-test.dev/5c327379") + grant_response = op_client.grants.post_grant_request(grant_request=grant_req_dto,auth_server_endpoint=str(wallet.authServer)) + assert grant_response["continue"]["access_token"]["value"] is not None diff --git a/tests/integration/test_wallet.py b/tests/integration/test_wallet.py new file mode 100644 index 0000000..0dcdb36 --- /dev/null +++ b/tests/integration/test_wallet.py @@ -0,0 +1,15 @@ +def test_get_wallet_address(op_client,wallet_address_server): + """ + Test get wallet address + """ + wallet = op_client.wallet.get_wallet_address(wallet_address_server) + assert str(wallet.id) == wallet_address_server + assert wallet.assetCode is not None + + +def test_get_wallet_address_keys(op_client,wallet_address_server): + """ + Test get jwks.json + """ + keys = op_client.wallet.get_keys(wallet_address_server) + assert keys.keys[0].kid is not None diff --git a/test/unit/test_op_client.py b/tests/unit/test_op_client.py similarity index 62% rename from test/unit/test_op_client.py rename to tests/unit/test_op_client.py index 04f9b6c..e999bc6 100644 --- a/test/unit/test_op_client.py +++ b/tests/unit/test_op_client.py @@ -1,23 +1,21 @@ -import pytest +""" +Unit Tests for OP Client +""" from open_payments_sdk.api.auth import AccessTokens, Grants from open_payments_sdk.api.resource import IncomingPayments, OutgoingPayments, Quotes from open_payments_sdk.client.client import OpenPaymentsClient -from open_payments_sdk.gnap_utils.keys import KeyManager - -@pytest.fixture -def keyid_private_key() -> dict: - key_manager = KeyManager() - key_pair = key_manager.generate_key_pair() - return { - "private_key": key_pair.private_key_pem, - "keyid": key_pair.jwks.keys[0].kid - } def test_create_op_client(keyid_private_key): + """ + Test OP client creation + """ keyid = keyid_private_key["keyid"] private_key = keyid_private_key["private_key"] - client = OpenPaymentsClient(keyid=keyid_private_key["keyid"],private_key=keyid_private_key["private_key"]) - + client = OpenPaymentsClient( + keyid=keyid_private_key["keyid"], + private_key=keyid_private_key["private_key"], + client_wallet_address="https://ilp.interledger-test.dev/elijahokellosalary" + ) assert client.keyid == keyid assert client.private_key == private_key From aa4493ca9ef5964e034d1a91d90a71e675e9a029 Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Sun, 22 Jun 2025 13:51:59 +0200 Subject: [PATCH 27/38] docs: added usage docs --- README.md | 16 ++++++++++++++-- tests/conftest.py | 9 +++++++++ tests/integration/test_grants.py | 3 ++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 88cab5e..ade5155 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ You can use the created client to interact with the resource server. In this cas #get wallet address wallet_address_details = op_client.wallet.get_wallet_address("https://ilp.interledger-test.dev/elijahokellosalary") -# Output +# Response {'id': AnyUrl('https://ilp.interledger-test.dev/elijahokellosalary'), 'publicName': 'elijahokellosalary', 'assetCode': AssetCode(root='USD'), 'assetScale': AssetScale(root=2), 'authServer': AnyUrl('https://auth.interledger-test.dev/'), 'resourceServer': AnyUrl('https://ilp.interledger-test.dev/')} ``` @@ -123,7 +123,19 @@ Get Wallet jwks #get wallet jwks wallet_jwks = op_client.wallet.get_keys("https://ilp.interledger-test.dev/elijahokellosalary") -# Output +# Response {'keys': []} ``` + +## Grants +You can use the created client to request a grant from the authorization server. In this case we will use it to request a grant for an incoming payment resource. Check the [fixtures](./tests/conftest.py) file to see the request body. + +```python +wallet = op_client.wallet.get_wallet_address("https://ilp.interledger-test.dev/5c327379") +grant_response = op_client.grants.post_grant_request(grant_request=grant_req_dto,auth_server_endpoint=str(wallet.authServer)) + +# Response +{'access_token': {'access': [{'actions': ['create', 'read'], 'identifier': 'https://ilp.interledger-test.dev/5c327379', 'type': 'incoming-payment'}], 'value': '2E6F040D518B6F1A0883', 'manage': 'https://auth.interledger-test.dev/token/dad85db0-804d-4778-bf78-33eb5f81d86e', 'expires_in': 600}, 'continue': {'access_token': {'value': '3A088F83D39BDCDEC995'}, 'uri': 'https://auth.interledger-test.dev/continue/d2bb7a46-8cd9-4dde-83e2-821353b50579'}} + +``` diff --git a/tests/conftest.py b/tests/conftest.py index f5faa6e..49fd4b3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -72,3 +72,12 @@ def grant_req_dto() -> GrantRequest: } } return GrantRequest(**grant_req) + + +@pytest.fixture +def interactive_grant_req_dto() -> GrantRequest: #TODO complete writing tests + """ + Create Interactive Grant + """ + grant_req = {} + return GrantRequest(**grant_req) \ No newline at end of file diff --git a/tests/integration/test_grants.py b/tests/integration/test_grants.py index cc45ec0..0d9efcc 100644 --- a/tests/integration/test_grants.py +++ b/tests/integration/test_grants.py @@ -2,10 +2,11 @@ from open_payments_sdk.models.auth import GrantRequest -def test_createtest_grant_request(op_client: OpenPaymentsClient, grant_req_dto: GrantRequest): +def test_create_grant_request(op_client: OpenPaymentsClient, grant_req_dto: GrantRequest): """ Test create grant request and get back access token """ wallet = op_client.wallet.get_wallet_address("https://ilp.interledger-test.dev/5c327379") grant_response = op_client.grants.post_grant_request(grant_request=grant_req_dto,auth_server_endpoint=str(wallet.authServer)) assert grant_response["continue"]["access_token"]["value"] is not None + \ No newline at end of file From 293b0d8f4bc95950a03d1d6830331b6a51fb2e9b Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Sun, 22 Jun 2025 19:28:31 +0200 Subject: [PATCH 28/38] chore: remove unused imports --- src/open_payments_sdk/api/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/open_payments_sdk/api/auth.py b/src/open_payments_sdk/api/auth.py index b308f1e..abc5d7f 100644 --- a/src/open_payments_sdk/api/auth.py +++ b/src/open_payments_sdk/api/auth.py @@ -5,7 +5,7 @@ from open_payments_sdk.gnap_utils.security import SecurityBase from open_payments_sdk.http import HttpClient -from open_payments_sdk.models.auth import AccessToken, GrantResponse, InteractionInstructionsResponse +from open_payments_sdk.models.auth import AccessToken from open_payments_sdk.models.auth import Grant as AuthGrant from open_payments_sdk.models.auth import (GrantContinueResponse, GrantRequest, InteractRef) From 973bc425b581c9365938c43f059f54aec7a63065 Mon Sep 17 00:00:00 2001 From: Elijah Okello Date: Thu, 17 Jul 2025 20:03:47 +0200 Subject: [PATCH 29/38] Update README.md Co-authored-by: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ade5155..71e520b 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ An Open Payments server runs two sub-systems, a resource server which exposes AP ### Installation 1. Activate your virtual emvironment. No need to create one, Poetry creates one. - [Read Poetry documentation](https://python-poetry.org/docs/managing-environments/) to read how to activate + Read [managing environments in Poetry](https://python-poetry.org/docs/managing-environments/). 2. Install the dependencies in the poetry.lock From 9303f8e4f5b32bdae0dc5527492e77c5864afcc0 Mon Sep 17 00:00:00 2001 From: Elijah Okello Date: Thu, 17 Jul 2025 20:08:52 +0200 Subject: [PATCH 30/38] Update README.md Co-authored-by: Sid Vishnoi <8426945+sidvishnoi@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 71e520b..107ead1 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ An Open Payments server runs two sub-systems, a resource server which exposes AP To install python visit [Python Download](https://www.python.org/downloads/) - Poetry - To install poetry visiit [Poetry Documentation](https://python-poetry.org/docs/) + To install poetry visit [Poetry Documentation](https://python-poetry.org/docs/). ### Installation From 29f52bd900d28d3f16a7947cff26f49343bf1ba5 Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Sat, 26 Jul 2025 18:32:46 +0200 Subject: [PATCH 31/38] chore: addressed issues in PR comments --- .github/workflows/test.yml | 23 ++++++++++++------ .gitignore | 2 +- README.md | 25 +++++++++++++------- src/open_payments_sdk/api/auth.py | 22 ++++++++--------- src/open_payments_sdk/api/resource.py | 25 ++++++++++---------- src/open_payments_sdk/gnap_utils/keys.py | 8 +++---- src/open_payments_sdk/gnap_utils/security.py | 14 ----------- src/open_payments_sdk/http.py | 12 +++++----- src/open_payments_sdk/models/auth.py | 24 +++++++++++++++---- src/open_payments_sdk/utils/utils.py | 18 ++++++++++++++ tests/integration/test_grants.py | 13 +++++++++- 11 files changed, 117 insertions(+), 69 deletions(-) create mode 100644 src/open_payments_sdk/utils/utils.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fe3cbb9..6f24295 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,4 @@ -name: Test Python open payments sdk f +name: Test Python open payments sdk on: push: @@ -10,11 +10,12 @@ on: jobs: test: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: matrix: - python-version: [3.11, 3.12, 3.13] + os: [ubuntu-latest, windows-latest] + python-version: [3.9,3.10,3.11, 3.12, 3.13] steps: - name: Checkout code @@ -25,13 +26,10 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Install Poetry + - name: Setup Poetry run: | curl -sSL https://install.python-poetry.org | python3 - echo "$HOME/.local/bin" >> $GITHUB_PATH - - - name: Configure Poetry - run: | poetry config virtualenvs.create false - name: Install dependencies @@ -42,3 +40,14 @@ jobs: - name: Run Unit Tests run: | poetry run pytest tests/unit/ + + audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Install dependencies + run: | + python3 -m pip install . # assumes dependencies declared in pyproject.toml + python3 -m pip install pip-audit + - name: Run pip-audit + run: pip-audit . diff --git a/.gitignore b/.gitignore index e0e410d..04ebe4d 100644 --- a/.gitignore +++ b/.gitignore @@ -170,4 +170,4 @@ poetry.toml pyrightconfig.json # End of https://www.toptal.com/developers/gitignore/api/python -privkey.pem \ No newline at end of file +privkey.pem diff --git a/README.md b/README.md index 107ead1..24abd66 100644 --- a/README.md +++ b/README.md @@ -21,43 +21,47 @@ An Open Payments server runs two sub-systems, a resource server which exposes AP ## Local development - - - Python >= 3.11 To install python visit [Python Download](https://www.python.org/downloads/) + - Poetry To install poetry visit [Poetry Documentation](https://python-poetry.org/docs/). ### Installation -1. Activate your virtual emvironment. No need to create one, Poetry creates one. +1. Activate your virtual emvironment. No need to create one, Poetry creates one. Read [managing environments in Poetry](https://python-poetry.org/docs/managing-environments/). - 2. Install the dependencies in the poetry.lock ``` > poetry install ``` -## Usage -To use this sdk, you will first need to install it in your project. Currently you will need to build from source but once it is hosted on pypi you will be able to install it with pip + +## Usage + +To use this SDK, you will first need to install it in your project. Currently, you will need to build from source but once it is hosted on PyPi you will be able to install it with `pip`. ```bash python3 -m pip install open-payments-python-sdk #currently not setup ``` + ## Installing from source -Clone the repository +Clone the repository + ```bash git clone https://github.com/interledger/open-payments-python-sdk.git cd open-payments-python-sdk ``` Build the package + ```bash poetry build ``` + After running this command, the wheel package will be written to the `dist/` folder in the repo you just cloned Install it in your project @@ -67,6 +71,7 @@ pip install open-payments-python-sdk/dist/open_payments_sdk-0.1.0-py3 ``` # Initialising the Client + To create a client you can do so by importing the `OpenPaymentsClient` defined in the [`client`](./src/client/client.py) module and instantiating it. ```python @@ -106,8 +111,10 @@ private_key = key_manager.load_ed25519_private_key_from_pem( public_key = private_key.public_key() # derive public key from private key ``` + ## Wallets -You can use the created client to interact with the resource server. In this case we will use it to interact with a wallet address to get the wallet address details and jwks.json + +You can use the created client to interact with the resource server. In this case we will use it to interact with a wallet address to get the wallet address details and jwks.json ```python #get wallet address @@ -117,6 +124,7 @@ wallet_address_details = op_client.wallet.get_wallet_address("https://ilp.interl {'id': AnyUrl('https://ilp.interledger-test.dev/elijahokellosalary'), 'publicName': 'elijahokellosalary', 'assetCode': AssetCode(root='USD'), 'assetScale': AssetScale(root=2), 'authServer': AnyUrl('https://auth.interledger-test.dev/'), 'resourceServer': AnyUrl('https://ilp.interledger-test.dev/')} ``` + Get Wallet jwks ```python @@ -129,6 +137,7 @@ wallet_jwks = op_client.wallet.get_keys("https://ilp.interledger-test.dev/elijah ``` ## Grants + You can use the created client to request a grant from the authorization server. In this case we will use it to request a grant for an incoming payment resource. Check the [fixtures](./tests/conftest.py) file to see the request body. ```python diff --git a/src/open_payments_sdk/api/auth.py b/src/open_payments_sdk/api/auth.py index abc5d7f..e1c315a 100644 --- a/src/open_payments_sdk/api/auth.py +++ b/src/open_payments_sdk/api/auth.py @@ -5,10 +5,10 @@ from open_payments_sdk.gnap_utils.security import SecurityBase from open_payments_sdk.http import HttpClient -from open_payments_sdk.models.auth import AccessToken -from open_payments_sdk.models.auth import Grant as AuthGrant +from open_payments_sdk.models.auth import AccessToken, Grant from open_payments_sdk.models.auth import (GrantContinueResponse, GrantRequest, InteractRef) +from open_payments_sdk.utils.utils import get_default_covered_components, get_default_headers @@ -25,14 +25,14 @@ def post_grant_request( self, grant_request: GrantRequest, auth_server_endpoint: str, - ) -> AuthGrant: + ) -> Grant: """ Grant Request """ data = grant_request.model_dump(exclude_unset=True, mode="json") req_headers = { - **self.get_default_headers() + **get_default_headers() } request = self.http_client.build_request( method="POST", @@ -41,9 +41,9 @@ def post_grant_request( headers=req_headers ) request = self.set_content_digest(request=request) - request = self.sign_request(request,("content-type","content-digest","content-length",*self.get_default_covered_components())) + request = self.sign_request(request,("content-type","content-digest","content-length",*get_default_covered_components())) response = self.http_client.send(request=request) - return response.json() + return Grant.model_validate(response.json()) def post_grant_continuation_request( self, @@ -56,7 +56,7 @@ def post_grant_continuation_request( """ data = interact_ref.model_dump(exclude_unset=True, mode="json") req_headers = { - **self.get_default_headers(), + **get_default_headers(), **self.get_auth_header(access_token=access_token) } request = self.http_client.build_request( @@ -66,7 +66,7 @@ def post_grant_continuation_request( headers=req_headers ) request = self.set_content_digest(request=request) - request = self.sign_request(request,("content-type","content-digest","content-length","authorization",*self.get_default_covered_components())) + request = self.sign_request(request,("content-type","content-digest","content-length","authorization",*get_default_covered_components())) response = self.http_client.send(request=request) return GrantContinueResponse.model_validate(response.json()) @@ -89,7 +89,7 @@ def delete_grant( url=url, headers=req_headers ) - request = self.sign_request(request,("authorization",*self.get_default_covered_components())) + request = self.sign_request(request,("authorization",*get_default_covered_components())) self.http_client.send(request=request) class AccessTokens(SecurityBase): @@ -119,7 +119,7 @@ def post_rotate_access_token( url=url, headers=req_headers ) - request = self.sign_request(request,("authorization",*self.get_default_covered_components())) + request = self.sign_request(request,("authorization",*get_default_covered_components())) response = self.http_client.send(request=request) return AccessToken.model_validate(response.json()) @@ -143,5 +143,5 @@ def delete_access_token( url=url, headers=req_headers ) - request = self.sign_request(request,("authorization",*self.get_default_covered_components())) + request = self.sign_request(request,("authorization",*get_default_covered_components())) self.http_client.send(request=request) \ No newline at end of file diff --git a/src/open_payments_sdk/api/resource.py b/src/open_payments_sdk/api/resource.py index 317e144..732b6b8 100644 --- a/src/open_payments_sdk/api/resource.py +++ b/src/open_payments_sdk/api/resource.py @@ -13,6 +13,7 @@ PaginatedOutgoingPayments, PaymentListQuery, Quote, QuoteRequest) +from open_payments_sdk.utils.utils import get_default_covered_components, get_default_headers class IncomingPayments(SecurityBase): @@ -36,7 +37,7 @@ def post_create_payment( url = f"{base_url}/incoming-payments" data = payment.model_dump(exclude_unset=True, mode="json") req_headers = { - **self.get_default_headers(), + **get_default_headers(), **self.get_auth_header(access_token=access_token) } request = self.http_client.build_request( @@ -46,7 +47,7 @@ def post_create_payment( headers=req_headers ) request = self.set_content_digest(request=request) - request = self.sign_request(request,("content-type","content-digest","content-length","authorization",*self.get_default_covered_components())) + request = self.sign_request(request,("content-type","content-digest","content-length","authorization",*get_default_covered_components())) response = self.http_client.send(request=request) return IncomingPayment.model_validate(response.json()) @@ -70,7 +71,7 @@ def get_incoming_payments( headers=req_headers, params=query_params ) - request = self.sign_request(request,("authorization",*self.get_default_covered_components())) + request = self.sign_request(request,("authorization",*get_default_covered_components())) response = self.http_client.send(request=request) return PaginatedIncomingPayments.model_validate(response.json()) @@ -93,7 +94,7 @@ def get_incoming_payment( url=url, headers=req_headers ) - request = self.sign_request(request,("authorization",*self.get_default_covered_components())) + request = self.sign_request(request,("authorization",*get_default_covered_components())) response = self.http_client.send(request=request) return IncomingPaymentResponse.model_validate(response.json()) @@ -116,7 +117,7 @@ def post_complete_incoming_payment( url=url, headers=req_headers ) - request = self.sign_request(request,("authorization",*self.get_default_covered_components())) + request = self.sign_request(request,("authorization",*get_default_covered_components())) response = self.http_client.send(request=request) return IncomingPayment.model_validate(response.json()) @@ -141,7 +142,7 @@ def post_create_payment( url = f"{base_url}/outgoing-payments" data = payment.model_dump(exclude_unset=True, mode="json") req_headers = { - **self.get_default_headers(), + **get_default_headers(), **self.get_auth_header(access_token=access_token) } request = self.http_client.build_request( @@ -151,7 +152,7 @@ def post_create_payment( headers=req_headers ) request = self.set_content_digest(request=request) - request = self.sign_request(request,("content-type","content-digest","content-length","authorization",*self.get_default_covered_components())) + request = self.sign_request(request,("content-type","content-digest","content-length","authorization",*get_default_covered_components())) response = self.http_client.send(request=request) return OutgoingPayment.model_validate(response.json()) @@ -176,7 +177,7 @@ def get_outgoing_payments( headers=req_headers, params=query_params ) - response = request = self.sign_request(request,("authorization",*self.get_default_covered_components())) + response = request = self.sign_request(request,("authorization",*get_default_covered_components())) self.http_client.send(request=request) return PaginatedOutgoingPayments.model_validate(response.json()) @@ -198,7 +199,7 @@ def get_outgoing_payment( url=url, headers=req_headers ) - request = self.sign_request(request,("authorization",*self.get_default_covered_components())) + request = self.sign_request(request,("authorization",*get_default_covered_components())) response = self.http_client.send(request=request) return OutgoingPayment.model_validate(response.json()) @@ -223,7 +224,7 @@ def post_create_quote( url = f"{base_url}/quotes" data = quote.model_dump(exclude_unset=True, mode="json") req_headers = { - **self.get_default_headers(), + **get_default_headers(), **self.get_auth_header(access_token=access_token) } request = self.http_client.build_request( @@ -233,7 +234,7 @@ def post_create_quote( json=data ) request = self.set_content_digest(request=request) - request = self.sign_request(request,("content-type","content-digest","content-length","authorization",*self.get_default_covered_components())) + request = self.sign_request(request,("content-type","content-digest","content-length","authorization",*get_default_covered_components())) response = self.http_client.send(request=request) return Quote.model_validate(response.json()) @@ -256,6 +257,6 @@ def get_quote( url=url, headers=req_headers ) - request = self.sign_request(request,("authorization",*self.get_default_covered_components())) + request = self.sign_request(request,("authorization",*get_default_covered_components())) response = self.http_client.send(request=request) return Quote.model_validate(response.json()) diff --git a/src/open_payments_sdk/gnap_utils/keys.py b/src/open_payments_sdk/gnap_utils/keys.py index ee17528..0e18960 100644 --- a/src/open_payments_sdk/gnap_utils/keys.py +++ b/src/open_payments_sdk/gnap_utils/keys.py @@ -43,15 +43,15 @@ def generate_key_pair(self) -> KeyPair: keypair = KeyPair(jwks=key_jwks, private_key_pem=private_key_pem) return KeyPair.model_validate(keypair) - def load_ed25519_private_key_from_pem(self,pem_str: Union[str, bytes]) -> Ed25519PrivateKey: + def load_ed25519_private_key_from_pem(self,pem_bytes: Union[str, bytes]) -> Ed25519PrivateKey: """ Read private key from str or bytes string """ - if isinstance(pem_str,str): - pem_str = pem_str.encode("utf-8") + if isinstance(pem_bytes,str): + pem_bytes = pem_bytes.encode("utf-8") private_key = serialization.load_pem_private_key( - data=pem_str, + data=pem_bytes, password=None ) diff --git a/src/open_payments_sdk/gnap_utils/security.py b/src/open_payments_sdk/gnap_utils/security.py index 4d1a7ea..445c062 100644 --- a/src/open_payments_sdk/gnap_utils/security.py +++ b/src/open_payments_sdk/gnap_utils/security.py @@ -44,20 +44,6 @@ def sign_request(self, message: Request, covered_component_ids: Sequence[str] )- label="sig1" ) return message - - def get_default_headers(self) -> dict: - """ - Get default headers - """ - return { - "Content-Type": "application/json" - } - - def get_default_covered_components(self) -> tuple: - """ - Return default covered components - """ - return ("@method","@target-uri") def set_content_digest(self, request: Request) -> Request: """ diff --git a/src/open_payments_sdk/http.py b/src/open_payments_sdk/http.py index d17601c..1312255 100644 --- a/src/open_payments_sdk/http.py +++ b/src/open_payments_sdk/http.py @@ -1,7 +1,7 @@ """ HTTP Client """ -import httpx +from httpx import Request, Response, Client class HttpClient: """ @@ -20,11 +20,11 @@ def build_request( data = None, json: dict = None, params: dict = None - ) -> httpx.Request: + ) -> Request: """ - Build httpx request + Build request """ - return httpx.Request( + return Request( method=method, url=url, headers=headers, @@ -33,11 +33,11 @@ def build_request( params=params ) - def send(self, request: httpx.Request) -> httpx.Response: + def send(self, request: Request) -> Response: """ Make an http request """ - with httpx.Client(timeout=self.http_timeout) as client: + with Client(timeout=self.http_timeout) as client: res = client.send(request=request) res.raise_for_status() return res diff --git a/src/open_payments_sdk/models/auth.py b/src/open_payments_sdk/models/auth.py index 7f3a5e3..e5da46a 100644 --- a/src/open_payments_sdk/models/auth.py +++ b/src/open_payments_sdk/models/auth.py @@ -1,7 +1,7 @@ from enum import Enum -from typing import List, Optional, Union +from typing import Any, List, Optional, Union -from pydantic import AnyUrl, BaseModel, ConfigDict, Field, RootModel, conint +from pydantic import AnyUrl, BaseModel, ConfigDict, Field, RootModel, conint, model_validator, root_validator class TypeIncoming(Enum): @@ -296,13 +296,27 @@ class GrantRequest(BaseModel): client: Client interact: Optional[InteractRequest] = None - -class InteractionInstructionsResponse(BaseModel): +class ReservedKeyMappingModel(BaseModel): + """ + Base class that maps 'continue' to 'cont' in incoming data. + """ + + @model_validator(mode='before') + @classmethod + def replace_continue_key(cls, values: Any) -> Any: + """ + Map continue to cont + """ + if isinstance(values, dict) and "continue" in values: + values["cont"] = values.pop("continue") + return values + +class InteractionInstructionsResponse(ReservedKeyMappingModel): interact: InteractResponse cont: Continue -class GrantResponse(BaseModel): +class GrantResponse(ReservedKeyMappingModel): access_token: AccessToken cont: Continue diff --git a/src/open_payments_sdk/utils/utils.py b/src/open_payments_sdk/utils/utils.py new file mode 100644 index 0000000..9268f96 --- /dev/null +++ b/src/open_payments_sdk/utils/utils.py @@ -0,0 +1,18 @@ +""" +Common Utilities +""" + + +def get_default_headers() -> dict: + """ + Get default headers + """ + return { + "Content-Type": "application/json" + } + +def get_default_covered_components() -> tuple: + """ + Return default covered components + """ + return ("@method","@target-uri") \ No newline at end of file diff --git a/tests/integration/test_grants.py b/tests/integration/test_grants.py index 0d9efcc..2978cf4 100644 --- a/tests/integration/test_grants.py +++ b/tests/integration/test_grants.py @@ -1,3 +1,4 @@ +import json from open_payments_sdk.client.client import OpenPaymentsClient from open_payments_sdk.models.auth import GrantRequest @@ -8,5 +9,15 @@ def test_create_grant_request(op_client: OpenPaymentsClient, grant_req_dto: Gran """ wallet = op_client.wallet.get_wallet_address("https://ilp.interledger-test.dev/5c327379") grant_response = op_client.grants.post_grant_request(grant_request=grant_req_dto,auth_server_endpoint=str(wallet.authServer)) - assert grant_response["continue"]["access_token"]["value"] is not None + assert grant_response.root.access_token.value is not None + assert grant_response.root.access_token.value != "" + + +def test_create_interactive_grant_request( + op_client: OpenPaymentsClient, + grant_req_dto: GrantRequest + ): + """ + Test interactive grant request + """ \ No newline at end of file From 3f468f5b6cd8872bf30fa13a98fca742754833c1 Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Sat, 26 Jul 2025 18:36:58 +0200 Subject: [PATCH 32/38] ci: fix failing pipeline --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6f24295..c29c9a5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest] - python-version: [3.9,3.10,3.11, 3.12, 3.13] + python-version: [3.9,3.11, 3.12,3.13] steps: - name: Checkout code From 28ad4f0a43bc2476aa7fe10897945925a3a0b59a Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Sat, 26 Jul 2025 18:53:00 +0200 Subject: [PATCH 33/38] ci: fixed ci error and add windows support and set python supported version --- .github/workflows/test.yml | 12 ++++++++++-- pyproject.toml | 4 ++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c29c9a5..410f28b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest] - python-version: [3.9,3.11, 3.12,3.13] + python-version: ["3.9","3.10","3.11","3.12","3.13"] steps: - name: Checkout code @@ -26,7 +26,15 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Setup Poetry + - name: Setup Poetry on Windows + if: runner.os == 'Windows' + run: | + (Invoke-WebRequest -Uri https://install.python-poetry.org -UseBasicParsing).Content | py - + echo "$env:USERPROFILE\.local\bin" | Out-File -Append -Encoding ascii $env:GITHUB_PATH + poetry config virtualenvs.create false + + - name: Setup Poetry on Ubuntu + if: runner.os != 'Windows' run: | curl -sSL https://install.python-poetry.org | python3 - echo "$HOME/.local/bin" >> $GITHUB_PATH diff --git a/pyproject.toml b/pyproject.toml index 6d5cc28..bfe364a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,10 +5,10 @@ description = "Python SDK implementation for open-payments API" authors = [ {name = "Yiannis Giannelos",email = "johngiannelos@gmail.com"}, {name = "Elijah Okello", email = "elijahokello90@gmail.com"}, - {name = "Kasweka Michael Mukoko", email = "kasweka.mukoko@izyane.com"} + {name = "Kasweka Michael Mukoko", email = "kaswekamukoko@gmail.com"} ] license = {text = "Apache-2.0"} -requires-python = ">=3.11" +requires-python = ">=3.9" dependencies = [ "httpx (>=0.28.1,<0.29.0)", "pydantic (>=2.10.6,<3.0.0)", From 81429ee3dbe04e1c1ff20a2462a3fa278689d23f Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Sat, 26 Jul 2025 18:55:38 +0200 Subject: [PATCH 34/38] fix: fixed python versions and synced lock file --- .github/workflows/test.yml | 2 +- poetry.lock | 36 +++++++++++++++++++++++++++++++----- pyproject.toml | 2 +- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 410f28b..6012baa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest] - python-version: ["3.9","3.10","3.11","3.12","3.13"] + python-version: ["3.10","3.11","3.12","3.13"] steps: - name: Checkout code diff --git a/poetry.lock b/poetry.lock index e2db086..f5c124b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -25,6 +25,7 @@ files = [ ] [package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} idna = ">=2.8" sniffio = ">=1.1" typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} @@ -103,6 +104,8 @@ mypy-extensions = ">=0.4.3" packaging = ">=22.0" pathspec = ">=0.9.0" platformdirs = ">=2" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} [package.extras] colorama = ["colorama (>=0.4.3)"] @@ -334,6 +337,25 @@ files = [ {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, ] +[[package]] +name = "exceptiongroup" +version = "1.3.0" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +groups = ["main", "dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, + {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] + [[package]] name = "executing" version = "2.2.0" @@ -513,8 +535,9 @@ files = [ ] [package.dependencies] -decorator = {version = "*", markers = "python_version >= \"3.11\""} -ipython = {version = ">=7.31.1", markers = "python_version >= \"3.11\""} +decorator = {version = "*", markers = "python_version > \"3.6\""} +ipython = {version = ">=7.31.1", markers = "python_version > \"3.6\""} +tomli = {version = "*", markers = "python_version > \"3.6\" and python_version < \"3.11\""} [[package]] name = "ipython" @@ -531,6 +554,7 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} decorator = "*" +exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} jedi = ">=0.16" matplotlib-inline = "*" pexpect = {version = ">4.3", markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""} @@ -1026,9 +1050,11 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" pluggy = ">=1.5,<2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] @@ -1135,7 +1161,7 @@ description = "A lil' TOML parser" optional = false python-versions = ">=3.8" groups = ["dev"] -markers = "python_version == \"3.11\"" +markers = "python_version <= \"3.11\"" files = [ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, @@ -1213,5 +1239,5 @@ files = [ [metadata] lock-version = "2.1" -python-versions = ">=3.11" -content-hash = "31aa5dce166c2ac6b7581ee4b80386c306756ea07368184a423dffa2827e3be9" +python-versions = ">=3.10" +content-hash = "9dde0f8e1090fb17c60fe4ae8de9749f3a21819dbaa747bba43daa10099db2c6" diff --git a/pyproject.toml b/pyproject.toml index bfe364a..3a5fd16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ authors = [ {name = "Kasweka Michael Mukoko", email = "kaswekamukoko@gmail.com"} ] license = {text = "Apache-2.0"} -requires-python = ">=3.9" +requires-python = ">=3.10" dependencies = [ "httpx (>=0.28.1,<0.29.0)", "pydantic (>=2.10.6,<3.0.0)", From c339b919699900ef291efc3a4f4148f15cde74b4 Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Sat, 26 Jul 2025 19:02:11 +0200 Subject: [PATCH 35/38] ci: add poetry to path on windows runer --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6012baa..759705c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,6 +31,7 @@ jobs: run: | (Invoke-WebRequest -Uri https://install.python-poetry.org -UseBasicParsing).Content | py - echo "$env:USERPROFILE\.local\bin" | Out-File -Append -Encoding ascii $env:GITHUB_PATH + echo 'if (-not (Get-Command poetry -ErrorAction Ignore)) { $env:Path += ";C:\Users\runneradmin\AppData\Roaming\Python\Scripts" }' | Out-File -Append $PROFILE poetry config virtualenvs.create false - name: Setup Poetry on Ubuntu From 27b71066d46d759eb1a9bedfa444a5b266ee8e80 Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Sat, 26 Jul 2025 19:10:35 +0200 Subject: [PATCH 36/38] ci: add poetry to path on windows --- .github/workflows/test.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 759705c..c62ad2c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,8 +30,7 @@ jobs: if: runner.os == 'Windows' run: | (Invoke-WebRequest -Uri https://install.python-poetry.org -UseBasicParsing).Content | py - - echo "$env:USERPROFILE\.local\bin" | Out-File -Append -Encoding ascii $env:GITHUB_PATH - echo 'if (-not (Get-Command poetry -ErrorAction Ignore)) { $env:Path += ";C:\Users\runneradmin\AppData\Roaming\Python\Scripts" }' | Out-File -Append $PROFILE + echo "$env:APPDATA\Python\Scripts" >> $env:GITHUB_PATH poetry config virtualenvs.create false - name: Setup Poetry on Ubuntu From bbfbe66d2beeb3b04dbc338ed79cfc1982ad7732 Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Sun, 27 Jul 2025 01:37:57 +0200 Subject: [PATCH 37/38] ci: debug windows installation dir --- .github/workflows/test.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c62ad2c..6f9de3c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,6 +30,8 @@ jobs: if: runner.os == 'Windows' run: | (Invoke-WebRequest -Uri https://install.python-poetry.org -UseBasicParsing).Content | py - + echo $env:APPDATA\Python\Scripts + dir $env:APPDATA\Python\Scripts echo "$env:APPDATA\Python\Scripts" >> $env:GITHUB_PATH poetry config virtualenvs.create false From a8ab4eea509d9923cc035426566c135f0001ae69 Mon Sep 17 00:00:00 2001 From: elijah0kello Date: Sun, 27 Jul 2025 01:42:17 +0200 Subject: [PATCH 38/38] ci: verify poetry is installed and create envs in new step --- .github/workflows/test.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6f9de3c..57239a0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,10 +30,14 @@ jobs: if: runner.os == 'Windows' run: | (Invoke-WebRequest -Uri https://install.python-poetry.org -UseBasicParsing).Content | py - - echo $env:APPDATA\Python\Scripts - dir $env:APPDATA\Python\Scripts echo "$env:APPDATA\Python\Scripts" >> $env:GITHUB_PATH + + - name: Verify Poetry and create env on Windows + if: runner.os == 'Windows' + run: | + poetry --version poetry config virtualenvs.create false + - name: Setup Poetry on Ubuntu if: runner.os != 'Windows'