clerk-sdk-ruby 3.2.0 → 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: 7e5b0d6f82c56ddfe8791511ecf1312aff901c8070a3b225fe9210a991002ebd
4
- data.tar.gz: 9955424ce840e42db36b246642b69983d89d3ab8c35667027d48f225a08fe9ae
3
+ metadata.gz: 7b3b121821e34e9882661931f7e5c7ccdd630095905252ba53c8a48b9c62320c
4
+ data.tar.gz: ab0b8077553effb1c8521204787499ee28e0a544629980a7b098789a5c474e14
5
5
  SHA512:
6
- metadata.gz: 2ddfdc48fa90ecb3134e881cc9a277e88af66be69239a020f9bc4bea287902186faa7ff721d322e68e96603563b96d6ac60a72b4a481c0e19be1e006516126c3
7
- data.tar.gz: 8c726270e98329aaed14a8e0a4ca54701c4cea9eccff14ecea888b639f026f9c40469cc5905f81dbe678305e295ed9995e0e198b7d408c79d938eb8d2253e165
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,13 +1,17 @@
1
- ## unreleased
2
1
 
3
- ## 3.2.0 - 2024-04-08
2
+ ## 4.0.0.beta2 - 2024-02-26
4
3
 
5
- - fix: Infinite redirect loop when client_uat=0 and __session exists (#55) [https://github.com/clerk/clerk-sdk-ruby/pull/55]
4
+ Note: this is identical to 4.0.0.beta1, which was yanked because it was not generated from the main branch.
6
5
 
7
- ## 3.1.0 - 2024-03-19
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]
8
9
 
9
- - fix: Incompatible __client_uat & __session should show interstitial (#51) [https://github.com/clerk/clerk-sdk-ruby/pull/51]
10
- - fix: Incorrect check that lead to infinite redirect loop introduced by (#51) [https://github.com/clerk/clerk-sdk-ruby/pull/51]
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]
11
15
 
12
16
  ## 3.0.0 - 2024-01-09
13
17
 
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- clerk-sdk-ruby (3.2.0)
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).
@@ -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
@@ -119,167 +121,47 @@ module Clerk
119
121
  end
120
122
 
121
123
  env["clerk"] = Clerk::ProxyV2.new
122
- header_token = req.env["HTTP_AUTHORIZATION"]
123
- header_token = header_token.strip.sub(/\ABearer /, '') if header_token
124
- cookie_token = req.cookies["__session"]
125
- client_uat = req.cookies["__client_uat"]
126
-
127
- ##########################################################################
128
- # #
129
- # HEADER AUTHENTICATION #
130
- # #
131
- ##########################################################################
132
- if header_token
133
- begin
134
- return signed_out(env) if !sdk.decode_token(header_token) # malformed JWT
135
- rescue JWT::DecodeError
136
- return signed_out(env) # malformed JSON authorization header
137
- end
138
-
139
- begin
140
- token = verify_token(header_token)
141
- return signed_in(env, token, header_token) if token
142
- rescue JWT::ExpiredSignature, JWT::InvalidIatError
143
- unknown(interstitial: false)
144
- end
145
-
146
- # Clerk.js should refresh the token and retry
147
- return unknown(interstitial: false)
148
- end
149
-
150
- # in cross-origin XHRs the use of Authorization header is mandatory.
151
- if cross_origin_request?(req)
152
- return signed_out(env)
153
- end
154
-
155
- if development_or_staging? && !browser_request?(req)
156
- # the interstitial won't work if the user agent is not a browser, so
157
- # short-circuit and avoid rendering it
158
- #
159
- # We only limit this to dev/stg because we're not yet sure how robust
160
- # this strategy is, yet. In the future, we might enable it for prod too.
161
- return signed_out(env)
162
- end
163
-
164
- ##########################################################################
165
- # #
166
- # COOKIE AUTHENTICATION #
167
- # #
168
- ##########################################################################
169
124
 
170
- if development_or_staging? && (req.referrer.nil? || cross_origin_request?(req))
171
- return unknown(interstitial: true)
172
- end
173
-
174
- # Show interstitial when there is no client_uat and cookie token
175
- if client_uat.to_s.empty? && cookie_token.to_s.empty?
176
- return unknown(interstitial: true)
177
- end
178
-
179
- if client_uat == "0"
180
- return signed_out(env)
181
- end
125
+ auth_context = AuthenticateContext.new(req, Clerk.configuration)
126
+ auth_request = AuthenticateRequest.new(auth_context)
182
127
 
183
- # Show interstitial when there is client_uat is incompatible with cookie token
184
- has_cookie_token_without_client = client_uat.to_s.empty? && cookie_token
185
- has_client_without_cookie_token = client_uat.to_s != "" && cookie_token.to_s.empty?
186
- return unknown(interstitial: true) if has_cookie_token_without_client || has_client_without_cookie_token
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)
187
132
 
188
- begin
189
- token = verify_token(cookie_token)
190
- return signed_out(env) if !token
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)
191
138
 
192
- if token["iat"] && client_uat && Integer(client_uat) <= token["iat"]
193
- return signed_in(env, token, cookie_token)
194
- end
195
- rescue JWT::ExpiredSignature, JWT::InvalidIatError
196
- unknown(interstitial: true)
139
+ set_cookie_headers!(headers, auth_request_cookies) if auth_request_cookies
197
140
  end
198
141
 
199
- unknown(interstitial: true)
142
+ [status, headers, body]
200
143
  end
201
144
 
202
145
  private
203
146
 
204
- # Outcome A
205
- def signed_in(env, claims, token)
206
- env["clerk"] = ProxyV2.new(session_claims: claims, session_token: token)
207
-
208
- @app.call(env)
209
- end
210
-
211
- # Outcome B
212
- def signed_out(env)
213
- @app.call(env)
214
- end
215
-
216
- # Outcome C
217
- def unknown(interstitial: false, **opts)
218
- return [401, interstitial_headers(**opts), []] if !interstitial
219
-
220
- # Load Clerk.js to update the __session and __client_uat cookies.
221
- [401, interstitial_headers(**opts), [sdk.interstitial]]
222
- end
223
-
224
- def development_or_staging?
225
- Clerk.configuration.api_key &&
226
- (Clerk.configuration.api_key.start_with?("test_") ||
227
- Clerk.configuration.api_key.start_with?("sk_test_"))
228
- end
229
-
230
- def production?
231
- Clerk.configuration.api_key &&
232
- (Clerk.configuration.api_key.start_with?("live_") ||
233
- Clerk.configuration.api_key.start_with?("sk_live_"))
234
- end
235
-
236
- def cross_origin_request?(req)
237
- # origin contains scheme+host and optionally port (omitted if 80 or 443)
238
- # ref. https://www.rfc-editor.org/rfc/rfc6454#section-6.1
239
- origin = req.env["HTTP_ORIGIN"]
240
- return false if origin.nil?
241
-
242
- # strip scheme
243
- origin = origin.strip.sub(/\A(\w+:)?\/\//, '')
244
- return false if origin.empty?
245
-
246
- # Rack's host and port helpers are reverse-proxy-aware; that
247
- # is, they prefer the de-facto X-Forwarded-* headers if they're set
248
- request_host = req.host
249
- request_host << ":#{req.port}" if req.port != 80 && req.port != 443
250
-
251
- origin != request_host
252
- end
253
-
254
- def browser_request?(req)
255
- user_agent = req.env["HTTP_USER_AGENT"]
256
-
257
- !user_agent.nil? && user_agent.starts_with?("Mozilla/")
258
- end
259
-
260
- def verify_token(token)
261
- return false if token.nil? || token.strip.empty?
262
-
263
- begin
264
- sdk.verify_token(token)
265
- rescue JWT::ExpiredSignature, JWT::InvalidIatError => e
266
- raise e
267
- rescue JWT::DecodeError, JWT::RequiredDependencyError => e
268
- 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)
269
153
  end
270
154
  end
271
155
 
272
- def sdk
273
- Clerk::SDK.new
274
- end
275
-
276
- def interstitial_headers(reason: nil, message: nil, status: nil)
277
- {
278
- "Content-Type" => "text/html",
279
- "X-Clerk-Auth-Reason" => reason,
280
- "X-Clerk-Auth-Message" => message,
281
- "X-Clerk-Auth-Status" => status,
282
- }.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
283
165
  end
284
166
  end
285
167
  end
data/lib/clerk/sdk.rb CHANGED
@@ -96,7 +96,7 @@ module Clerk
96
96
  end
97
97
  end
98
98
 
99
- body = if response["Content-Type"] == "application/json"
99
+ body = if response[CONTENT_TYPE_HEADER] == "application/json"
100
100
  JSON.parse(response.body)
101
101
  else
102
102
  response.body
@@ -155,10 +155,6 @@ module Clerk
155
155
  Resources::JWKS.new(self)
156
156
  end
157
157
 
158
- def interstitial(refresh=false)
159
- request(:get, "internal/interstitial")
160
- end
161
-
162
158
  # Returns the decoded JWT payload without verifying if the signature is
163
159
  # valid.
164
160
  #
@@ -185,7 +181,7 @@ module Clerk
185
181
  { keys: SDK.jwks_cache.fetch(self, kid_not_found: (options[:invalidate] || options[:kid_not_found]), force_refresh: force_refresh_jwks) }
186
182
  end
187
183
 
188
- 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
189
185
  end
190
186
  end
191
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 = "3.2.0"
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: 3.2.0
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: 2024-04-08 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
@@ -148,9 +152,9 @@ required_ruby_version: !ruby/object:Gem::Requirement
148
152
  version: 2.4.0
149
153
  required_rubygems_version: !ruby/object:Gem::Requirement
150
154
  requirements:
151
- - - ">="
155
+ - - ">"
152
156
  - !ruby/object:Gem::Version
153
- version: '0'
157
+ version: 1.3.1
154
158
  requirements: []
155
159
  rubygems_version: 3.2.3
156
160
  signing_key: