Skip to content

Commit

Permalink
Support for member session authZ for RBAC
Browse files Browse the repository at this point in the history
  • Loading branch information
logan-stytch committed Dec 5, 2023
1 parent 4d28557 commit 8a71cb2
Show file tree
Hide file tree
Showing 13 changed files with 166 additions and 102 deletions.
12 changes: 9 additions & 3 deletions lib/stytch/b2b_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@
require_relative 'b2b_organizations'
require_relative 'b2b_otp'
require_relative 'b2b_passwords'
require_relative 'b2b_rbac'
require_relative 'b2b_sessions'
require_relative 'b2b_sso'
require_relative 'm2m'
require_relative 'rbac_local'

module StytchB2B
class Client
ENVIRONMENTS = %i[live test].freeze

attr_reader :discovery, :m2m, :magic_links, :oauth, :otps, :organizations, :passwords, :sso, :sessions
attr_reader :discovery, :m2m, :magic_links, :oauth, :otps, :organizations, :passwords, :rbac, :sso, :sessions

def initialize(project_id:, secret:, env: nil, &block)
@api_host = api_host(env, project_id)
Expand All @@ -23,15 +25,19 @@ def initialize(project_id:, secret:, env: nil, &block)

create_connection(&block)

rbac = StytchB2B::RBAC.new(@connection)
@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)
@magic_links = StytchB2B::MagicLinks.new(@connection)
@oauth = StytchB2B::OAuth.new(@connection)
@otps = StytchB2B::OTPs.new(@connection)
@organizations = StytchB2B::Organizations.new(@connection)
@passwords = StytchB2B::Passwords.new(@connection)
@rbac = StytchB2B::RBAC.new(@connection)
@sso = StytchB2B::SSO.new(@connection)
@sessions = StytchB2B::Sessions.new(@connection, project_id)
@sessions = StytchB2B::Sessions.new(@connection, @project_id, @policy_cache)
end

private
Expand Down
6 changes: 6 additions & 0 deletions lib/stytch/b2b_oauth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,12 @@ def initialize(connection)
#
# c) The Organization has at least one other Member with a verified email address with the same domain as the end user (to prevent phishing attacks).
# The type of this field is list of +DiscoveredOrganization+ (+object+).
# provider_type::
# (no documentation yet)
# The type of this field is +String+.
# provider_tenant_id::
# (no documentation yet)
# The type of this field is +String+.
# 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+.
Expand Down
32 changes: 4 additions & 28 deletions lib/stytch/b2b_organizations.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ def initialize(connection)
#
# == Parameters:
# organization_name::
# The name of the Organization. Must be between 1 and 128 characters in length.
# The name of the Organization.
# The type of this field is +String+.
# organization_slug::
# The unique URL slug of the Organization. The slug only accepts alphanumeric characters and the following reserved characters: `-` `.` `_` `~`. Must be between 2 and 128 characters in length.
# The unique URL slug of the Organization. A minimum of two characters is required. The slug only accepts alphanumeric characters and the following reserved characters: `-` `.` `_` `~`.
# The type of this field is nilable +String+.
# organization_logo_url::
# The image URL of the Organization logo.
Expand Down Expand Up @@ -171,10 +171,10 @@ def get(
# 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+.
# organization_name::
# The name of the Organization. Must be between 1 and 128 characters in length.
# The name of the Organization.
# The type of this field is nilable +String+.
# organization_slug::
# The unique URL slug of the Organization. The slug only accepts alphanumeric characters and the following reserved characters: `-` `.` `_` `~`. Must be between 2 and 128 characters in length.
# The unique URL slug of the Organization. A minimum of two characters is required. The slug only accepts alphanumeric characters and the following reserved characters: `-` `.` `_` `~`.
# The type of this field is nilable +String+.
# organization_logo_url::
# The image URL of the Organization logo.
Expand Down Expand Up @@ -619,30 +619,6 @@ def delete_password(
delete_request("/v1/b2b/organizations/#{organization_id}/members/passwords/#{member_password_id}")
end

# Get a Member by `member_id`. This endpoint does not require an `organization_id`, so you can use it to get members across organizations. This is a dangerous operation. Incorrect use may open you up to indirect object reference (IDOR) attacks. We recommend using the [Get Member](https://stytch.com/docs/b2b/api/get-member) API instead.
#
# == Parameters:
# member_id::
# Globally unique UUID that identifies a specific Member. The `member_id` is critical to perform operations on a Member, so be sure to preserve this value.
# The type of this field is +String+.
#
# == Returns:
# An object with the following fields:
# request_id::
# Globally unique UUID that is returned with every API call. This value is important to log for debugging purposes; we may ask for this value to help identify a specific API call when helping you debug an issue.
# The type of this field is +String+.
# member_id::
# Globally unique UUID that identifies a specific Member.
# The type of this field is +String+.
# member::
# The [Member object](https://stytch.com/docs/b2b/api/member-object)
# The type of this field is +Member+ (+object+).
# organization::
# The [Organization object](https://stytch.com/docs/b2b/api/organization-object).
# The type of this field is +Organization+ (+object+).
# 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+.
def dangerously_get(
member_id:
)
Expand Down
36 changes: 8 additions & 28 deletions lib/stytch/b2b_passwords.rb
Original file line number Diff line number Diff line change
Expand Up @@ -538,30 +538,13 @@ def initialize(connection)
# The JSON Web Token (JWT) for a given Stytch Session.
# The type of this field is nilable +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,
# 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 be created with a 60 minute duration. If you don't want
# to use the Stytch session product, you can ignore the session fields in the response.
# (no documentation yet)
# The type of this field is nilable +Integer+.
# 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.
# (no documentation yet)
# The type of this field is nilable +object+.
# locale::
# Used to determine which language to use when sending the user this delivery method. Parameter is a [IETF BCP 47 language tag](https://www.w3.org/International/articles/language-tags/), e.g. `"en"`.
#
# Currently supported languages are English (`"en"`), Spanish (`"es"`), and Brazilian Portuguese (`"pt-br"`); if no value is provided, the copy defaults to English.
#
# Request support for additional languages [here](https://docs.google.com/forms/d/e/1FAIpQLScZSpAu_m2AmLXRT3F3kap-s_mcV6UTBitYn6CdyWP0-o7YjQ/viewform?usp=sf_link")!
#
# (no documentation yet)
# The type of this field is nilable +ResetRequestLocale+ (string enum).
#
# == Returns:
Expand All @@ -579,19 +562,16 @@ def initialize(connection)
# The [Organization object](https://stytch.com/docs/b2b/api/organization-object).
# The type of this field is +Organization+ (+object+).
# session_token::
# A secret token for a given Stytch Session.
# (no documentation yet)
# The type of this field is +String+.
# session_jwt::
# The JSON Web Token (JWT) for a given Stytch Session.
# (no documentation yet)
# The type of this field is +String+.
# intermediate_session_token::
# The Intermediate Session Token. This token does not necessarily belong to a specific instance of a Member, but represents a bag of factors that may be converted to a member session.
# The token can be used with the [OTP SMS Authenticate endpoint](https://stytch.com/docs/b2b/api/authenticate-otp-sms) to complete an MFA flow;
# the [Exchange Intermediate Session endpoint](https://stytch.com/docs/b2b/api/exchange-intermediate-session) to join a specific Organization that allows the factors represented by the intermediate session token;
# or the [Create Organization via Discovery endpoint](https://stytch.com/docs/b2b/api/create-organization-via-discovery) to create a new Organization and Member.
# (no documentation yet)
# The type of this field is +String+.
# member_authenticated::
# Indicates whether the Member is fully authenticated. If false, the Member needs to complete an MFA step to log in to the Organization.
# (no documentation yet)
# The type of this field is +Boolean+.
# 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.
Expand All @@ -600,7 +580,7 @@ def initialize(connection)
# The [Session object](https://stytch.com/docs/b2b/api/session-object).
# The type of this field is nilable +MemberSession+ (+object+).
# mfa_required::
# Information about the MFA requirements of the Organization and the Member's options for fulfilling MFA.
# (no documentation yet)
# The type of this field is nilable +MfaRequired+ (+object+).
def reset(
organization_id:,
Expand Down
25 changes: 25 additions & 0 deletions lib/stytch/b2b_rbac.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

# !!!
# WARNING: This file is autogenerated
# Only modify code within MANUAL() sections
# or your changes may be overwritten later!
# !!!

require_relative 'request_helper'

module StytchB2B
class RBAC
include Stytch::RequestHelper

def initialize(connection)
@connection = connection
end

def policy
query_params = {}
request = request_with_query_params('/v1/b2b/rbac/policy', query_params)
get_request(request)
end
end
end
51 changes: 37 additions & 14 deletions lib/stytch/b2b_sessions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ module StytchB2B
class Sessions
include Stytch::RequestHelper

def initialize(connection, project_id)
def initialize(connection, project_id, policy_cache)
@connection = connection

@policy_cache = policy_cache
@project_id = project_id
@cache_last_update = 0
@jwks_loader = lambda do |options|
Expand Down Expand Up @@ -325,11 +326,14 @@ def get_jwks(
# 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()
# 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.
def authenticate_jwt(
session_jwt,
max_token_age_seconds: nil,
session_duration_minutes: nil,
session_custom_claims: nil
session_custom_claims: nil,
authorization_check: nil
)
if max_token_age_seconds == 0
return authenticate(
Expand All @@ -343,7 +347,15 @@ def authenticate_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 }
if authorization_check and session['roles']
@policy_cache.perform_authorization_check(
subject_roles: session['roles'],
subject_org_id: session['organization_id'],
authz_request: authorization_check
)
end

{ 'session' => session['member_session'] }
else
authenticate(
session_jwt: session_jwt,
Expand All @@ -364,7 +376,7 @@ def authenticate_jwt(
# 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)
def authenticate_jwt_local(session_jwt, authorization_check: nil)
issuer = 'stytch.com/' + @project_id
begin
decoded_token = JWT.decode session_jwt, nil, true,
Expand All @@ -381,24 +393,35 @@ def authenticate_jwt_local(session_jwt)
end
end

# Note that the 'user_id' field is DEPRECATED: Use member_id instead
# This field will be removed in a future MAJOR release.
def marshal_jwt_into_session(jwt)
stytch_claim = 'https://stytch.com/session'
organization_claim = 'https://stytch.com/organization'
roles_claim = 'https://stytch.com/roles'

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]
reserved_claims = ['aud', 'exp', 'iat', 'iss', 'jti', 'nbf', 'sub', stytch_claim, organization_claim, roles_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
'member_session' => {
'session_id' => jwt[stytch_claim]['id'],
'organization_id' => jwt[organization_claim]['id'],
'member_id' => jwt['sub'],
# DEPRECATED: Use member_id instead
'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
},
'roles' => jwt[roles_claim]
}
end
# ENDMANUAL(Sessions::authenticate_jwt)
Expand Down
2 changes: 1 addition & 1 deletion lib/stytch/b2b_sso.rb
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ def create_connection(
# The URL for which assertions for login requests will be sent. This will be provided by the IdP.
# The type of this field is nilable +String+.
# alternative_audience_uri::
# An alternative URL to use for the Audience Restriction. This value can be used when you wish to migrate an existing SAML integration to Stytch with zero downtime.
# (no documentation yet)
# The type of this field is nilable +String+.
#
# == Returns:
Expand Down
5 changes: 3 additions & 2 deletions lib/stytch/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
require_relative 'oauth'
require_relative 'otps'
require_relative 'passwords'
require_relative 'rbac_local'
require_relative 'sessions'
require_relative 'totps'
require_relative 'users'
Expand All @@ -25,12 +26,12 @@ def initialize(project_id:, secret:, env: nil, &block)
create_connection(&block)

@crypto_wallets = Stytch::CryptoWallets.new(@connection)
@m2m = Stytch::M2M.new(@connection, project_id)
@m2m = Stytch::M2M.new(@connection, @project_id)
@magic_links = Stytch::MagicLinks.new(@connection)
@oauth = Stytch::OAuth.new(@connection)
@otps = Stytch::OTPs.new(@connection)
@passwords = Stytch::Passwords.new(@connection)
@sessions = Stytch::Sessions.new(@connection, project_id)
@sessions = Stytch::Sessions.new(@connection, @project_id)
@totps = Stytch::TOTPs.new(@connection)
@users = Stytch::Users.new(@connection)
@webauthn = Stytch::WebAuthn.new(@connection)
Expand Down
14 changes: 14 additions & 0 deletions lib/stytch/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,18 @@ def initialize(scope)
super(msg)
end
end

class TenancyError < StandardError
def initialize(subject_org_id, request_org_id)
msg = "Subject organization_id #{subject_org_id} does not match authZ request organization_id #{request_org_id}"
super(msg)
end
end

class PermissionError < StandardError
def initialize(request)
msg = "Permission denied for request #{request}"
super(msg)
end
end
end
1 change: 0 additions & 1 deletion lib/stytch/m2m.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ def initialize(connection, project_id)
@connection = connection

@clients = Stytch::M2M::Clients.new(@connection)

@project_id = project_id
@cache_last_update = 0
@jwks_loader = lambda do |options|
Expand Down
Loading

0 comments on commit 8a71cb2

Please sign in to comment.