clerk-sdk-ruby 3.0.0 → 4.0.0.beta2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.github/workflows/main.yml +2 -0
- data/.github/workflows/semgrep.yml +24 -0
- data/CHANGELOG.md +14 -1
- data/Gemfile.lock +1 -1
- data/README.md +33 -11
- data/lib/clerk/authenticate_context.rb +183 -0
- data/lib/clerk/authenticate_request.rb +253 -0
- data/lib/clerk/constants.rb +50 -0
- data/lib/clerk/rack_middleware.rb +1 -1
- data/lib/clerk/rack_middleware_v2.rb +30 -141
- data/lib/clerk/sdk.rb +2 -6
- data/lib/clerk/version.rb +1 -1
- data/lib/clerk.rb +4 -1
- metadata +9 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7b3b121821e34e9882661931f7e5c7ccdd630095905252ba53c8a48b9c62320c
|
4
|
+
data.tar.gz: ab0b8077553effb1c8521204787499ee28e0a544629980a7b098789a5c474e14
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 007f6ba6dc18e7d1e54173160e069bc76869b7c13a645eea53943669536ad3963e95272a2ace2d64cd7c9c68d3386dbe5695726e235f559f59f8b15f1addec99
|
7
|
+
data.tar.gz: a0ecb1901197fe8e2a01269326dfb61bdf6da2dd0b9c22298fb580370d6cf2527f5e5f1eaa0a19687b3d242104bf8784a740ac80bfae0df1ff576e1b03c14451
|
data/.github/workflows/main.yml
CHANGED
@@ -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,17 @@
|
|
1
|
-
|
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]
|
2
15
|
|
3
16
|
## 3.0.0 - 2024-01-09
|
4
17
|
|
data/Gemfile.lock
CHANGED
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["
|
91
|
-
c.base_url = "https://..." # if omitted: "https://api.clerk.
|
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`.
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
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[
|
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,160 +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
124
|
|
150
|
-
|
151
|
-
|
152
|
-
return signed_out(env)
|
153
|
-
end
|
125
|
+
auth_context = AuthenticateContext.new(req, Clerk.configuration)
|
126
|
+
auth_request = AuthenticateRequest.new(auth_context)
|
154
127
|
|
155
|
-
|
156
|
-
|
157
|
-
|
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
|
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)
|
163
132
|
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
if development_or_staging? && (req.referrer.nil? || cross_origin_request?(req))
|
170
|
-
return unknown(interstitial: true)
|
171
|
-
end
|
172
|
-
|
173
|
-
if production? && client_uat.nil?
|
174
|
-
return signed_out(env)
|
175
|
-
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)
|
176
138
|
|
177
|
-
|
178
|
-
return signed_out(env)
|
139
|
+
set_cookie_headers!(headers, auth_request_cookies) if auth_request_cookies
|
179
140
|
end
|
180
141
|
|
181
|
-
|
182
|
-
token = verify_token(cookie_token)
|
183
|
-
return signed_out(env) if !token
|
184
|
-
|
185
|
-
if token["iat"] && client_uat && Integer(client_uat) <= token["iat"]
|
186
|
-
return signed_in(env, token, cookie_token)
|
187
|
-
end
|
188
|
-
rescue JWT::ExpiredSignature, JWT::InvalidIatError
|
189
|
-
unknown(interstitial: true)
|
190
|
-
end
|
191
|
-
|
192
|
-
unknown(interstitial: true)
|
142
|
+
[status, headers, body]
|
193
143
|
end
|
194
144
|
|
195
145
|
private
|
196
146
|
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
# Outcome B
|
205
|
-
def signed_out(env)
|
206
|
-
@app.call(env)
|
207
|
-
end
|
208
|
-
|
209
|
-
# Outcome C
|
210
|
-
def unknown(interstitial: false, **opts)
|
211
|
-
return [401, interstitial_headers(**opts), []] if !interstitial
|
212
|
-
|
213
|
-
# Load Clerk.js to update the __session and __client_uat cookies.
|
214
|
-
[401, interstitial_headers(**opts), [sdk.interstitial]]
|
215
|
-
end
|
216
|
-
|
217
|
-
def development_or_staging?
|
218
|
-
Clerk.configuration.api_key &&
|
219
|
-
(Clerk.configuration.api_key.start_with?("test_") ||
|
220
|
-
Clerk.configuration.api_key.start_with?("sk_test_"))
|
221
|
-
end
|
222
|
-
|
223
|
-
def production?
|
224
|
-
Clerk.configuration.api_key &&
|
225
|
-
(Clerk.configuration.api_key.start_with?("live_") ||
|
226
|
-
Clerk.configuration.api_key.start_with?("sk_live_"))
|
227
|
-
end
|
228
|
-
|
229
|
-
def cross_origin_request?(req)
|
230
|
-
# origin contains scheme+host and optionally port (omitted if 80 or 443)
|
231
|
-
# ref. https://www.rfc-editor.org/rfc/rfc6454#section-6.1
|
232
|
-
origin = req.env["HTTP_ORIGIN"]
|
233
|
-
return false if origin.nil?
|
234
|
-
|
235
|
-
# strip scheme
|
236
|
-
origin = origin.strip.sub(/\A(\w+:)?\/\//, '')
|
237
|
-
return false if origin.empty?
|
238
|
-
|
239
|
-
# Rack's host and port helpers are reverse-proxy-aware; that
|
240
|
-
# is, they prefer the de-facto X-Forwarded-* headers if they're set
|
241
|
-
request_host = req.host
|
242
|
-
request_host << ":#{req.port}" if req.port != 80 && req.port != 443
|
243
|
-
|
244
|
-
origin != request_host
|
245
|
-
end
|
246
|
-
|
247
|
-
def browser_request?(req)
|
248
|
-
user_agent = req.env["HTTP_USER_AGENT"]
|
249
|
-
|
250
|
-
!user_agent.nil? && user_agent.starts_with?("Mozilla/")
|
251
|
-
end
|
252
|
-
|
253
|
-
def verify_token(token)
|
254
|
-
return false if token.nil? || token.strip.empty?
|
255
|
-
|
256
|
-
begin
|
257
|
-
sdk.verify_token(token)
|
258
|
-
rescue JWT::ExpiredSignature, JWT::InvalidIatError => e
|
259
|
-
raise e
|
260
|
-
rescue JWT::DecodeError, JWT::RequiredDependencyError => e
|
261
|
-
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)
|
262
153
|
end
|
263
154
|
end
|
264
155
|
|
265
|
-
def
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
"X-Clerk-Auth-Status" => status,
|
275
|
-
}.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
|
276
165
|
end
|
277
166
|
end
|
278
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[
|
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
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:
|
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-
|
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,11 +152,11 @@ 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:
|
157
|
+
version: 1.3.1
|
154
158
|
requirements: []
|
155
|
-
rubygems_version: 3.3
|
159
|
+
rubygems_version: 3.2.3
|
156
160
|
signing_key:
|
157
161
|
specification_version: 4
|
158
162
|
summary: Clerk SDK for Ruby.
|