diff --git a/lib/stytch/b2b_client.rb b/lib/stytch/b2b_client.rb index 7270fbe..9cbdb42 100644 --- a/lib/stytch/b2b_client.rb +++ b/lib/stytch/b2b_client.rb @@ -31,7 +31,7 @@ def initialize(project_id:, secret:, env: nil, &block) @organizations = StytchB2B::Organizations.new(@connection) @passwords = StytchB2B::Passwords.new(@connection) @sso = StytchB2B::SSO.new(@connection) - @sessions = StytchB2B::Sessions.new(@connection) + @sessions = StytchB2B::Sessions.new(@connection, project_id) end private diff --git a/lib/stytch/b2b_sessions.rb b/lib/stytch/b2b_sessions.rb index baa029f..dff19e0 100644 --- a/lib/stytch/b2b_sessions.rb +++ b/lib/stytch/b2b_sessions.rb @@ -6,14 +6,31 @@ # or your changes may be overwritten later! # !!! +require 'jwt' +require 'json/jwt' +require_relative 'errors' require_relative 'request_helper' module StytchB2B class Sessions include Stytch::RequestHelper - def initialize(connection) + def initialize(connection, project_id) @connection = connection + + @project_id = project_id + @cache_last_update = 0 + @jwks_loader = lambda do |options| + @cached_keys = nil if options[:invalidate] && @cache_last_update < Time.now.to_i - 300 + @cached_keys ||= begin + @cache_last_update = Time.now.to_i + keys = [] + get_jwks(project_id: @project_id)['keys'].each do |r| + keys << r + end + { keys: keys } + end + end end # Retrieves all active Sessions for a Member. @@ -290,5 +307,92 @@ def get_jwks( request = request_with_query_params("/v1/b2b/sessions/jwks/#{project_id}", query_params) get_request(request) end + + # MANUAL(Sessions::authenticate_jwt)(SERVICE_METHOD) + # ADDIMPORT: require 'jwt' + # ADDIMPORT: require 'json/jwt' + # ADDIMPORT: require_relative 'errors' + + # Parse a JWT and verify the signature. If max_token_age_seconds is unset, call the API directly + # If max_token_age_seconds is set and the JWT was issued (based on the "iat" claim) less than + # 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() + def authenticate_jwt( + session_jwt, + max_token_age_seconds: nil, + session_duration_minutes: nil, + session_custom_claims: nil + ) + if max_token_age_seconds == 0 + return authenticate( + session_jwt: session_jwt, + session_duration_minutes: session_duration_minutes, + session_custom_claims: session_custom_claims + ) + end + + decoded_jwt = authenticate_jwt_local(session_jwt) + iat_time = Time.at(decoded_jwt['iat']).to_datetime + if iat_time + max_token_age_seconds >= Time.now + session = marshal_jwt_into_session(decoded_jwt) + { 'session' => session } + else + authenticate( + session_jwt: session_jwt, + session_duration_minutes: session_duration_minutes, + session_custom_claims: session_custom_claims + ) + end + rescue StandardError + # JWT could not be verified locally. Check with the Stytch API. + authenticate( + session_jwt: session_jwt, + session_duration_minutes: session_duration_minutes, + session_custom_claims: session_custom_claims + ) + end + + # Parse a JWT and verify the signature locally (without calling /authenticate in the API) + # Uses the cached value to get the JWK but if it is unavailable, it calls the get_jwks() + # function to get the JWK + # This method never authenticates a JWT directly with the API + def authenticate_jwt_local(session_jwt) + 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'] } + decoded_token[0] + rescue JWT::InvalidIssuerError + raise JWTInvalidIssuerError + rescue JWT::InvalidAudError + raise JWTInvalidAudienceError + rescue JWT::ExpiredSignature + raise JWTExpiredSignatureError + rescue JWT::IncorrectAlgorithm + raise JWTIncorrectAlgorithmError + end + end + + def marshal_jwt_into_session(jwt) + stytch_claim = 'https://stytch.com/session' + expires_at = jwt[stytch_claim]['expires_at'] || Time.at(jwt['exp']).to_datetime.utc.strftime('%Y-%m-%dT%H:%M:%SZ') + # The custom claim set is all the claims in the payload except for the standard claims and + # the Stytch session claim. The cleanest way to collect those seems to be naming what we want + # to omit and filtering the rest to collect the custom claims. + reserved_claims = ['aud', 'exp', 'iat', 'iss', 'jti', 'nbf', 'sub', stytch_claim] + custom_claims = jwt.reject { |key, _| reserved_claims.include?(key) } + { + 'session_id' => jwt[stytch_claim]['id'], + 'user_id' => jwt['sub'], + 'started_at' => jwt[stytch_claim]['started_at'], + 'last_accessed_at' => jwt[stytch_claim]['last_accessed_at'], + # For JWTs that include it, prefer the inner expires_at claim. + 'expires_at' => expires_at, + 'attributes' => jwt[stytch_claim]['attributes'], + 'authentication_factors' => jwt[stytch_claim]['authentication_factors'], + 'custom_claims' => custom_claims + } + end + # ENDMANUAL(Sessions::authenticate_jwt) end end diff --git a/lib/stytch/magic_links.rb b/lib/stytch/magic_links.rb index 9e9552d..c83c71a 100644 --- a/lib/stytch/magic_links.rb +++ b/lib/stytch/magic_links.rb @@ -167,9 +167,7 @@ def initialize(connection) # Send a magic link to an existing Stytch user using their email address. If you'd like to create a user and send them a magic link by email with one request, use our [log in or create endpoint](https://stytch.com/docs/api/log-in-or-create-user-by-email). # # ### Add an email to an existing user - # This endpoint also allows you to add a new email to an existing Stytch User. Including a `user_id`, `session_token`, or `session_jwt` in the request will add the email to the pre-existing Stytch User upon successful authentication. - # - # Adding a new email to an existing Stytch User requires the user to be present and validate the email via magic link. This requirement is in place to prevent account takeover attacks. + # This endpoint also allows you to add a new email address to an existing Stytch User. Including a `user_id`, `session_token`, or `session_jwt` in your Send Magic Link by email request will add the new, unverified email address to the existing Stytch User. Upon successful authentication, the email address will be marked as verified. # # ### Next steps # The user is emailed a magic link which redirects them to the provided [redirect URL](https://stytch.com/docs/guides/magic-links/email-magic-links/redirect-routing). Collect the `token` from the URL query parameters, and call [Authenticate magic link](https://stytch.com/docs/api/authenticate-magic-link) to complete authentication. diff --git a/lib/stytch/otps.rb b/lib/stytch/otps.rb index 671f535..1724730 100644 --- a/lib/stytch/otps.rb +++ b/lib/stytch/otps.rb @@ -131,9 +131,7 @@ def initialize(connection) # # ### Add a phone number to an existing user # - # This endpoint also allows you to add a new phone number to an existing Stytch User. Including a `user_id`, `session_token`, or `session_jwt` in the request will add the phone number to the pre-existing Stytch User upon successful authentication. - # - # Adding a new phone number to an existing Stytch User requires the user to be present and validate the phone number via OTP. This requirement is in place to prevent account takeover attacks. + # This endpoint also allows you to add a new phone number to an existing Stytch User. Including a `user_id`, `session_token`, or `session_jwt` in your Send one-time passcode by SMS request will add the new, unverified phone number to the existing Stytch User. Upon successful authentication, the phone number will be marked as verified. # # ### Next steps # @@ -289,9 +287,7 @@ def initialize(connection) # # ### Add a phone number to an existing user # - # This endpoint also allows you to add a new phone number to an existing Stytch User. Including a `user_id`, `session_token`, or `session_jwt` in the request will add the phone number to the pre-existing Stytch User upon successful authentication. - # - # Adding a new phone number to an existing Stytch User requires the user to be present and validate the phone number via OTP. This requirement is in place to prevent account takeover attacks. + # This endpoint also allows you to add a new phone number to an existing Stytch User. Including a `user_id`, `session_token`, or `session_jwt` in your Send one-time passcode by WhatsApp request will add the new, unverified phone number to the existing Stytch User. Upon successful authentication, the phone number will be marked as verified. # # ### Next steps # @@ -442,9 +438,7 @@ def initialize(connection) # Send a One-Time Passcode (OTP) to a User using their email. If you'd like to create a user and send them a passcode with one request, use our [log in or create endpoint](https://stytch.com/docs/api/log-in-or-create-user-by-email-otp). # # ### Add an email to an existing user - # This endpoint also allows you to add a new email to an existing Stytch User. Including a `user_id`, `session_token`, or `session_jwt` in the request will add the email to the pre-existing Stytch User upon successful authentication. - # - # Adding a new email to an existing Stytch User requires the User to be present and validate the email via OTP. This requirement is in place to prevent account takeover attacks. + # This endpoint also allows you to add a new email address to an existing Stytch User. Including a `user_id`, `session_token`, or `session_jwt` in your Send one-time passcode by email request will add the new, unverified email address to the existing Stytch User. Upon successful authentication, the email address will be marked as verified. # # ### Next steps # Collect the OTP which was delivered to the user. Call [Authenticate OTP](https://stytch.com/docs/api/authenticate-otp) using the OTP `code` along with the `phone_id` found in the response as the `method_id`. diff --git a/lib/stytch/users.rb b/lib/stytch/users.rb index 30b6046..1303338 100644 --- a/lib/stytch/users.rb +++ b/lib/stytch/users.rb @@ -196,7 +196,7 @@ def search( # Update a User's attributes. # - # **Note:** In order to add a new email address or phone number to an existing User object, pass the new email address or phone number into the respective `/send` endpoint for the authentication method of your choice. If you specify the existing User's `user_id` while calling the `/send` endpoint, the new email address or phone number will be added to the existing User object upon successful authentication. We require this process to guard against an account takeover vulnerability. + # **Note:** In order to add a new email address or phone number to an existing User object, pass the new email address or phone number into the respective `/send` endpoint for the authentication method of your choice. If you specify the existing User's `user_id` while calling the `/send` endpoint, the new, unverified email address or phone number will be added to the existing User object. Upon successful authentication, the email address or phone number will be marked as verified. We require this process to guard against an account takeover vulnerability. # # == Parameters: # user_id:: diff --git a/lib/stytch/version.rb b/lib/stytch/version.rb index 0386675..966a63b 100644 --- a/lib/stytch/version.rb +++ b/lib/stytch/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Stytch - VERSION = '6.4.0' + VERSION = '6.5.0' end