diff --git a/.rubocop.yml b/.rubocop.yml index 0ba5b41..e15ddcd 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -7,6 +7,7 @@ AllCops: TargetRubyVersion: 2.7 Layout/LineLength: { Enabled: false } +LeadingCommentSpace: { Enabled: false } Metrics: { Enabled: false } diff --git a/lib/stytch/b2b_client.rb b/lib/stytch/b2b_client.rb index 721941e..1e8c4bf 100644 --- a/lib/stytch/b2b_client.rb +++ b/lib/stytch/b2b_client.rb @@ -26,6 +26,7 @@ def initialize(project_id:, secret:, env: nil, &block) @api_host = api_host(env, project_id) @project_id = project_id @secret = secret + @is_b2b_client = true create_connection(&block) @@ -33,7 +34,7 @@ def initialize(project_id:, secret:, env: nil, &block) @policy_cache = StytchB2B::PolicyCache.new(rbac_client: rbac) @discovery = StytchB2B::Discovery.new(@connection) - @m2m = Stytch::M2M.new(@connection, @project_id) + @m2m = Stytch::M2M.new(@connection, @project_id, @is_b2b_client) @magic_links = StytchB2B::MagicLinks.new(@connection) @oauth = StytchB2B::OAuth.new(@connection) @otps = StytchB2B::OTPs.new(@connection) diff --git a/lib/stytch/b2b_magic_links.rb b/lib/stytch/b2b_magic_links.rb index fcc6bf3..1a6b21d 100644 --- a/lib/stytch/b2b_magic_links.rb +++ b/lib/stytch/b2b_magic_links.rb @@ -268,7 +268,6 @@ def login_or_signup( # Send an invite email to a new Member to join an Organization. The Member will be created with an `invited` status until they successfully authenticate. Sending invites to `pending` Members will update their status to `invited`. Sending invites to already `active` Members will return an error. # # The magic link invite will be valid for 1 week. - # /%} # # == Parameters: # organization_id:: diff --git a/lib/stytch/b2b_organizations.rb b/lib/stytch/b2b_organizations.rb index 53ed1df..d739c35 100644 --- a/lib/stytch/b2b_organizations.rb +++ b/lib/stytch/b2b_organizations.rb @@ -230,18 +230,6 @@ def get( # # *See the [Organization authentication settings](https://stytch.com/docs/b2b/api/org-auth-settings) resource to learn more about fields like `email_jit_provisioning`, `email_invites`, `sso_jit_provisioning`, etc., and their behaviors. # - # Our RBAC implementation offers out-of-the-box handling of authorization checks for this endpoint. If you pass in - # a header containing a `session_token` or a `session_jwt` for an unexpired Member Session, we will check that the - # Member Session has the necessary permissions. The specific permissions needed depend on which of the optional fields - # are passed in the request. For example, if the `organization_name` argument is provided, the Member Session must have - # permission to perform the `update.info.name` action on the `stytch.organization` Resource. - # - # If the Member Session does not contain a Role that satisfies the requested permissions, or if the Member's Organization - # does not match the `organization_id` passed in the request, a 403 error will be thrown. Otherwise, the request will - # proceed as normal. - # - # To learn more about our RBAC implementation, see our [RBAC guide](https://stytch.com/docs/b2b/guides/rbac/overview). - # # == Parameters: # organization_id:: # Globally unique UUID that identifies a specific Organization. The `organization_id` is critical to perform operations on an Organization, so be sure to preserve this value. @@ -429,7 +417,7 @@ def update( put_request("/v1/b2b/organizations/#{organization_id}", request, headers) end - # Deletes an Organization specified by `organization_id`. All Members of the Organization will also be deleted. /%} + # Deletes an Organization specified by `organization_id`. All Members of the Organization will also be deleted. # # == Parameters: # organization_id:: @@ -673,18 +661,6 @@ def initialize(connection) # Updates a Member specified by `organization_id` and `member_id`. # - # Our RBAC implementation offers out-of-the-box handling of authorization checks for this endpoint. If you pass in - # a header containing a `session_token` or a `session_jwt` for an unexpired Member Session, we will check that the - # Member Session has the necessary permissions. The specific permissions needed depend on which of the optional fields - # are passed in the request. For example, if the `organization_name` argument is provided, the Member Session must have - # permission to perform the `update.info.name` action on the `stytch.organization` Resource. - # - # If the Member Session does not contain a Role that satisfies the requested permissions, or if the Member's Organization - # does not match the `organization_id` passed in the request, a 403 error will be thrown. Otherwise, the request will - # proceed as normal. - # - # To learn more about our RBAC implementation, see our [RBAC guide](https://stytch.com/docs/b2b/guides/rbac/overview). - # # == Parameters: # organization_id:: # Globally unique UUID that identifies a specific Organization. The `organization_id` is critical to perform operations on an Organization, so be sure to preserve this value. @@ -806,7 +782,7 @@ def update( put_request("/v1/b2b/organizations/#{organization_id}/members/#{member_id}", request, headers) end - # Deletes a Member specified by `organization_id` and `member_id`. /%} + # Deletes a Member specified by `organization_id` and `member_id`. # # == Parameters: # organization_id:: @@ -840,7 +816,7 @@ def delete( delete_request("/v1/b2b/organizations/#{organization_id}/members/#{member_id}", headers) end - # Reactivates a deleted Member's status and its associated email status (if applicable) to active, specified by `organization_id` and `member_id`. /%} + # Reactivates a deleted Member's status and its associated email status (if applicable) to active, specified by `organization_id` and `member_id`. # # == Parameters: # organization_id:: @@ -889,7 +865,6 @@ def reactivate( # Existing Member Sessions that include a phone number authentication factor will not be revoked if the phone number is deleted, and MFA will not be enforced until the Member logs in again. # If you wish to enforce MFA immediately after a phone number is deleted, you can do so by prompting the Member to enter a new phone number # and calling the [OTP SMS send](https://stytch.com/docs/b2b/api/otp-sms-send) endpoint, then calling the [OTP SMS Authenticate](https://stytch.com/docs/b2b/api/authenticate-otp-sms) endpoint. - # /%} # # == Parameters: # organization_id:: @@ -934,7 +909,6 @@ def delete_mfa_phone_number( # To mint a new registration for a Member, you must first call this endpoint to delete the existing registration. # # Existing Member Sessions that include the TOTP authentication factor will not be revoked if the registration is deleted, and MFA will not be enforced until the Member logs in again. - # /%} # # == Parameters: # organization_id:: @@ -978,18 +952,6 @@ def delete_totp( # # *All fuzzy search filters require a minimum of three characters. # - # Our RBAC implementation offers out-of-the-box handling of authorization checks for this endpoint. If you pass in - # a header containing a `session_token` or a `session_jwt` for an unexpired Member Session, we will check that the - # Member Session has permission to perform the `search` action on the `stytch.member` Resource. In addition, enforcing - # RBAC on this endpoint means that you may only search for Members within the calling Member's Organization, so the - # `organization_ids` argument may only contain the `organization_id` of the Member Session passed in the header. - # - # If the Member Session does not contain a Role that satisfies the requested permission, or if the `organization_ids` - # argument contains an `organization_id` that the Member Session does not belong to, a 403 error will be thrown. - # Otherwise, the request will proceed as normal. - # - # To learn more about our RBAC implementation, see our [RBAC guide](https://stytch.com/docs/b2b/guides/rbac/overview). - # # == Parameters: # organization_ids:: # An array of organization_ids. At least one value is required. @@ -1043,7 +1005,7 @@ def search( post_request('/v1/b2b/organizations/members/search', request, headers) end - # Delete a Member's password. /%} + # Delete a Member's password. # # == Parameters: # organization_id:: @@ -1116,7 +1078,7 @@ def dangerously_get( get_request(request, headers) end - # Creates a Member. An `organization_id` and `email_address` are required. /%} + # Creates a Member. An `organization_id` and `email_address` are required. # # == Parameters: # organization_id:: diff --git a/lib/stytch/b2b_passwords.rb b/lib/stytch/b2b_passwords.rb index 558a377..e7ea1a3 100644 --- a/lib/stytch/b2b_passwords.rb +++ b/lib/stytch/b2b_passwords.rb @@ -34,7 +34,7 @@ def initialize(connection) # # == Parameters: # password:: - # The password to authenticate. + # The password to authenticate, reset, or set for the first time. Any UTF8 character is allowed, e.g. spaces, emojis, non-English characers, etc. # The type of this field is +String+. # email_address:: # The email address of the Member. @@ -89,6 +89,8 @@ def strength_check( # Adds an existing password to a member's email that doesn't have a password yet. We support migrating members from passwords stored with bcrypt, scrypt, argon2, MD-5, SHA-1, and PBKDF2. This endpoint has a rate limit of 100 requests per second. # + # The member's email will be marked as verified when you use this endpoint. + # # == Parameters: # email_address:: # The email address of the Member. @@ -219,7 +221,7 @@ def migrate( # The email address of the Member. # The type of this field is +String+. # password:: - # The password to authenticate. + # The password to authenticate, reset, or set for the first time. Any UTF8 character is allowed, e.g. spaces, emojis, non-English characers, etc. # The type of this field is +String+. # session_token:: # A secret token for a given Stytch Session. @@ -427,12 +429,14 @@ def reset_start( # # If a valid `session_token` or `session_jwt` is passed in, the Member will not be required to complete an MFA step. # + # Note that a successful password reset by email will revoke all active sessions for the `member_id`. + # # == Parameters: # password_reset_token:: # The password reset token to authenticate. # The type of this field is +String+. # password:: - # The password to reset. + # The password to authenticate, reset, or set for the first time. Any UTF8 character is allowed, e.g. spaces, emojis, non-English characers, etc. # The type of this field is +String+. # session_token:: # Reuse an existing session instead of creating a new one. If you provide a `session_token`, Stytch will update the session. @@ -557,12 +561,14 @@ def initialize(connection) # Reset the Member's password using their existing session. The endpoint will error if the session does not contain an authentication factor that has been issued within the last 5 minutes. Either `session_token` or `session_jwt` should be provided. # + # Note that a successful password reset via an existing session will revoke all active sessions for the `member_id`, except for the one used during the reset flow. + # # == Parameters: # organization_id:: # Globally unique UUID that identifies a specific Organization. The `organization_id` is critical to perform operations on an Organization, so be sure to preserve this value. # The type of this field is +String+. # password:: - # The password to authenticate. + # The password to authenticate, reset, or set for the first time. Any UTF8 character is allowed, e.g. spaces, emojis, non-English characers, etc. # The type of this field is +String+. # session_token:: # A secret token for a given Stytch Session. @@ -677,15 +683,17 @@ def initialize(connection) # # If a valid `session_token` or `session_jwt` is passed in, the Member will not be required to complete an MFA step. # + # Note that a successful password reset via an existing password will revoke all active sessions for the `member_id`. + # # == Parameters: # email_address:: # The email address of the Member. # The type of this field is +String+. # existing_password:: - # The member's current password that they supplied. + # The Member's current password that they supplied. # The type of this field is +String+. # new_password:: - # The member's elected new password. + # The Member's elected new password. # The type of this field is +String+. # organization_id:: # Globally unique UUID that identifies a specific Organization. The `organization_id` is critical to perform operations on an Organization, so be sure to preserve this value. diff --git a/lib/stytch/b2b_scim.rb b/lib/stytch/b2b_scim.rb index a0b9c44..5115be8 100644 --- a/lib/stytch/b2b_scim.rb +++ b/lib/stytch/b2b_scim.rb @@ -115,6 +115,25 @@ def to_headers end end + class GetGroupsRequestOptions + # Optional authorization object. + # Pass in an active Stytch Member session token or session JWT and the request + # will be run using that member's permissions. + attr_accessor :authorization + + def initialize( + authorization: nil + ) + @authorization = authorization + end + + def to_headers + headers = {} + headers.merge!(@authorization.to_headers) if authorization + headers + end + end + class CreateRequestOptions # Optional authorization object. # Pass in an active Stytch Member session token or session JWT and the request @@ -159,7 +178,7 @@ def initialize(connection) @connection = connection end - # Update a SCIM Connection. /%} + # Update a SCIM Connection. # # == Parameters: # organization_id:: @@ -210,7 +229,7 @@ def update( put_request("/v1/b2b/scim/#{organization_id}/connection/#{connection_id}", request, headers) end - # Deletes a SCIM Connection. /%} + # Deletes a SCIM Connection. # # == Parameters: # organization_id:: @@ -244,7 +263,7 @@ def delete( delete_request("/v1/b2b/scim/#{organization_id}/connection/#{connection_id}", headers) end - # Start a SCIM token rotation. /%} + # Start a SCIM token rotation. # # == Parameters: # organization_id:: @@ -280,7 +299,7 @@ def rotate_start( post_request("/v1/b2b/scim/#{organization_id}/connection/#{connection_id}/rotate/start", request, headers) end - # Completes a SCIM token rotation. This will complete the current token rotation process and update the active token to be the new token supplied in the [start SCIM token rotation](https://stytch.com/docs/b2b/api/scim-rotate-token-start) response. /%} + # Completes a SCIM token rotation. This will complete the current token rotation process and update the active token to be the new token supplied in the [start SCIM token rotation](https://stytch.com/docs/b2b/api/scim-rotate-token-start) response. # # == Parameters: # organization_id:: @@ -316,7 +335,7 @@ def rotate_complete( post_request("/v1/b2b/scim/#{organization_id}/connection/#{connection_id}/rotate/complete", request, headers) end - # Cancel a SCIM token rotation. This will cancel the current token rotation process, keeping the original token active. /%} + # Cancel a SCIM token rotation. This will cancel the current token rotation process, keeping the original token active. # # == Parameters: # organization_id:: @@ -352,7 +371,54 @@ def rotate_cancel( post_request("/v1/b2b/scim/#{organization_id}/connection/#{connection_id}/rotate/cancel", request, headers) end - # Create a new SCIM Connection. /%} + # Gets a paginated list of all SCIM Groups associated with a given Connection. + # + # == Parameters: + # organization_id:: + # Globally unique UUID that identifies a specific Organization. The `organization_id` is critical to perform operations on an Organization, so be sure to preserve this value. + # The type of this field is +String+. + # connection_id:: + # The ID of the SCIM connection. + # The type of this field is +String+. + # cursor:: + # The `cursor` field allows you to paginate through your results. Each result array is limited to 1000 results. If your query returns more than 1000 results, you will need to paginate the responses using the `cursor`. If you receive a response that includes a non-null `next_cursor` in the `results_metadata` object, repeat the search call with the `next_cursor` value set to the `cursor` field to retrieve the next page of results. Continue to make search calls until the `next_cursor` in the response is null. + # The type of this field is nilable +String+. + # limit:: + # The number of search results to return per page. The default limit is 100. A maximum of 1000 results can be returned by a single search request. If the total size of your result set is greater than one page size, you must paginate the response. See the `cursor` field. + # The type of this field is nilable +Integer+. + # + # == Returns: + # An object with the following fields: + # scim_groups:: + # A list of SCIM Connection Groups belonging to the connection. + # The type of this field is list of +SCIMGroup+ (+object+). + # status_code:: + # (no documentation yet) + # The type of this field is +Integer+. + # next_cursor:: + # The `next_cursor` string is returned when your search result contains more than one page of results. This value is passed into your next search call in the `cursor` field. + # The type of this field is nilable +String+. + # + # == Method Options: + # This method supports an optional +StytchB2B::SCIM::Connection::GetGroupsRequestOptions+ object which will modify the headers sent in the HTTP request. + def get_groups( + organization_id:, + connection_id:, + cursor: nil, + limit: nil, + method_options: nil + ) + headers = {} + headers = headers.merge(method_options.to_headers) unless method_options.nil? + query_params = { + cursor: cursor, + limit: limit + } + request = request_with_query_params("/v1/b2b/scim/#{organization_id}/connection/#{connection_id}", query_params) + get_request(request, headers) + end + + # Create a new SCIM Connection. # # == Parameters: # organization_id:: @@ -394,7 +460,7 @@ def create( post_request("/v1/b2b/scim/#{organization_id}/connection", request, headers) end - # Get SCIM Connections. /%} + # Get SCIM Connections. # # == Parameters: # organization_id:: diff --git a/lib/stytch/b2b_sessions.rb b/lib/stytch/b2b_sessions.rb index 2f51562..dc8908e 100644 --- a/lib/stytch/b2b_sessions.rb +++ b/lib/stytch/b2b_sessions.rb @@ -13,6 +13,25 @@ module StytchB2B class Sessions + class RevokeRequestOptions + # Optional authorization object. + # Pass in an active Stytch Member session token or session JWT and the request + # will be run using that member's permissions. + attr_accessor :authorization + + def initialize( + authorization: nil + ) + @authorization = authorization + end + + def to_headers + headers = {} + headers.merge!(@authorization.to_headers) if authorization + headers + end + end + include Stytch::RequestHelper def initialize(connection, project_id, policy_cache) @@ -70,7 +89,7 @@ def get( # Authenticates a Session and updates its lifetime by the specified `session_duration_minutes`. If the `session_duration_minutes` is not specified, a Session will not be extended. This endpoint requires either a `session_jwt` or `session_token` be included in the request. It will return an error if both are present. # - # You may provide a JWT that needs to be refreshed and is expired according to its `exp` claim. A new JWT will be returned if both the signature and the underlying Session are still valid. See our [How to use Stytch Session JWTs](https://stytch.com/docs/b2b/guides/sessions/using-jwts) guide for more information. + # You may provide a JWT that needs to be refreshed and is expired according to its `exp` claim. A new JWT will be returned if both the signature and the underlying Session are still valid. See our [How to use Stytch Session JWTs](https://stytch.com/docs/b2b/guides/sessions/resources/using-jwts) guide for more information. # # If an `authorization_check` object is passed in, this method will also check if the Member is authorized to perform the given action on the given Resource in the specified Organization. A Member is authorized if their Member Session contains a Role, assigned [explicitly or implicitly](https://stytch.com/docs/b2b/guides/rbac/role-assignment), with adequate permissions. # In addition, the `organization_id` passed in the authorization check must match the Member's Organization. @@ -189,13 +208,18 @@ def authenticate( # status_code:: # The HTTP status code of the response. Stytch follows standard HTTP response status code patterns, e.g. 2XX values equate to success, 3XX values are redirects, 4XX are client errors, and 5XX are server errors. # The type of this field is +Integer+. + # + # == Method Options: + # This method supports an optional +StytchB2B::Sessions::RevokeRequestOptions+ object which will modify the headers sent in the HTTP request. def revoke( member_session_id: nil, session_token: nil, session_jwt: nil, - member_id: nil + member_id: nil, + method_options: nil ) headers = {} + headers = headers.merge(method_options.to_headers) unless method_options.nil? request = {} request[:member_session_id] = member_session_id unless member_session_id.nil? request[:session_token] = session_token unless session_token.nil? @@ -397,7 +421,7 @@ def migrate( # # If you're using your own JWT validation library, many have built-in support for JWKS rotation, and you'll just need to supply this API endpoint. If not, your application should decide which JWKS to use for validation by inspecting the `kid` value. # - # See our [How to use Stytch Session JWTs](https://stytch.com/docs/b2b/guides/sessions/using-jwts) guide for more information. + # See our [How to use Stytch Session JWTs](https://stytch.com/docs/b2b/guides/sessions/resources/using-jwts) guide for more information. # # == Parameters: # project_id:: @@ -436,14 +460,17 @@ def get_jwks( # Note that the 'user_id' field of the returned session is DEPRECATED: Use member_id instead # This field will be removed in a future MAJOR release. # If max_token_age_seconds is not supplied 300 seconds will be used as the default. + # If clock_tolerance_seconds is not supplied 0 seconds will be used as the default. def authenticate_jwt( session_jwt, max_token_age_seconds: nil, session_duration_minutes: nil, session_custom_claims: nil, - authorization_check: nil + authorization_check: nil, + clock_tolerance_seconds: nil ) max_token_age_seconds = 300 if max_token_age_seconds.nil? + clock_tolerance_seconds = 0 if clock_tolerance_seconds.nil? if max_token_age_seconds == 0 return authenticate( @@ -454,7 +481,7 @@ def authenticate_jwt( ) end - decoded_jwt = authenticate_jwt_local(session_jwt, max_token_age_seconds: max_token_age_seconds, authorization_check: authorization_check) + decoded_jwt = authenticate_jwt_local(session_jwt, max_token_age_seconds: max_token_age_seconds, authorization_check: authorization_check, clock_tolerance_seconds: clock_tolerance_seconds) return decoded_jwt unless decoded_jwt.nil? authenticate( @@ -478,13 +505,15 @@ def authenticate_jwt( # function to get the JWK # This method never authenticates a JWT directly with the API # If max_token_age_seconds is not supplied 300 seconds will be used as the default. - def authenticate_jwt_local(session_jwt, max_token_age_seconds: nil, authorization_check: nil) + # If clock_tolerance_seconds is not supplied 0 seconds will be used as the default. + def authenticate_jwt_local(session_jwt, max_token_age_seconds: nil, authorization_check: nil, clock_tolerance_seconds: nil) max_token_age_seconds = 300 if max_token_age_seconds.nil? + clock_tolerance_seconds = 0 if clock_tolerance_seconds.nil? issuer = 'stytch.com/' + @project_id begin decoded_token = JWT.decode session_jwt, nil, true, - { jwks: @jwks_loader, iss: issuer, verify_iss: true, aud: @project_id, verify_aud: true, algorithms: ['RS256'] } + { jwks: @jwks_loader, iss: issuer, verify_iss: true, aud: @project_id, verify_aud: true, algorithms: ['RS256'], nbf_leeway: clock_tolerance_seconds } session = decoded_token[0] iat_time = Time.at(session['iat']).to_datetime diff --git a/lib/stytch/b2b_sso.rb b/lib/stytch/b2b_sso.rb index 3a3fba6..ff52669 100644 --- a/lib/stytch/b2b_sso.rb +++ b/lib/stytch/b2b_sso.rb @@ -58,7 +58,7 @@ def initialize(connection) @saml = StytchB2B::SSO::SAML.new(@connection) end - # Get all SSO Connections owned by the organization. /%} + # Get all SSO Connections owned by the organization. # # == Parameters: # organization_id:: @@ -96,7 +96,7 @@ def get_connections( get_request(request, headers) end - # Delete an existing SSO connection. /%} + # Delete an existing SSO connection. # # == Parameters: # organization_id:: @@ -300,7 +300,7 @@ def initialize(connection) @connection = connection end - # Create a new OIDC Connection. /%} + # Create a new OIDC Connection. # # == Parameters: # organization_id:: @@ -360,7 +360,6 @@ def create_connection( # * `token_url` # * `userinfo_url` # * `jwks_url` - # /%} # # == Parameters: # organization_id:: @@ -528,7 +527,7 @@ def initialize(connection) @connection = connection end - # Create a new SAML Connection. /%} + # Create a new SAML Connection. # # == Parameters: # organization_id:: @@ -577,7 +576,6 @@ def create_connection( # * `attribute_mapping` # * `idp_entity_id` # * `x509_certificate` - # /%} # # == Parameters: # organization_id:: @@ -670,7 +668,6 @@ def update_connection( # * `idp_entity_id` # * `x509_certificate` # * `attribute_mapping` (must be supplied using [Update SAML Connection](update-saml-connection)) - # /%} # # == Parameters: # organization_id:: @@ -715,7 +712,6 @@ def update_by_url( # Delete a SAML verification certificate. # # You may need to do this when rotating certificates from your IdP, since Stytch allows a maximum of 5 certificates per connection. There must always be at least one certificate per active connection. - # /%} # # == Parameters: # organization_id:: diff --git a/lib/stytch/client.rb b/lib/stytch/client.rb index 1d9bf1a..aca2c1b 100644 --- a/lib/stytch/client.rb +++ b/lib/stytch/client.rb @@ -22,11 +22,12 @@ def initialize(project_id:, secret:, env: nil, &block) @api_host = api_host(env, project_id) @project_id = project_id @secret = secret + @is_b2b_client = false create_connection(&block) @crypto_wallets = Stytch::CryptoWallets.new(@connection) - @m2m = Stytch::M2M.new(@connection, @project_id) + @m2m = Stytch::M2M.new(@connection, @project_id, @is_b2b_client) @magic_links = Stytch::MagicLinks.new(@connection) @oauth = Stytch::OAuth.new(@connection) @otps = Stytch::OTPs.new(@connection) diff --git a/lib/stytch/m2m.rb b/lib/stytch/m2m.rb index 580164c..99b5fe4 100644 --- a/lib/stytch/m2m.rb +++ b/lib/stytch/m2m.rb @@ -13,12 +13,13 @@ class M2M include Stytch::RequestHelper attr_reader :clients - def initialize(connection, project_id) + def initialize(connection, project_id, is_b2b_client) @connection = connection @clients = Stytch::M2M::Clients.new(@connection) @project_id = project_id @cache_last_update = 0 + @is_b2b_client = is_b2b_client @jwks_loader = lambda do |options| @cached_keys = nil if options[:invalidate] && @cache_last_update < Time.now.to_i - 300 @cached_keys ||= begin @@ -37,9 +38,11 @@ def initialize(connection, project_id) def get_jwks( project_id: ) + headers = {} query_params = {} - request = request_with_query_params("/v1/sessions/jwks/#{project_id}", query_params) - get_request(request) + path = @is_b2b_client ? "/v1/b2b/sessions/jwks/#{project_id}" : "/v1/sessions/jwks/#{project_id}" + request = request_with_query_params(path, query_params) + get_request(request, headers) end # ENDMANUAL(M2M::get_jwks) @@ -100,6 +103,9 @@ def token(client_id:, client_secret:, scopes: nil) # A function to check if the token has the required scopes. This defaults to a function that assumes # scopes are either direct string matches or written in the form "action:resource". See the # documentation for +perform_authorization_check+ for more information. + # clock_tolerance_seconds: + # The tolerance to use during verification of the nbf claim. This can help with clock drift issues. + # The type of this field is nilable +Integer+. # == Returns: # +nil+ if the token could not be validated, or an object with the following fields: # scopes:: @@ -115,10 +121,11 @@ def authenticate_token( access_token:, required_scopes: nil, max_token_age: nil, - scope_authorization_func: method(:perform_authorization_check) + scope_authorization_func: method(:perform_authorization_check), + clock_tolerance_seconds: nil ) # Intentionally allow this to re-raise if authentication fails - decoded_jwt = authenticate_token_local(access_token) + decoded_jwt = authenticate_token_local(access_token, clock_tolerance_seconds: clock_tolerance_seconds) iat_time = Time.at(decoded_jwt['iat']).to_datetime @@ -173,11 +180,13 @@ def perform_authorization_check( end # Parse a M2M token and verify the signature locally (without calling /authenticate in the API) - def authenticate_token_local(jwt) + # If clock_tolerance_seconds is not supplied 0 seconds will be used as the default. + def authenticate_token_local(jwt, clock_tolerance_seconds: nil) + clock_tolerance_seconds = 0 if clock_tolerance_seconds.nil? issuer = 'stytch.com/' + @project_id begin decoded_token = JWT.decode jwt, nil, true, - { jwks: @jwks_loader, iss: issuer, verify_iss: true, aud: @project_id, verify_aud: true, algorithms: ['RS256'] } + { jwks: @jwks_loader, iss: issuer, verify_iss: true, aud: @project_id, verify_aud: true, algorithms: ['RS256'], nbf_leeway: clock_tolerance_seconds } decoded_token[0] rescue JWT::InvalidIssuerError raise JWTInvalidIssuerError diff --git a/lib/stytch/passwords.rb b/lib/stytch/passwords.rb index f1e68bc..3760776 100644 --- a/lib/stytch/passwords.rb +++ b/lib/stytch/passwords.rb @@ -34,7 +34,7 @@ def initialize(connection) # The email address of the end user. # The type of this field is +String+. # password:: - # The password of the user + # The password for the user. Any UTF8 character is allowed, e.g. spaces, emojis, non-English characers, etc. # The type of this field is +String+. # 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, @@ -127,7 +127,7 @@ def create( # The email address of the end user. # The type of this field is +String+. # password:: - # The password of the user + # The password for the user. Any UTF8 character is allowed, e.g. spaces, emojis, non-English characers, etc. # The type of this field is +String+. # session_token:: # The `session_token` associated with a User's existing Session. @@ -214,7 +214,7 @@ def authenticate( # # == Parameters: # password:: - # The password of the user + # The password for the user. Any UTF8 character is allowed, e.g. spaces, emojis, non-English characers, etc. # The type of this field is +String+. # email:: # The email address of the end user. @@ -456,7 +456,7 @@ def reset_start( # See examples and read more about redirect URLs [here](https://stytch.com/docs/guides/dashboard/redirect-urls). # The type of this field is +String+. # password:: - # The password of the user + # The password for the user. Any UTF8 character is allowed, e.g. spaces, emojis, non-English characers, etc. # The type of this field is +String+. # session_token:: # The `session_token` associated with a User's existing Session. @@ -651,7 +651,7 @@ def initialize(connection) # # == Parameters: # password:: - # The password of the user + # The password for the user. Any UTF8 character is allowed, e.g. spaces, emojis, non-English characers, etc. # The type of this field is +String+. # session_token:: # The `session_token` associated with a User's existing Session. diff --git a/lib/stytch/sessions.rb b/lib/stytch/sessions.rb index 6394e50..a2b4540 100644 --- a/lib/stytch/sessions.rb +++ b/lib/stytch/sessions.rb @@ -203,13 +203,16 @@ def get_jwks( # max_token_age_seconds seconds ago, then just verify locally and don't call the API # To force remote validation for all tokens, set max_token_age_seconds to 0 or call authenticate() # If max_token_age_seconds is not supplied 300 seconds will be used as the default. + # If clock_tolerance_seconds is not supplied 0 seconds will be used as the default. def authenticate_jwt( session_jwt, max_token_age_seconds: nil, session_duration_minutes: nil, - session_custom_claims: nil + session_custom_claims: nil, + clock_tolerance_seconds: nil ) max_token_age_seconds = 300 if max_token_age_seconds.nil? + clock_tolerance_seconds = 0 if clock_tolerance_seconds.nil? if max_token_age_seconds == 0 return authenticate( @@ -219,7 +222,11 @@ def authenticate_jwt( ) end - session = authenticate_jwt_local(session_jwt, max_token_age_seconds: max_token_age_seconds) + session = authenticate_jwt_local( + session_jwt, + max_token_age_seconds: max_token_age_seconds, + clock_tolerance_seconds: clock_tolerance_seconds + ) return session unless session.nil? authenticate( @@ -241,13 +248,15 @@ def authenticate_jwt( # function to get the JWK # This method never authenticates a JWT directly with the API # If max_token_age_seconds is not supplied 300 seconds will be used as the default. - def authenticate_jwt_local(session_jwt, max_token_age_seconds: nil) + # If clock_tolerance_seconds is not supplied 0 seconds will be used as the default. + def authenticate_jwt_local(session_jwt, max_token_age_seconds: nil, clock_tolerance_seconds: nil) max_token_age_seconds = 300 if max_token_age_seconds.nil? + clock_tolerance_seconds = 0 if clock_tolerance_seconds.nil? issuer = 'stytch.com/' + @project_id begin decoded_token = JWT.decode session_jwt, nil, true, - { jwks: @jwks_loader, iss: issuer, verify_iss: true, aud: @project_id, verify_aud: true, algorithms: ['RS256'] } + { jwks: @jwks_loader, iss: issuer, verify_iss: true, aud: @project_id, verify_aud: true, algorithms: ['RS256'], nbf_leeway: clock_tolerance_seconds } session = decoded_token[0] iat_time = Time.at(session['iat']).to_datetime diff --git a/lib/stytch/version.rb b/lib/stytch/version.rb index ec351ff..2202f6c 100644 --- a/lib/stytch/version.rb +++ b/lib/stytch/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Stytch - VERSION = '9.1.0' + VERSION = '9.2.0' end diff --git a/spec/stytch/m2m_spec.rb b/spec/stytch/m2m_spec.rb index e068d61..7b2d087 100644 --- a/spec/stytch/m2m_spec.rb +++ b/spec/stytch/m2m_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true RSpec.describe Stytch::M2M do - let(:m2m) { Stytch::M2M.new(nil, '') } + let(:m2m) { Stytch::M2M.new(nil, '', false) } it 'handles basic m2m auth' do has = ['read:user', 'write:user']