From b1c0bc571357b4609c77de1ffbe059a2f53cf515 Mon Sep 17 00:00:00 2001 From: jennifer-stytch <111309110+jennifer-stytch@users.noreply.github.com> Date: Thu, 1 Aug 2024 08:24:40 -0700 Subject: [PATCH] Add Sign in With Ethereum (#213) --- stytch/b2b/api/organizations.py | 12 ++-- stytch/b2b/api/organizations_members.py | 4 +- stytch/b2b/api/passwords_email.py | 12 ++-- stytch/b2b/api/scim_connection.py | 4 +- stytch/b2b/models/organizations.py | 18 +++++- stytch/b2b/models/scim.py | 7 +++ stytch/consumer/api/crypto_wallets.py | 29 ++++++++- stytch/consumer/api/passwords_email.py | 12 ++-- stytch/consumer/api/sessions.py | 34 +++++++++++ stytch/consumer/api/webauthn.py | 24 ++++---- stytch/consumer/models/crypto_wallets.py | 75 +++++++++++++++++++----- stytch/consumer/models/m2m.py | 1 + stytch/consumer/models/sessions.py | 12 ++++ stytch/core/response_base.py | 3 +- stytch/version.py | 2 +- 15 files changed, 194 insertions(+), 55 deletions(-) diff --git a/stytch/b2b/api/organizations.py b/stytch/b2b/api/organizations.py index 3ff36ed5..e2fad37e 100644 --- a/stytch/b2b/api/organizations.py +++ b/stytch/b2b/api/organizations.py @@ -462,9 +462,9 @@ def update( if sso_jit_provisioning is not None: data["sso_jit_provisioning"] = sso_jit_provisioning if sso_jit_provisioning_allowed_connections is not None: - data[ - "sso_jit_provisioning_allowed_connections" - ] = sso_jit_provisioning_allowed_connections + data["sso_jit_provisioning_allowed_connections"] = ( + sso_jit_provisioning_allowed_connections + ) if email_allowed_domains is not None: data["email_allowed_domains"] = email_allowed_domains if email_jit_provisioning is not None: @@ -633,9 +633,9 @@ async def update_async( if sso_jit_provisioning is not None: data["sso_jit_provisioning"] = sso_jit_provisioning if sso_jit_provisioning_allowed_connections is not None: - data[ - "sso_jit_provisioning_allowed_connections" - ] = sso_jit_provisioning_allowed_connections + data["sso_jit_provisioning_allowed_connections"] = ( + sso_jit_provisioning_allowed_connections + ) if email_allowed_domains is not None: data["email_allowed_domains"] = email_allowed_domains if email_jit_provisioning is not None: diff --git a/stytch/b2b/api/organizations_members.py b/stytch/b2b/api/organizations_members.py index c57ae0f1..34717967 100644 --- a/stytch/b2b/api/organizations_members.py +++ b/stytch/b2b/api/organizations_members.py @@ -659,7 +659,7 @@ def unlink_retired_email( Member's primary email address and the old primary email address is retired. A retired email address cannot be used by other Members in the same Organization. However, unlinking retired email - addresses allows then to be subsequently re-used by other Organization Members. Retired email addresses can be viewed + addresses allows them to be subsequently re-used by other Organization Members. Retired email addresses can be viewed on the [Member object](https://stytch.com/docs/b2b/api/member-object). %} @@ -707,7 +707,7 @@ async def unlink_retired_email_async( Member's primary email address and the old primary email address is retired. A retired email address cannot be used by other Members in the same Organization. However, unlinking retired email - addresses allows then to be subsequently re-used by other Organization Members. Retired email addresses can be viewed + addresses allows them to be subsequently re-used by other Organization Members. Retired email addresses can be viewed on the [Member object](https://stytch.com/docs/b2b/api/member-object). %} diff --git a/stytch/b2b/api/passwords_email.py b/stytch/b2b/api/passwords_email.py index 04b3daf2..da7357bf 100644 --- a/stytch/b2b/api/passwords_email.py +++ b/stytch/b2b/api/passwords_email.py @@ -73,9 +73,9 @@ def reset_start( if reset_password_redirect_url is not None: data["reset_password_redirect_url"] = reset_password_redirect_url if reset_password_expiration_minutes is not None: - data[ - "reset_password_expiration_minutes" - ] = reset_password_expiration_minutes + data["reset_password_expiration_minutes"] = ( + reset_password_expiration_minutes + ) if code_challenge is not None: data["code_challenge"] = code_challenge if login_redirect_url is not None: @@ -136,9 +136,9 @@ async def reset_start_async( if reset_password_redirect_url is not None: data["reset_password_redirect_url"] = reset_password_redirect_url if reset_password_expiration_minutes is not None: - data[ - "reset_password_expiration_minutes" - ] = reset_password_expiration_minutes + data["reset_password_expiration_minutes"] = ( + reset_password_expiration_minutes + ) if code_challenge is not None: data["code_challenge"] = code_challenge if login_redirect_url is not None: diff --git a/stytch/b2b/api/scim_connection.py b/stytch/b2b/api/scim_connection.py index 70615ea2..fa112bb3 100644 --- a/stytch/b2b/api/scim_connection.py +++ b/stytch/b2b/api/scim_connection.py @@ -59,7 +59,7 @@ def update( - connection_id: The ID of the SCIM connection. - display_name: A human-readable display name for the connection. - identity_provider: (no documentation yet) - - scim_group_implicit_role_assignments: (no documentation yet) + - scim_group_implicit_role_assignments: An array of SCIM group implicit role assignments. Each object in the array must contain a `group` and a `role_id`. """ # noqa headers: Dict[str, str] = {} if method_options is not None: @@ -102,7 +102,7 @@ async def update_async( - connection_id: The ID of the SCIM connection. - display_name: A human-readable display name for the connection. - identity_provider: (no documentation yet) - - scim_group_implicit_role_assignments: (no documentation yet) + - scim_group_implicit_role_assignments: An array of SCIM group implicit role assignments. Each object in the array must contain a `group` and a `role_id`. """ # noqa headers: Dict[str, str] = {} if method_options is not None: diff --git a/stytch/b2b/models/organizations.py b/stytch/b2b/models/organizations.py index b35ee557..e61c657f 100644 --- a/stytch/b2b/models/organizations.py +++ b/stytch/b2b/models/organizations.py @@ -276,6 +276,12 @@ class ResultsMetadata(pydantic.BaseModel): class RetiredEmail(pydantic.BaseModel): + """ + Fields: + - email_id: The globally unique UUID of a Member's email. + - email_address: The email address of the Member. + """ # noqa + email_id: str email_address: str @@ -328,7 +334,17 @@ class Member(pydantic.BaseModel): who create an Organization through the [discovery flow](https://stytch.com/docs/b2b/api/create-organization-via-discovery). See the [RBAC guide](https://stytch.com/docs/b2b/guides/rbac/stytch-default) for more details on this Role. - totp_registration_id: (no documentation yet) - - retired_email_addresses: (no documentation yet) + - retired_email_addresses: + A list of retired email addresses for this member. + A previously active email address can be marked as retired in one of two ways: + - It's replaced with a new primary email address during an explicit Member update. + - A new email address is surfaced by an OAuth, SAML or OIDC provider. In this case the new email address becomes the + Member's primary email address and the old primary email address is retired. + + A retired email address cannot be used by other Members in the same Organization. However, unlinking retired email + addresses allows them to be subsequently re-used by other Organization Members. Retired email addresses can be unlinked + using the [Unlink Retired Email endpoint](https://stytch.com/docs/b2b/api/unlink-retired-member-email). + - mfa_enrolled: Sets whether the Member is enrolled in MFA. If true, the Member must complete an MFA step whenever they wish to log in to their Organization. If false, the Member only needs to complete an MFA step if the Organization's MFA policy is set to `REQUIRED_FOR_ALL`. - mfa_phone_number: The Member's phone number. A Member may only have one phone number. - default_mfa_method: (no documentation yet) diff --git a/stytch/b2b/models/scim.py b/stytch/b2b/models/scim.py index e654cea9..c3addc35 100644 --- a/stytch/b2b/models/scim.py +++ b/stytch/b2b/models/scim.py @@ -101,6 +101,13 @@ class SCIMGroup(pydantic.BaseModel): class SCIMGroupImplicitRoleAssignments(pydantic.BaseModel): + """ + Fields: + - role_id: The ID of the role. + - group_id: (no documentation yet) + - group_name: (no documentation yet) + """ # noqa + role_id: str group_id: str group_name: str diff --git a/stytch/consumer/api/crypto_wallets.py b/stytch/consumer/api/crypto_wallets.py index 1cc09798..592d4d55 100644 --- a/stytch/consumer/api/crypto_wallets.py +++ b/stytch/consumer/api/crypto_wallets.py @@ -6,11 +6,12 @@ from __future__ import annotations -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Union from stytch.consumer.models.crypto_wallets import ( AuthenticateResponse, AuthenticateStartResponse, + SIWEParams, ) from stytch.core.api_base import ApiBase from stytch.core.http.client import AsyncClient, SyncClient @@ -31,8 +32,14 @@ def authenticate_start( user_id: Optional[str] = None, session_token: Optional[str] = None, session_jwt: Optional[str] = None, + siwe_params: Optional[Union[SIWEParams, Dict[str, Any]]] = None, ) -> AuthenticateStartResponse: - """Initiate the authentication of a crypto wallet. After calling this endpoint, the user will need to sign a message containing only the returned `challenge` field. + """Initiate the authentication of a crypto wallet. After calling this endpoint, the user will need to sign a message containing the returned `challenge` field. + + For Ethereum crypto wallets, you can optionally use the Sign In With Ethereum (SIWE) protocol for the message by passing in the `siwe_params`. The only required fields are `domain` and `uri`. + If the crypto wallet detects that the domain in the message does not match the website's domain, it will display a warning to the user. + + If not using the SIWE protocol, the message will simply consist of the project name and a random string. Fields: - crypto_wallet_type: The type of wallet to authenticate. Currently `ethereum` and `solana` are supported. Wallets for any EVM-compatible chains (such as Polygon or BSC) are also supported and are grouped under the `ethereum` type. @@ -40,6 +47,7 @@ def authenticate_start( - user_id: The unique ID of a specific User. - session_token: The `session_token` associated with a User's existing Session. - session_jwt: The `session_jwt` associated with a User's existing Session. + - siwe_params: The parameters for a Sign In With Ethereum (SIWE) message. May only be passed if the `crypto_wallet_type` is `ethereum`. """ # noqa headers: Dict[str, str] = {} data: Dict[str, Any] = { @@ -52,6 +60,10 @@ def authenticate_start( data["session_token"] = session_token if session_jwt is not None: data["session_jwt"] = session_jwt + if siwe_params is not None: + data["siwe_params"] = ( + siwe_params if isinstance(siwe_params, dict) else siwe_params.dict() + ) url = self.api_base.url_for("/v1/crypto_wallets/authenticate/start", data) res = self.sync_client.post(url, data, headers) @@ -64,8 +76,14 @@ async def authenticate_start_async( user_id: Optional[str] = None, session_token: Optional[str] = None, session_jwt: Optional[str] = None, + siwe_params: Optional[SIWEParams] = None, ) -> AuthenticateStartResponse: - """Initiate the authentication of a crypto wallet. After calling this endpoint, the user will need to sign a message containing only the returned `challenge` field. + """Initiate the authentication of a crypto wallet. After calling this endpoint, the user will need to sign a message containing the returned `challenge` field. + + For Ethereum crypto wallets, you can optionally use the Sign In With Ethereum (SIWE) protocol for the message by passing in the `siwe_params`. The only required fields are `domain` and `uri`. + If the crypto wallet detects that the domain in the message does not match the website's domain, it will display a warning to the user. + + If not using the SIWE protocol, the message will simply consist of the project name and a random string. Fields: - crypto_wallet_type: The type of wallet to authenticate. Currently `ethereum` and `solana` are supported. Wallets for any EVM-compatible chains (such as Polygon or BSC) are also supported and are grouped under the `ethereum` type. @@ -73,6 +91,7 @@ async def authenticate_start_async( - user_id: The unique ID of a specific User. - session_token: The `session_token` associated with a User's existing Session. - session_jwt: The `session_jwt` associated with a User's existing Session. + - siwe_params: The parameters for a Sign In With Ethereum (SIWE) message. May only be passed if the `crypto_wallet_type` is `ethereum`. """ # noqa headers: Dict[str, str] = {} data: Dict[str, Any] = { @@ -85,6 +104,10 @@ async def authenticate_start_async( data["session_token"] = session_token if session_jwt is not None: data["session_jwt"] = session_jwt + if siwe_params is not None: + data["siwe_params"] = ( + siwe_params if isinstance(siwe_params, dict) else siwe_params.dict() + ) url = self.api_base.url_for("/v1/crypto_wallets/authenticate/start", data) res = await self.async_client.post(url, data, headers) diff --git a/stytch/consumer/api/passwords_email.py b/stytch/consumer/api/passwords_email.py index b0414254..b00f378d 100644 --- a/stytch/consumer/api/passwords_email.py +++ b/stytch/consumer/api/passwords_email.py @@ -70,9 +70,9 @@ def reset_start( if reset_password_redirect_url is not None: data["reset_password_redirect_url"] = reset_password_redirect_url if reset_password_expiration_minutes is not None: - data[ - "reset_password_expiration_minutes" - ] = reset_password_expiration_minutes + data["reset_password_expiration_minutes"] = ( + reset_password_expiration_minutes + ) if code_challenge is not None: data["code_challenge"] = code_challenge if attributes is not None: @@ -133,9 +133,9 @@ async def reset_start_async( if reset_password_redirect_url is not None: data["reset_password_redirect_url"] = reset_password_redirect_url if reset_password_expiration_minutes is not None: - data[ - "reset_password_expiration_minutes" - ] = reset_password_expiration_minutes + data["reset_password_expiration_minutes"] = ( + reset_password_expiration_minutes + ) if code_challenge is not None: data["code_challenge"] = code_challenge if attributes is not None: diff --git a/stytch/consumer/api/sessions.py b/stytch/consumer/api/sessions.py index c652b440..75b707d3 100644 --- a/stytch/consumer/api/sessions.py +++ b/stytch/consumer/api/sessions.py @@ -201,6 +201,23 @@ def migrate( session_duration_minutes: Optional[int] = None, session_custom_claims: Optional[Dict[str, Any]] = None, ) -> MigrateResponse: + """Migrate a session from an external OIDC compliant endpoint. Stytch will call the external UserInfo endpoint defined in your Stytch Project settings in the [Dashboard](/dashboard), and then perform a lookup using the `session_token`. If the response contains a valid email address, Stytch will attempt to match that email address with an existing User and create a Stytch Session. You will need to create the user before using this endpoint. + + Fields: + - session_token: The `session_token` associated with a User's existing Session. + - session_duration_minutes: Set the session lifetime to be this many minutes from now. This will start a new session if one doesn't already exist, + returning both an opaque `session_token` and `session_jwt` for this session. Remember that the `session_jwt` will have a fixed lifetime of + five minutes regardless of the underlying session duration, and will need to be refreshed over time. + + This value must be a minimum of 5 and a maximum of 527040 minutes (366 days). + + If a `session_token` or `session_jwt` is provided then a successful authentication will continue to extend the session this many minutes. + + If the `session_duration_minutes` parameter is not specified, a Stytch session will not be created. + - session_custom_claims: Add a custom claims map to the Session being authenticated. Claims are only created if a Session is initialized by providing a value in `session_duration_minutes`. Claims will be included on the Session object and in the JWT. To update a key in an existing Session, supply a new value. To delete a key, supply a null value. + + Custom claims made with reserved claims ("iss", "sub", "aud", "exp", "nbf", "iat", "jti") will be ignored. Total custom claims size cannot exceed four kilobytes. + """ # noqa headers: Dict[str, str] = {} data: Dict[str, Any] = { "session_token": session_token, @@ -220,6 +237,23 @@ async def migrate_async( session_duration_minutes: Optional[int] = None, session_custom_claims: Optional[Dict[str, Any]] = None, ) -> MigrateResponse: + """Migrate a session from an external OIDC compliant endpoint. Stytch will call the external UserInfo endpoint defined in your Stytch Project settings in the [Dashboard](/dashboard), and then perform a lookup using the `session_token`. If the response contains a valid email address, Stytch will attempt to match that email address with an existing User and create a Stytch Session. You will need to create the user before using this endpoint. + + Fields: + - session_token: The `session_token` associated with a User's existing Session. + - session_duration_minutes: Set the session lifetime to be this many minutes from now. This will start a new session if one doesn't already exist, + returning both an opaque `session_token` and `session_jwt` for this session. Remember that the `session_jwt` will have a fixed lifetime of + five minutes regardless of the underlying session duration, and will need to be refreshed over time. + + This value must be a minimum of 5 and a maximum of 527040 minutes (366 days). + + If a `session_token` or `session_jwt` is provided then a successful authentication will continue to extend the session this many minutes. + + If the `session_duration_minutes` parameter is not specified, a Stytch session will not be created. + - session_custom_claims: Add a custom claims map to the Session being authenticated. Claims are only created if a Session is initialized by providing a value in `session_duration_minutes`. Claims will be included on the Session object and in the JWT. To update a key in an existing Session, supply a new value. To delete a key, supply a null value. + + Custom claims made with reserved claims ("iss", "sub", "aud", "exp", "nbf", "iat", "jti") will be ignored. Total custom claims size cannot exceed four kilobytes. + """ # noqa headers: Dict[str, str] = {} data: Dict[str, Any] = { "session_token": session_token, diff --git a/stytch/consumer/api/webauthn.py b/stytch/consumer/api/webauthn.py index ef911c5f..15db1ff1 100644 --- a/stytch/consumer/api/webauthn.py +++ b/stytch/consumer/api/webauthn.py @@ -61,9 +61,9 @@ def register_start( if authenticator_type is not None: data["authenticator_type"] = authenticator_type if return_passkey_credential_options is not None: - data[ - "return_passkey_credential_options" - ] = return_passkey_credential_options + data["return_passkey_credential_options"] = ( + return_passkey_credential_options + ) url = self.api_base.url_for("/v1/webauthn/register/start", data) res = self.sync_client.post(url, data, headers) @@ -103,9 +103,9 @@ async def register_start_async( if authenticator_type is not None: data["authenticator_type"] = authenticator_type if return_passkey_credential_options is not None: - data[ - "return_passkey_credential_options" - ] = return_passkey_credential_options + data["return_passkey_credential_options"] = ( + return_passkey_credential_options + ) url = self.api_base.url_for("/v1/webauthn/register/start", data) res = await self.async_client.post(url, data, headers) @@ -236,9 +236,9 @@ def authenticate_start( if user_id is not None: data["user_id"] = user_id if return_passkey_credential_options is not None: - data[ - "return_passkey_credential_options" - ] = return_passkey_credential_options + data["return_passkey_credential_options"] = ( + return_passkey_credential_options + ) url = self.api_base.url_for("/v1/webauthn/authenticate/start", data) res = self.sync_client.post(url, data, headers) @@ -271,9 +271,9 @@ async def authenticate_start_async( if user_id is not None: data["user_id"] = user_id if return_passkey_credential_options is not None: - data[ - "return_passkey_credential_options" - ] = return_passkey_credential_options + data["return_passkey_credential_options"] = ( + return_passkey_credential_options + ) url = self.api_base.url_for("/v1/webauthn/authenticate/start", data) res = await self.async_client.post(url, data, headers) diff --git a/stytch/consumer/models/crypto_wallets.py b/stytch/consumer/models/crypto_wallets.py index d71b1550..d38a5856 100644 --- a/stytch/consumer/models/crypto_wallets.py +++ b/stytch/consumer/models/crypto_wallets.py @@ -6,13 +6,71 @@ from __future__ import annotations -from typing import Optional +import datetime +from typing import List, Optional + +import pydantic from stytch.consumer.models.sessions import Session from stytch.consumer.models.users import User from stytch.core.response_base import ResponseBase +class SIWEParams(pydantic.BaseModel): + """ + Fields: + - domain: Only required if `siwe_params` is passed. The domain that is requesting the crypto wallet signature. Must be an RFC 3986 authority. + - uri: Only required if `siwe_params` is passed. An RFC 3986 URI referring to the resource that is the subject of the signing. + - resources: A list of information or references to information the user wishes to have resolved as part of authentication. Every resource must be an RFC 3986 URI. + - chain_id: The EIP-155 Chain ID to which the session is bound. Defaults to 1. + - statement: A human-readable ASCII assertion that the user will sign. + - issued_at: The time when the message was generated. Defaults to the current time. All timestamps in our API conform to the RFC 3339 standard and are expressed in UTC, e.g. `2021-12-29T12:33:09Z`. + - not_before: The time when the signed authentication message will become valid. Defaults to the current time. All timestamps in our API conform to the RFC 3339 standard and are expressed in UTC, e.g. `2021-12-29T12:33:09Z`. + - message_request_id: A system-specific identifier that may be used to uniquely refer to the sign-in request. + """ # noqa + + domain: str + uri: str + resources: List[str] + chain_id: Optional[int] = None + statement: Optional[str] = None + issued_at: Optional[datetime.datetime] = None + not_before: Optional[datetime.datetime] = None + message_request_id: Optional[str] = None + + +class AuthenticateStartResponse(ResponseBase): + """Response type for `CryptoWallets.authenticate_start`. + Fields: + - user_id: The unique ID of the affected User. + - challenge: A challenge string to be signed by the wallet in order to prove ownership. + - user_created: In `login_or_create` endpoints, this field indicates whether or not a User was just created. + """ # noqa + + user_id: str + challenge: str + user_created: bool + + +class SIWEParamsResponse(ResponseBase): + """ + Fields: + - domain: The domain that requested the crypto wallet signature. + - uri: An RFC 3986 URI referring to the resource that is the subject of the signing. + - chain_id: The EIP-155 Chain ID to which the session is bound. + - resources: A list of information or references to information the user wishes to have resolved as part of authentication. Every resource must be an RFC 3986 URI. + - issued_at: The time when the message was generated. All timestamps in our API conform to the RFC 3339 standard and are expressed in UTC, e.g. `2021-12-29T12:33:09Z`. + - message_request_id: A system-specific identifier that may be used to uniquely refer to the sign-in request. + """ # noqa + + domain: str + uri: str + chain_id: int + resources: List[str] + issued_at: Optional[datetime.datetime] = None + message_request_id: Optional[str] = None + + class AuthenticateResponse(ResponseBase): """Response type for `CryptoWallets.authenticate`. Fields: @@ -24,6 +82,7 @@ class AuthenticateResponse(ResponseBase): See [GET sessions](https://stytch.com/docs/api/session-get) for complete response fields. + - siwe_params: The parameters of the Sign In With Ethereum (SIWE) message that was signed. """ # noqa user_id: str @@ -31,16 +90,4 @@ class AuthenticateResponse(ResponseBase): session_jwt: str user: User session: Optional[Session] = None - - -class AuthenticateStartResponse(ResponseBase): - """Response type for `CryptoWallets.authenticate_start`. - Fields: - - user_id: The unique ID of the affected User. - - challenge: A challenge string to be signed by the wallet in order to prove ownership. - - user_created: In `login_or_create` endpoints, this field indicates whether or not a User was just created. - """ # noqa - - user_id: str - challenge: str - user_created: bool + siwe_params: Optional[SIWEParamsResponse] = None diff --git a/stytch/consumer/models/m2m.py b/stytch/consumer/models/m2m.py index 5e220847..16a6288b 100644 --- a/stytch/consumer/models/m2m.py +++ b/stytch/consumer/models/m2m.py @@ -135,6 +135,7 @@ class GetTokenResponse(ResponseBase): # ENDMANUAL(GetTokenResponse) + # MANUAL(M2MJWTClaims)(TYPES) # ADDIMPORT: from typing import Any, Dict, List, Optional class M2MJWTClaims(pydantic.BaseModel): diff --git a/stytch/consumer/models/sessions.py b/stytch/consumer/models/sessions.py index cecd64a4..2af8d8ed 100644 --- a/stytch/consumer/models/sessions.py +++ b/stytch/consumer/models/sessions.py @@ -493,6 +493,18 @@ class GetResponse(ResponseBase): class MigrateResponse(ResponseBase): + """Response type for `Sessions.migrate`. + Fields: + - user_id: The unique ID of the affected User. + - session_token: A secret token for a given Stytch Session. + - session_jwt: The JSON Web Token (JWT) for a given Stytch Session. + - user: The `user` object affected by this API call. See the [Get user endpoint](https://stytch.com/docs/api/get-user) for complete response field details. + - session: If you initiate a Session, by including `session_duration_minutes` in your authenticate call, you'll receive a full Session object in the response. + + See [GET sessions](https://stytch.com/docs/api/session-get) for complete response fields. + + """ # noqa + user_id: str session_token: str session_jwt: str diff --git a/stytch/core/response_base.py b/stytch/core/response_base.py index f3cb0ea0..ad90c40d 100644 --- a/stytch/core/response_base.py +++ b/stytch/core/response_base.py @@ -7,8 +7,7 @@ import pydantic -class ResponseError(ValueError): - ... +class ResponseError(ValueError): ... class ResponseBase(pydantic.BaseModel): diff --git a/stytch/version.py b/stytch/version.py index f9e29a7c..10db32a3 100644 --- a/stytch/version.py +++ b/stytch/version.py @@ -1 +1 @@ -__version__ = "11.1.0" +__version__ = "11.2.0"