clerk-sdk-ruby 2.11.1 → 4.0.0.beta2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 44a06343f2301fd2a975f73e9cafe69c1965c1ef8bac85ff404832e0346aa44d
4
- data.tar.gz: eadb539cdf1de8cab247cdf3cee2c11618bdde59130098f3cab3c0813e45e1e6
3
+ metadata.gz: 7b3b121821e34e9882661931f7e5c7ccdd630095905252ba53c8a48b9c62320c
4
+ data.tar.gz: ab0b8077553effb1c8521204787499ee28e0a544629980a7b098789a5c474e14
5
5
  SHA512:
6
- metadata.gz: ba62a865518047cf7a8b6c25881bdded00463d2ea6150cc71749dc72892b13c88022cf9081e61805654bbcf99a06907dad6afc0deb9dc9ee08291e7e495c9f47
7
- data.tar.gz: fe68ea6c686db7651e38a99d6369afdf7fb8b7f1ef1a65d2e671c5f75276cd4731ee35ff406f77e31311b43ad606ce8d9aaa4064463d1b7e4087a35713207b90
6
+ metadata.gz: 007f6ba6dc18e7d1e54173160e069bc76869b7c13a645eea53943669536ad3963e95272a2ace2d64cd7c9c68d3386dbe5695726e235f559f59f8b15f1addec99
7
+ data.tar.gz: a0ecb1901197fe8e2a01269326dfb61bdf6da2dd0b9c22298fb580370d6cf2527f5e5f1eaa0a19687b3d242104bf8784a740ac80bfae0df1ff576e1b03c14451
@@ -16,3 +16,5 @@ jobs:
16
16
  gem install bundler -v 2.2.15
17
17
  bundle install
18
18
  bundle exec rake
19
+ env:
20
+ CLERK_PUBLISHABLE_KEY: 'pk_test_ZXhhbXBsZS5jb20k'
@@ -0,0 +1,24 @@
1
+ name: Semgrep
2
+ on:
3
+ workflow_dispatch: {}
4
+ pull_request: {}
5
+ push:
6
+ branches:
7
+ - main
8
+ paths:
9
+ - .github/workflows/semgrep.yml
10
+ schedule:
11
+ # random HH:MM to avoid a load spike on GitHub Actions at 00:00
12
+ - cron: '15 18 * * *'
13
+ jobs:
14
+ semgrep:
15
+ name: semgrep/ci
16
+ runs-on: ubuntu-22.04
17
+ env:
18
+ SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}
19
+ container:
20
+ image: returntocorp/semgrep
21
+ if: (github.actor != 'dependabot[bot]')
22
+ steps:
23
+ - uses: actions/checkout@v3
24
+ - run: semgrep ci
data/CHANGELOG.md CHANGED
@@ -1,4 +1,30 @@
1
- ## unreleased
1
+
2
+ ## 4.0.0.beta2 - 2024-02-26
3
+
4
+ Note: this is identical to 4.0.0.beta1, which was yanked because it was not generated from the main branch.
5
+
6
+ - feat: replace interstitial with handshake (internal mechanisms) [https://github.com/clerk/clerk-sdk-ruby/pull/45]
7
+ - chore: re-organize and refactor internal code to extract functionality of rack middleware [https://github.com/clerk/clerk-sdk-ruby/pull/45]
8
+ - changed: `CLERK_PUBLISHABLE_KEY` or `publishable_key` in `Clerk.configure` is **required** [https://github.com/clerk/clerk-sdk-ruby/pull/46]
9
+
10
+ ## [YANKED] 4.0.0.beta1 - 2024-02-26
11
+
12
+ - feat: replace interstitial with handshake (internal mechanisms) [https://github.com/clerk/clerk-sdk-ruby/pull/45]
13
+ - chore: re-organize and refactor internal code to extract functionality of rack middleware [https://github.com/clerk/clerk-sdk-ruby/pull/45]
14
+ - changed: `CLERK_PUBLISHABLE_KEY` or `publishable_key` in `Clerk.configure` is **required** [https://github.com/clerk/clerk-sdk-ruby/pull/46]
15
+
16
+ ## 3.0.0 - 2024-01-09
17
+
18
+ Note: this is identical to 2.12.0, which was yanked because it contained a
19
+ breaking change.
20
+
21
+ - feat: Add org role/permission helpers [https://github.com/clerk/clerk-sdk-ruby/pull/40]
22
+ - changed: drop create sms endpoint [https://github.com/clerk/clerk-sdk-ruby/pull/39]
23
+
24
+ ## [YANKED] 2.12.0 - 2024-01-09
25
+
26
+ - feat: Add org role/permission helpers [https://github.com/clerk/clerk-sdk-ruby/pull/40]
27
+ - changed: drop create sms endpoint [https://github.com/clerk/clerk-sdk-ruby/pull/39]
2
28
 
3
29
  ## 2.11.1 - 2023-10-31
4
30
 
@@ -78,12 +104,12 @@ Identical to 2.9.0.beta3
78
104
 
79
105
  ## 2.0.0 - 2021-10-21
80
106
 
81
- This release introduces the new networkless middleware which works with the new
107
+ This release introduces the new networkless middleware which works with the new
82
108
  authentication scheme, [Auth v2](https://clerk.com/docs/upgrade-guides/auth-v2).
83
109
 
84
110
  It is backwards-incompatible with applications using Auth v1.
85
111
 
86
- - [BREAKING]: In order to use this version, you must set the authVersion prop
112
+ - [BREAKING]: In order to use this version, you must set the authVersion prop
87
113
  accordingly in your frontend: `Clerk.load({authVersion: 2})`
88
114
 
89
115
  For more information on Auth v2, please refer to
data/CODEOWNERS CHANGED
@@ -1 +1 @@
1
- * @clerkinc/backend-team
1
+ * @clerkinc/backend
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- clerk-sdk-ruby (2.11.1)
4
+ clerk-sdk-ruby (4.0.0.beta2)
5
5
  concurrent-ruby (~> 1.1)
6
6
  faraday (>= 1.4.1, < 3.0)
7
7
  jwt (~> 2.5)
data/README.md CHANGED
@@ -87,8 +87,9 @@ supported configuration settings their environment variable equivalents:
87
87
 
88
88
  ```ruby
89
89
  Clerk.configure do |c|
90
- c.api_key = "your_api_key" # if omitted: ENV["CLERK_API_KEY"] - API calls will fail if unset
91
- c.base_url = "https://..." # if omitted: "https://api.clerk.dev/v1/"
90
+ c.api_key = "your_api_key" # if omitted: ENV["CLERK_SECRET_KEY"] - API calls will fail if unset
91
+ c.base_url = "https://..." # if omitted: ENV["CLERK_API_BASE"] - defaults to "https://api.clerk.com/v1/"
92
+ c.publishable_key = "pk_(test|live)_...." # if omitted: ENV["CLERK_PUBLISHABLE_KEY"] - Handshake mechanism (check section below) will fail if unset
92
93
  c.logger = Logger.new(STDOUT) # if omitted, no logging
93
94
  c.middleware_cache_store = ActiveSupport::Cache::FileStore.new("/tmp/clerk_middleware_cache") # if omitted: no caching
94
95
  c.excluded_routes ["/foo", "/bar/*"]
@@ -159,24 +160,45 @@ single key (`errors`) containing an array of error objects.
159
160
  Read the [API documentation](https://clerk.com/docs/reference/backend-api)
160
161
  for details on expected parameters and response formats.
161
162
 
163
+ <a name="handshake"></a>
164
+
165
+ ### Handshake
166
+
167
+ The Client Handshake is a mechanism that is used to resolve a request’s authentication state from “unknown” to definitively signed in or signed out. Clerk’s session management architecture relies on a short-lived session JWT to validate requests, along with a long-lived session that is used to keep the session JWT fresh by interacting with the Frontend API. The long-lived session token is stored in an HttpOnly cookie associated with the Frontend API domain. If a short-lived session JWT is expired on a request to an application’s backend, the SDK doesn’t know if the session has ended, or if a new short-lived JWT needs to be issued. When an SDK gets into this state, it triggers the handshake.
168
+
169
+ With the handshake, we can resolve the authentication state on the backend and ensure the request is properly handled as signed in or out, instead of being in a potentially “unknown” state. The handshake flow relies on redirects to exchange session information between FAPI and the application, ensuring the resolution of unknown authentication states minimizes performance impact and behaves consistently across different framework and language implementations.
170
+
162
171
  ## Development
163
172
 
164
173
  After checking out the repo, run `bin/setup` to install dependencies. Then, run
165
174
  `bundle exec rake` to run the tests. You can also run `bin/console` for an
166
175
  interactive prompt that will allow you to experiment.
167
176
 
168
- To install this gem onto your local machine, run `bundle exec rake install`. To
169
- release a new version, update the version number in `version.rb`, and then run
170
- `bundle exec rake release`, which will create a git tag for the version, push
171
- git commits and the created tag, and push the `.gem` file to
172
- [rubygems.org](https://rubygems.org).
177
+ To install this gem onto your local machine, run `bundle exec rake install`.
178
+
179
+ ## Release
180
+
181
+ To release a new version:
182
+ - update the version number in `version.rb`
183
+ - update `CHANGELOG.md` to include information about the changes
184
+ - merge changes into main
185
+ - run `bundle exec rake release`
186
+
187
+ If gem publishing is NOT executed automatically:
188
+ - run `gem push pkg/clerk-sdk-ruby-{version}.gem` to push the `.gem` file to [rubygems.org](https://rubygems.org)
189
+
190
+ The `bundle exec rake release` command:
191
+ - creates a git tag with the version found in `version.rb`
192
+ - pushes the git tag
193
+
194
+ ## Yank release
195
+
196
+ We should avoid yanking a releasing but if it's necessary execute `gem yank clerk-sdk-ruby -v {version}`
173
197
 
174
198
  ## Contributing
175
199
 
176
- Bug reports and pull requests are welcome on GitHub at
177
- https://github.com/clerkinc/clerk-sdk-ruby.
200
+ Bug reports and pull requests are welcome on GitHub at https://github.com/clerkinc/clerk-sdk-ruby.
178
201
 
179
202
  ## License
180
203
 
181
- The gem is available as open source under the terms of the
182
- [MIT License](https://opensource.org/licenses/MIT).
204
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -61,6 +61,14 @@ module Clerk
61
61
  request.env["clerk"].org_id
62
62
  end
63
63
 
64
+ def clerk_organization_role
65
+ request.env["clerk"].org_role
66
+ end
67
+
68
+ def clerk_organization_permissions
69
+ request.env["clerk"].org_permissions
70
+ end
71
+
64
72
  def clerk_user_signed_in?
65
73
  !!clerk_verified_session_claims
66
74
  end
@@ -82,7 +90,8 @@ module Clerk
82
90
  :clerk_verified_session_claims, :clerk_verified_session_token,
83
91
  :clerk_user, :clerk_user_id, :clerk_user_signed_in?, :clerk_sign_in_url,
84
92
  :clerk_sign_up_url, :clerk_user_profile_url,
85
- :clerk_organization, :clerk_organization_id
93
+ :clerk_organization, :clerk_organization_id, :clerk_organization_role,
94
+ :clerk_organization_permissions
86
95
  end
87
96
  end
88
97
  end
@@ -0,0 +1,183 @@
1
+ require "ostruct"
2
+ require "forwardable"
3
+ require "base64"
4
+
5
+ module Clerk
6
+ ##
7
+ # This class represents a parameter object used to contain all request and configuration
8
+ # information required by the middleware to resolve the current request state.
9
+ # link: https://refactoring.guru/introduce-parameter-object
10
+ class AuthenticateContext
11
+ extend Forwardable
12
+
13
+ ##
14
+ # Expose the url of the request that this parameter object was created from as a URI object.
15
+ attr_reader :clerk_url
16
+
17
+ ##
18
+ # Expose properties that does not require validations or complex logic to retrieve
19
+ # values by delegating them to the cookies or headers variables.
20
+ def_delegators :@cookies, :session_token_in_cookie, :client_uat
21
+ def_delegators :@headers, :session_token_in_header, :sec_fetch_dest
22
+
23
+ ##
24
+ # Creates a new parameter object using Rack::Request and Clerk::Config objects.
25
+ def initialize(request, config)
26
+ @clerk_url = URI.parse(request.url)
27
+ @config = config
28
+
29
+ @cookies = OpenStruct.new({
30
+ session_token_in_cookie: request.cookies[SESSION_COOKIE],
31
+ client_uat: request.cookies[CLIENT_UAT_COOKIE],
32
+ handshake_token: request.cookies[HANDSHAKE_COOKIE],
33
+ dev_browser: request.cookies[DEV_BROWSER_COOKIE]
34
+ })
35
+
36
+ @headers = OpenStruct.new({
37
+ session_token_in_header: request.env[AUTHORIZATION_HEADER].to_s.gsub(/bearer/i, '').strip,
38
+ sec_fetch_dest: request.env[SEC_FETCH_DEST_HEADER],
39
+ accept: request.env[ACCEPT_HEADER].to_s,
40
+ origin: request.env[ORIGIN_HEADER].to_s,
41
+ host: request.host,
42
+ port: request.port
43
+ })
44
+ end
45
+
46
+ ##
47
+ # The following properties are part of the props supported in all the AuthenticateContext
48
+ # objects across all of our SDKs (eg JS, Go)
49
+ def secret_key
50
+ @config.api_key.to_s
51
+ end
52
+
53
+ def publishable_key
54
+ @config.publishable_key.to_s
55
+ end
56
+
57
+ def domain
58
+ # TODO(dimkl): Add multi-domain support
59
+ ""
60
+ end
61
+
62
+ def is_satellite?
63
+ # TODO(dimkl): Add multi-domain support
64
+ false
65
+ end
66
+
67
+ def proxy_url
68
+ # TODO(dimkl): Add multi-domain support
69
+ ""
70
+ end
71
+
72
+ def handshake_token
73
+ @handshake_token ||= retrieve_from_query_string(@clerk_url, HANDSHAKE_COOKIE) || @cookies.handshake_token.to_s
74
+ end
75
+
76
+ def clerk_synced?
77
+ # TODO(dimkl): Add multi-domain support
78
+ false
79
+ end
80
+
81
+ def clerk_redirect_url
82
+ # TODO(dimkl): Add multi-domain support
83
+ ""
84
+ end
85
+
86
+ def dev_browser
87
+ @dev_browser ||= retrieve_from_query_string(@clerk_url, DEV_BROWSER_COOKIE) || @cookies.dev_browser.to_s
88
+ end
89
+
90
+ # The frontend_api returned is without protocol prefix
91
+ def frontend_api
92
+ return "" if !valid_publishable_key?(publishable_key.to_s)
93
+
94
+ @frontend_api ||= if !proxy_url.empty?
95
+ proxy_url
96
+ elsif development_instance? && !domain.empty?
97
+ "clerk.#{domain}"
98
+ else
99
+ # remove $ postfix
100
+ decode_publishable_key(publishable_key).chop
101
+ end
102
+ end
103
+
104
+ def development_instance?
105
+ secret_key.start_with?("sk_test_")
106
+ end
107
+
108
+ def production_instance?
109
+ secret_key.start_with?("sk_live_")
110
+ end
111
+
112
+ def document_request?
113
+ @headers.sec_fetch_dest == "document"
114
+ end
115
+
116
+ def accepts_html?
117
+ @headers.accept && @headers.accept.start_with?('text/html')
118
+ end
119
+
120
+ def eligible_for_multi_domain?
121
+ is_satellite? && document_request? && !clerk_synced?
122
+ end
123
+
124
+ def active_client?
125
+ @cookies.client_uat.to_i > 0
126
+ end
127
+
128
+ def cross_origin_request?
129
+ # origin contains scheme+host and optionally port (omitted if 80 or 443)
130
+ # ref. https://www.rfc-editor.org/rfc/rfc6454#section-6.1
131
+ return false if @headers.origin.nil?
132
+
133
+ # strip scheme
134
+ origin = @headers.origin.strip.sub(/\A(\w+:)?\/\//, '')
135
+ return false if origin.empty?
136
+
137
+ # Rack's host and port helpers are reverse-proxy-aware; that
138
+ # is, they prefer the de-facto X-Forwarded-* headers if they're set
139
+ request_host = @headers.host
140
+ request_host << ":#{@headers.port}" if @headers.port != 80 && @headers.port != 443
141
+
142
+ origin != request_host
143
+ end
144
+
145
+ def dev_browser?
146
+ !dev_browser.empty?
147
+ end
148
+
149
+ def session_token_in_header?
150
+ !session_token_in_header.to_s.empty?
151
+ end
152
+
153
+ def handshake_token?
154
+ !handshake_token.to_s.empty?
155
+ end
156
+
157
+ def session_token_in_cookie?
158
+ !session_token_in_cookie.to_s.empty?
159
+ end
160
+
161
+ private
162
+
163
+ def valid_publishable_key?(pk)
164
+ valid_publishable_key_prefix?(pk) && valid_publishable_key_postfix?(pk)
165
+ end
166
+
167
+ def valid_publishable_key_prefix?(pk)
168
+ pk.start_with?("pk_live_") || pk.start_with?("pk_test_")
169
+ end
170
+
171
+ def valid_publishable_key_postfix?(pk)
172
+ decode_publishable_key(pk).end_with?("$")
173
+ end
174
+
175
+ def decode_publishable_key(pk)
176
+ Base64.decode64(pk.split("_")[2].to_s)
177
+ end
178
+
179
+ def retrieve_from_query_string(url, key)
180
+ Rack::Utils.parse_query(url.query)[key]
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,253 @@
1
+ module Clerk
2
+ ##
3
+ # This class represents a service object used to determine the current request state
4
+ # for the current env passed based on a provided Clerk::AuthenticateContext.
5
+ # There is only 1 public method exposed (`resolve`) to be invoked with a env parameter.
6
+ class AuthenticateRequest
7
+ attr_reader :auth_context
8
+
9
+ ##
10
+ # Creates a new instance using Clerk::AuthenticateContext object.
11
+ def initialize(auth_context)
12
+ @auth_context = auth_context
13
+ end
14
+
15
+ ##
16
+ # Determines the current request state by verifying a Clerk token in headers or cookies.
17
+ # The possible outcomes of this method are `signed-in`, `signed-out` or `handshake` states.
18
+ # The return values are the same as a return value of a rack middleware `[http_status_code, headers, body]`.
19
+ # When used in a middleware the consumer of this service should return the return value when there is an
20
+ # `http_status_code` provided otherwise the should continue with the middleware chain.
21
+ # The headers provided in the return value is a hash of { header_key => header_value } and in the case
22
+ # of a `Set-Cookie` header the `header_value` used is a list of raw HTTP Set-Cookie directives.
23
+ def resolve(env)
24
+ if auth_context.session_token_in_header?
25
+ resolve_header_token(env)
26
+ else
27
+ resolve_cookie_token(env)
28
+ end
29
+ end
30
+
31
+ protected
32
+
33
+ def resolve_header_token(env)
34
+ begin
35
+ # malformed JWT
36
+ return signed_out(reason: TokenVerificationErrorReason::TOKEN_INVALID) if !sdk.decode_token(auth_context.session_token_in_header)
37
+
38
+ claims = verify_token(auth_context.session_token_in_header)
39
+ return signed_in(env, claims, auth_context.session_token_in_header) if claims
40
+ rescue JWT::DecodeError
41
+ # malformed JSON authorization header
42
+ return signed_out(reason: TokenVerificationErrorReason::TOKEN_INVALID)
43
+ rescue JWT::ExpiredSignature
44
+ return signed_out(enforce_auth: true, reason: TokenVerificationErrorReason::TOKEN_EXPIRED)
45
+ rescue JWT::InvalidIatError
46
+ return signed_out(enforce_auth: true, reason: TokenVerificationErrorReason::TOKEN_NOT_ACTIVE_YET)
47
+ end
48
+
49
+ # Clerk.js should refresh the token and retry
50
+ return signed_out(enforce_auth: true)
51
+ end
52
+
53
+ def resolve_cookie_token(env)
54
+ # in cross-origin XHRs the use of Authorization header is mandatory.
55
+ # TODO: add reason
56
+ return signed_out if auth_context.cross_origin_request?
57
+
58
+ if auth_context.handshake_token?
59
+ return resolve_handshake(env)
60
+ end
61
+
62
+ if auth_context.development_instance? && auth_context.dev_browser?
63
+ return handle_handshake_maybe_status(env, reason: AuthErrorReason::DEV_BROWSER_SYNC)
64
+ end
65
+
66
+ # TODO(dimkl): Add multi-domain support for production
67
+ # if auth_context.production_instance? && auth_context.eligible_for_multi_domain?
68
+ # return handle_handshake_maybe_status(env, reason: AuthErrorReason::SATELLITE_COOKIE_NEEDS_SYNCING)
69
+ # end
70
+
71
+ # TODO(dimkl): Add multi-domain support for development
72
+ # if auth_context.development_instance? && auth_context.eligible_for_multi_domain?
73
+ # trigger handshake using auth_context.sign_in_url as base redirect_url
74
+ # return handle_handshake_maybe_status(env, reason: AuthErrorReason::SATELLITE_COOKIE_NEEDS_SYNCING, '', headers);
75
+ # end
76
+
77
+ # TODO(dimkl): Add multi-domain support for development in primary
78
+ # if auth_context.development_instance? && !auth_context.is_satellite? && auth_context.clerk_redirect_url
79
+ # trigger handshake using auth_context.clerk_redirect_url as base redirect_url + mark it as clerk_synced
80
+ # return handle_handshake_maybe_status(env, reason: AuthErrorReason::PRIMARY_RESPONDS_TO_SYNCING, '', headers);
81
+ # end
82
+
83
+ if !auth_context.active_client? && !auth_context.session_token_in_cookie?
84
+ return signed_out(reason: AuthErrorReason::SESSION_TOKEN_AND_UAT_MISSING)
85
+ end
86
+
87
+ # This can eagerly run handshake since client_uat is SameSite=Strict in dev
88
+ if !auth_context.active_client? && auth_context.session_token_in_cookie?
89
+ return handle_handshake_maybe_status(env, reason: AuthErrorReason::SESSION_TOKEN_WITHOUT_CLIENT_UAT)
90
+ end
91
+
92
+ if auth_context.active_client? && !auth_context.session_token_in_cookie?
93
+ return handle_handshake_maybe_status(env, reason: AuthErrorReason::CLIENT_UAT_WITHOUT_SESSION_TOKEN)
94
+ end
95
+
96
+ begin
97
+ token = verify_token(auth_context.session_token_in_cookie)
98
+ return signed_out if !token
99
+
100
+ if token["iat"] < auth_context.client_uat
101
+ return handle_handshake_maybe_status(env, reason: AuthErrorReason::SESSION_TOKEN_OUTDATED);
102
+ end
103
+
104
+ return signed_in(env, token, auth_context.session_token_in_cookie)
105
+ rescue JWT::ExpiredSignature
106
+ handshake(env, reason: TokenVerificationErrorReason::TOKEN_EXPIRED)
107
+ rescue JWT::InvalidIatError
108
+ handshake(env, reason: TokenVerificationErrorReason::TOKEN_NOT_ACTIVE_YET)
109
+ end
110
+
111
+ signed_out
112
+ end
113
+
114
+ def resolve_handshake(env)
115
+ headers = {
116
+ "Access-Control-Allow-Origin" => "null",
117
+ "Access-Control-Allow-Credentials" => "true"
118
+ }
119
+
120
+ session_token = ''
121
+
122
+ # Return signed-out outcome if the handshake verification fails
123
+ handshake_payload = verify_token(auth_context.handshake_token)
124
+ return signed_out(enforce_auth: true, reason: TokenVerificationErrorReason::JWK_FAILED_TO_RESOLVE) if !handshake_payload
125
+
126
+ # Retrieve the cookie directives included in handshake token payload and convert it to set-cookie headers
127
+ # Also retrieve the session token separately to determine the outcome of the request
128
+ cookies_to_set = handshake_payload[HANDSHAKE_COOKIE_DIRECTIVES_KEY] || []
129
+ cookies_to_set.each do |cookie|
130
+ headers[COOKIE_HEADER] ||= []
131
+ headers[COOKIE_HEADER] << cookie
132
+
133
+ if cookie.start_with?("#{SESSION_COOKIE}=")
134
+ session_token = cookie.split(';')[0].split('=')[1]
135
+ end
136
+ end
137
+
138
+ # Clear handshake token from query params and set headers to redirect to the initial request url
139
+ if auth_context.development_instance?
140
+ redirect_url = auth_context.clerk_url.dup
141
+ remove_from_query_string(redirect_url, HANDSHAKE_COOKIE)
142
+ remove_from_query_string(redirect_url, HANDSHAKE_HELP_QUERY_PARAM)
143
+
144
+ headers[LOCATION_HEADER] = redirect_url.to_s
145
+ end
146
+
147
+ if !session_token
148
+ return signed_out(reason: AuthErrorReason::SESSION_TOKEN_MISSING, headers: headers)
149
+ end
150
+
151
+ verify_token_with_retry(env, session_token)
152
+ end
153
+
154
+ def handle_handshake_maybe_status(env, **opts)
155
+ return signed_out if !eligible_for_handshake?
156
+ handshake(env, **opts)
157
+ end
158
+
159
+ # A outcome
160
+ def handshake(env, **opts)
161
+ redirect_headers = { LOCATION_HEADER => redirect_to_handshake }
162
+ [307, debug_auth_headers(**opts).merge(redirect_headers), []]
163
+ end
164
+
165
+ # B outcome
166
+ def signed_out(**opts)
167
+ headers = opts.delete(:headers) || {}
168
+ enforce_auth = opts.delete(:enforce_auth)
169
+
170
+ if enforce_auth
171
+ [401, debug_auth_headers(**opts).merge(headers), []]
172
+ else
173
+ [nil, headers, []]
174
+ end
175
+ end
176
+
177
+ # C outcome
178
+ def signed_in(env, claims, token, **headers)
179
+ env["clerk"] = ProxyV2.new(session_claims: claims, session_token: token)
180
+ [nil, headers, []]
181
+ end
182
+
183
+ def eligible_for_handshake?
184
+ auth_context.document_request? || (!auth_context.document_request? && auth_context.accepts_html?)
185
+ end
186
+
187
+ private
188
+
189
+ def redirect_to_handshake
190
+ redirect_url = auth_context.clerk_url.dup
191
+ remove_from_query_string(redirect_url, DEV_BROWSER_COOKIE)
192
+
193
+ handshake_url = URI.parse("https://#{auth_context.frontend_api}/v1/client/handshake")
194
+ handshake_url_qs = Rack::Utils.parse_query(handshake_url.query)
195
+ handshake_url_qs["redirect_url"] = redirect_url
196
+
197
+ if auth_context.development_instance? && auth_context.dev_browser?
198
+ handshake_url_qs[DEV_BROWSER_COOKIE] = auth_context.dev_browser
199
+ end
200
+
201
+ handshake_url.query = handshake_url_qs.to_query
202
+ handshake_url.to_s
203
+ end
204
+
205
+ def remove_from_query_string(url, key)
206
+ qs = Rack::Utils.parse_query(url.query)
207
+ qs.delete(key)
208
+ url.query = qs.to_query
209
+ end
210
+
211
+ def verify_token(token, **opts)
212
+ return false if token.nil? || token.strip.empty?
213
+
214
+ begin
215
+ sdk.verify_token(token, **opts)
216
+ rescue JWT::ExpiredSignature, JWT::InvalidIatError => e
217
+ raise e
218
+ rescue JWT::DecodeError, JWT::RequiredDependencyError => e
219
+ false
220
+ end
221
+ end
222
+
223
+ # Verify session token and provide a 1-day leeway for development if initial verification
224
+ # fails for development instance due to invalid exp or iat
225
+ def verify_token_with_retry(env, token)
226
+ claims = verify_token(token)
227
+ return signed_in(env, claims, token) if claims
228
+ rescue JWT::ExpiredSignature, JWT::InvalidIatError => e
229
+ if auth_context.development_instance?
230
+ # TODO: log possible Clock skew detected
231
+
232
+ # Retry with a generous clock skew allowance (1 day)
233
+ claims = verify_token(token, timeout: 86_400)
234
+ return signed_in(env, claims, token) if claims
235
+ end
236
+
237
+ # Raise error if handshake resolution fails in production
238
+ raise e
239
+ end
240
+
241
+ def sdk
242
+ Clerk::SDK.new
243
+ end
244
+
245
+ def debug_auth_headers(reason: nil, message: nil, status: nil)
246
+ {
247
+ AUTH_REASON_HEADER => reason,
248
+ AUTH_MESSAGE_HEADER => message,
249
+ AUTH_STATUS_HEADER => status,
250
+ }.compact
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,50 @@
1
+ module Clerk
2
+ SESSION_COOKIE = "__session".freeze
3
+ CLIENT_UAT_COOKIE = "__client_uat".freeze
4
+
5
+ # Dev Browser
6
+ DEV_BROWSER_COOKIE = "__clerk_db_jwt".freeze
7
+
8
+ # Handshake
9
+ HANDSHAKE_COOKIE = "__clerk_handshake".freeze
10
+ HANDSHAKE_HELP_QUERY_PARAM = "__clerk_help".freeze
11
+ HANDSHAKE_COOKIE_DIRECTIVES_KEY = "handshake".freeze
12
+
13
+ # auth debug response headers
14
+ AUTH_STATUS_HEADER = "X-Clerk-Auth-Status".freeze
15
+ AUTH_REASON_HEADER = "X-Clerk-Auth-Reason".freeze
16
+ AUTH_MESSAGE_HEADER = "X-Clerk-Auth-Message".freeze
17
+
18
+ #
19
+ CONTENT_TYPE_HEADER = "Content-Type".freeze
20
+ SEC_FETCH_DEST_HEADER = "HTTP_SEC_FETCH_DEST".freeze
21
+
22
+ # headers used in response - should be lowered case and without http prefix
23
+ LOCATION_HEADER = "Location".freeze
24
+ COOKIE_HEADER = "Set-Cookie".freeze
25
+
26
+ # clerk url related headers
27
+ AUTHORIZATION_HEADER = "HTTP_AUTHORIZATION".freeze
28
+ ACCEPT_HEADER = "HTTP_ACCEPT".freeze
29
+ USER_AGENT_HEADER = "HTTP_USER_AGENT".freeze
30
+ ORIGIN_HEADER = "HTTP_ORIGIN".freeze
31
+
32
+ module TokenVerificationErrorReason
33
+ TOKEN_INVALID = "token-invalid".freeze
34
+ TOKEN_EXPIRED = "token-expired".freeze
35
+ TOKEN_NOT_ACTIVE_YET = "token-not-active-yet".freeze
36
+ JWK_FAILED_TO_RESOLVE = "jwk-failed-to-resolve".freeze
37
+ end
38
+
39
+ module AuthErrorReason
40
+ CLIENT_UAT_WITHOUT_SESSION_TOKEN = "client-uat-but-no-session-token".freeze
41
+ DEV_BROWSER_SYNC = "dev-browser-sync".freeze
42
+ PRIMARY_RESPONDS_TO_SYNCING = "primary-responds-to-syncing".freeze
43
+ SATELLITE_COOKIE_NEEDS_SYNCING = "satellite-needs-syncing".freeze
44
+ SESSION_TOKEN_AND_UAT_MISSING = "session-token-and-uat-missing".freeze
45
+ SESSION_TOKEN_MISSING = "session-token-missing".freeze
46
+ SESSION_TOKEN_OUTDATED = "session-token-outdated".freeze
47
+ SESSION_TOKEN_WITHOUT_CLIENT_UAT = "session-token-but-no-client-uat".freeze
48
+ UNEXPECTED_ERROR = "unexpected-error".freeze
49
+ end
50
+ end
@@ -17,7 +17,7 @@ module Clerk
17
17
  attr_reader :session_id, :error
18
18
  def initialize(env)
19
19
  req = Rack::Request.new(env)
20
- @token = req.cookies["__session"]
20
+ @token = req.cookies[SESSION_COOKIE]
21
21
  @session_id = req.params["_clerk_session_id"]
22
22
  @session = nil
23
23
  @user_id = nil
@@ -1,4 +1,6 @@
1
1
  require "clerk"
2
+ require_relative "authenticate_context"
3
+ require_relative "authenticate_request"
2
4
 
3
5
  module Clerk
4
6
  class ProxyV2
@@ -48,6 +50,18 @@ module Clerk
48
50
  @session_claims["org_id"]
49
51
  end
50
52
 
53
+ def org_role
54
+ return nil if @session_claims.nil?
55
+
56
+ @session_claims["org_role"]
57
+ end
58
+
59
+ def org_permissions
60
+ return nil if @session_claims.nil?
61
+
62
+ @session_claims["org_permissions"]
63
+ end
64
+
51
65
  private
52
66
 
53
67
  def fetch_user(user_id)
@@ -107,160 +121,47 @@ module Clerk
107
121
  end
108
122
 
109
123
  env["clerk"] = Clerk::ProxyV2.new
110
- header_token = req.env["HTTP_AUTHORIZATION"]
111
- header_token = header_token.strip.sub(/\ABearer /, '') if header_token
112
- cookie_token = req.cookies["__session"]
113
- client_uat = req.cookies["__client_uat"]
114
-
115
- ##########################################################################
116
- # #
117
- # HEADER AUTHENTICATION #
118
- # #
119
- ##########################################################################
120
- if header_token
121
- begin
122
- return signed_out(env) if !sdk.decode_token(header_token) # malformed JWT
123
- rescue JWT::DecodeError
124
- return signed_out(env) # malformed JSON authorization header
125
- end
126
124
 
127
- begin
128
- token = verify_token(header_token)
129
- return signed_in(env, token, header_token) if token
130
- rescue JWT::ExpiredSignature, JWT::InvalidIatError
131
- unknown(interstitial: false)
132
- end
125
+ auth_context = AuthenticateContext.new(req, Clerk.configuration)
126
+ auth_request = AuthenticateRequest.new(auth_context)
133
127
 
134
- # Clerk.js should refresh the token and retry
135
- return unknown(interstitial: false)
136
- end
128
+ status, auth_request_headers, body = auth_request.resolve(env)
129
+ return [status, auth_request_headers, body] if status
130
+
131
+ status, headers, body = @app.call(env)
137
132
 
138
- # in cross-origin XHRs the use of Authorization header is mandatory.
139
- if cross_origin_request?(req)
140
- return signed_out(env)
141
- end
133
+ if !auth_request_headers.empty?
134
+ # Remove them to avoid overriding existing cookies set in headers by other middlewares
135
+ auth_request_cookies = auth_request_headers.delete(COOKIE_HEADER)
136
+ # merge non-cookie related headers into response headers
137
+ headers.merge!(auth_request_headers)
142
138
 
143
- if development_or_staging? && !browser_request?(req)
144
- # the interstitial won't work if the user agent is not a browser, so
145
- # short-circuit and avoid rendering it
146
- #
147
- # We only limit this to dev/stg because we're not yet sure how robust
148
- # this strategy is, yet. In the future, we might enable it for prod too.
149
- return signed_out(env)
139
+ set_cookie_headers!(headers, auth_request_cookies) if auth_request_cookies
150
140
  end
151
141
 
152
- ##########################################################################
153
- # #
154
- # COOKIE AUTHENTICATION #
155
- # #
156
- ##########################################################################
157
- if development_or_staging? && (req.referrer.nil? || cross_origin_request?(req))
158
- return unknown(interstitial: true)
159
- end
160
-
161
- if production? && client_uat.nil?
162
- return signed_out(env)
163
- end
164
-
165
- if client_uat == "0"
166
- return signed_out(env)
167
- end
168
-
169
- begin
170
- token = verify_token(cookie_token)
171
- return signed_out(env) if !token
172
-
173
- if token["iat"] && client_uat && Integer(client_uat) <= token["iat"]
174
- return signed_in(env, token, cookie_token)
175
- end
176
- rescue JWT::ExpiredSignature, JWT::InvalidIatError
177
- unknown(interstitial: true)
178
- end
179
-
180
- unknown(interstitial: true)
142
+ [status, headers, body]
181
143
  end
182
144
 
183
145
  private
184
146
 
185
- # Outcome A
186
- def signed_in(env, claims, token)
187
- env["clerk"] = ProxyV2.new(session_claims: claims, session_token: token)
188
-
189
- @app.call(env)
190
- end
191
-
192
- # Outcome B
193
- def signed_out(env)
194
- @app.call(env)
195
- end
196
-
197
- # Outcome C
198
- def unknown(interstitial: false, **opts)
199
- return [401, interstitial_headers(**opts), []] if !interstitial
200
-
201
- # Load Clerk.js to update the __session and __client_uat cookies.
202
- [401, interstitial_headers(**opts), [sdk.interstitial]]
203
- end
204
-
205
- def development_or_staging?
206
- Clerk.configuration.api_key &&
207
- (Clerk.configuration.api_key.start_with?("test_") ||
208
- Clerk.configuration.api_key.start_with?("sk_test_"))
209
- end
210
-
211
- def production?
212
- Clerk.configuration.api_key &&
213
- (Clerk.configuration.api_key.start_with?("live_") ||
214
- Clerk.configuration.api_key.start_with?("sk_live_"))
215
- end
216
-
217
- def cross_origin_request?(req)
218
- # origin contains scheme+host and optionally port (omitted if 80 or 443)
219
- # ref. https://www.rfc-editor.org/rfc/rfc6454#section-6.1
220
- origin = req.env["HTTP_ORIGIN"]
221
- return false if origin.nil?
222
-
223
- # strip scheme
224
- origin = origin.strip.sub(/\A(\w+:)?\/\//, '')
225
- return false if origin.empty?
226
-
227
- # Rack's host and port helpers are reverse-proxy-aware; that
228
- # is, they prefer the de-facto X-Forwarded-* headers if they're set
229
- request_host = req.host
230
- request_host << ":#{req.port}" if req.port != 80 && req.port != 443
231
-
232
- origin != request_host
233
- end
234
-
235
- def browser_request?(req)
236
- user_agent = req.env["HTTP_USER_AGENT"]
237
-
238
- !user_agent.nil? && user_agent.starts_with?("Mozilla/")
239
- end
240
-
241
- def verify_token(token)
242
- return false if token.nil? || token.strip.empty?
243
-
244
- begin
245
- sdk.verify_token(token)
246
- rescue JWT::ExpiredSignature, JWT::InvalidIatError => e
247
- raise e
248
- rescue JWT::DecodeError, JWT::RequiredDependencyError => e
249
- false
147
+ def set_cookie_headers!(headers, cookie_headers)
148
+ cookie_headers.each do |cookie_header|
149
+ cookie_key = cookie_header.split(';')[0].split('=')[0]
150
+ cookie = Rack::Utils.parse_cookies_header(cookie_header)
151
+ cookie_params = convert_http_cookie_to_cookie_setter_params(cookie, cookie_key)
152
+ Rack::Utils.set_cookie_header!(headers, cookie_key, cookie_params)
250
153
  end
251
154
  end
252
155
 
253
- def sdk
254
- Clerk::SDK.new
255
- end
256
-
257
- def interstitial_headers(reason: nil, message: nil, status: nil)
258
- {
259
- "Content-Type" => "text/html",
260
- "X-Clerk-Auth-Reason" => reason,
261
- "X-Clerk-Auth-Message" => message,
262
- "X-Clerk-Auth-Status" => status,
263
- }.compact
156
+ def convert_http_cookie_to_cookie_setter_params(cookie, cookie_key)
157
+ # convert cookie to to match cookie setter method params (lowercase symbolized keys with `:value` key)
158
+ cookie_params = cookie.transform_keys { |k| k.downcase.to_sym }
159
+ # drop the current cookie name key to avoid polluting the expected cookie params
160
+ cookie_params[:value] = cookie.delete(cookie_key)
161
+ # fix issue with cookie expiration expected to be Date type
162
+ cookie_params[:expires] = Date.parse(cookie_params[:expires]) if cookie_params[:expires]
163
+
164
+ cookie_params
264
165
  end
265
166
  end
266
167
  end
@@ -6,6 +6,5 @@ require_relative "resources/emails"
6
6
  require_relative "resources/organizations"
7
7
  require_relative "resources/phone_numbers"
8
8
  require_relative "resources/sessions"
9
- require_relative "resources/sms_messages"
10
9
  require_relative "resources/users"
11
10
  require_relative "resources/jwks"
data/lib/clerk/sdk.rb CHANGED
@@ -15,7 +15,6 @@ require_relative "resources/emails"
15
15
  require_relative "resources/organizations"
16
16
  require_relative "resources/phone_numbers"
17
17
  require_relative "resources/sessions"
18
- require_relative "resources/sms_messages"
19
18
  require_relative "resources/users"
20
19
  require_relative "resources/users"
21
20
  require_relative "resources/jwks"
@@ -97,7 +96,7 @@ module Clerk
97
96
  end
98
97
  end
99
98
 
100
- body = if response["Content-Type"] == "application/json"
99
+ body = if response[CONTENT_TYPE_HEADER] == "application/json"
101
100
  JSON.parse(response.body)
102
101
  else
103
102
  response.body
@@ -148,10 +147,6 @@ module Clerk
148
147
  Resources::Sessions.new(self)
149
148
  end
150
149
 
151
- def sms_messages
152
- Resources::SMSMessages.new(self)
153
- end
154
-
155
150
  def users
156
151
  Resources::Users.new(self)
157
152
  end
@@ -160,10 +155,6 @@ module Clerk
160
155
  Resources::JWKS.new(self)
161
156
  end
162
157
 
163
- def interstitial(refresh=false)
164
- request(:get, "internal/interstitial")
165
- end
166
-
167
158
  # Returns the decoded JWT payload without verifying if the signature is
168
159
  # valid.
169
160
  #
@@ -190,7 +181,7 @@ module Clerk
190
181
  { keys: SDK.jwks_cache.fetch(self, kid_not_found: (options[:invalidate] || options[:kid_not_found]), force_refresh: force_refresh_jwks) }
191
182
  end
192
183
 
193
- JWT.decode(token, nil, true, algorithms: algorithms, jwks: jwk_loader).first
184
+ JWT.decode(token, nil, true, algorithms: algorithms, exp_leeway: timeout, jwks: jwk_loader).first
194
185
  end
195
186
  end
196
187
  end
data/lib/clerk/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clerk
4
- VERSION = "2.11.1"
4
+ VERSION = "4.0.0.beta2"
5
5
  end
data/lib/clerk.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "clerk/version"
4
+ require_relative "clerk/constants"
4
5
  require_relative "clerk/sdk"
5
6
 
6
7
  module Clerk
@@ -16,7 +17,7 @@ module Clerk
16
17
 
17
18
  class Config
18
19
  PRODUCTION_BASE_URL = "https://api.clerk.dev/v1/".freeze
19
- attr_accessor :api_key, :base_url, :logger, :middleware_cache_store
20
+ attr_accessor :api_key, :base_url, :publishable_key, :logger, :middleware_cache_store
20
21
 
21
22
  # An array of route paths on which the middleware will not execute.
22
23
  #
@@ -50,6 +51,8 @@ module Clerk
50
51
  @api_key = secret_key
51
52
  end
52
53
 
54
+ @publishable_key = ENV.fetch("CLERK_PUBLISHABLE_KEY")
55
+
53
56
  @excluded_routes = []
54
57
  end
55
58
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: clerk-sdk-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.11.1
4
+ version: 4.0.0.beta2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Clerk
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-11-06 00:00:00.000000000 Z
11
+ date: 2024-02-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -94,6 +94,7 @@ extensions: []
94
94
  extra_rdoc_files: []
95
95
  files:
96
96
  - ".github/workflows/main.yml"
97
+ - ".github/workflows/semgrep.yml"
97
98
  - ".gitignore"
98
99
  - CHANGELOG.md
99
100
  - CODEOWNERS
@@ -109,6 +110,9 @@ files:
109
110
  - docs/clerk-logo-light.png
110
111
  - lib/clerk.rb
111
112
  - lib/clerk/authenticatable.rb
113
+ - lib/clerk/authenticate_context.rb
114
+ - lib/clerk/authenticate_request.rb
115
+ - lib/clerk/constants.rb
112
116
  - lib/clerk/errors.rb
113
117
  - lib/clerk/jwks_cache.rb
114
118
  - lib/clerk/rack_middleware.rb
@@ -126,7 +130,6 @@ files:
126
130
  - lib/clerk/resources/plural_resource.rb
127
131
  - lib/clerk/resources/sessions.rb
128
132
  - lib/clerk/resources/singular_resource.rb
129
- - lib/clerk/resources/sms_messages.rb
130
133
  - lib/clerk/resources/users.rb
131
134
  - lib/clerk/sdk.rb
132
135
  - lib/clerk/utils.rb
@@ -149,9 +152,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
149
152
  version: 2.4.0
150
153
  required_rubygems_version: !ruby/object:Gem::Requirement
151
154
  requirements:
152
- - - ">="
155
+ - - ">"
153
156
  - !ruby/object:Gem::Version
154
- version: '0'
157
+ version: 1.3.1
155
158
  requirements: []
156
159
  rubygems_version: 3.2.3
157
160
  signing_key:
@@ -1,16 +0,0 @@
1
- require "forwardable"
2
- require_relative "plural_resource"
3
-
4
- module Clerk
5
- module Resources
6
- class SMSMessages
7
- extend Forwardable
8
-
9
- def initialize(client)
10
- @resource = PluralResource.new(client, "sms_messages")
11
- end
12
-
13
- def_delegators :@resource, :create
14
- end
15
- end
16
- end