Skip to content

Commit 15bfb92

Browse files
Merge pull request #46 from stytchauth/nikhil/jwt-auth
feat: Add Session JWT support
2 parents 8d47423 + ceaf68d commit 15bfb92

12 files changed

Lines changed: 188 additions & 7 deletions

File tree

lib/stytch/client.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def initialize(env:, project_id:, secret:, &block)
2626
@magic_links = Stytch::MagicLinks.new(@connection)
2727
@oauth = Stytch::OAuth.new(@connection)
2828
@otps = Stytch::OTPs.new(@connection)
29-
@sessions = Stytch::Sessions.new(@connection)
29+
@sessions = Stytch::Sessions.new(@connection, @project_id)
3030
@totps = Stytch::TOTPs.new(@connection)
3131
@webauthn = Stytch::WebAuthn.new(@connection)
3232
@crypto_wallets = Stytch::CryptoWallets.new(@connection)

lib/stytch/crypto_wallets.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ def authenticate(
3232
crypto_wallet_type:,
3333
signature:,
3434
session_token: nil,
35+
session_jwt: nil,
3536
session_duration_minutes: nil
3637
)
3738
request = {
@@ -41,6 +42,7 @@ def authenticate(
4142
}
4243

4344
request[:session_token] = session_token unless session_token.nil?
45+
request[:session_jwt] = session_jwt unless session_jwt.nil?
4446
request[:session_duration_minutes] = session_duration_minutes unless session_duration_minutes.nil?
4547

4648
post_request("#{PATH}/authenticate", request)

lib/stytch/errors.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
module Stytch
2+
class JWTInvalidIssuerError < StandardError
3+
def initialize(msg="JWT issuer did not match")
4+
super
5+
end
6+
end
7+
8+
class JWTInvalidAudienceError < StandardError
9+
def initialize(msg="JWT audience did not match")
10+
super
11+
end
12+
end
13+
14+
class JWTExpiredSignatureError < StandardError
15+
def initialize(msg="JWT signature has expired")
16+
super
17+
end
18+
end
19+
20+
class JWTIncorrectAlgorithmError < StandardError
21+
def initialize(msg="JWT algorithm is incorrect")
22+
super
23+
end
24+
end
25+
end

lib/stytch/magic_links.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ def authenticate(
3636
attributes: {},
3737
options: {},
3838
session_token: nil,
39+
session_jwt: nil,
3940
session_duration_minutes: nil
4041
)
4142
request = {
@@ -45,6 +46,7 @@ def authenticate(
4546
request[:attributes] = attributes if attributes != {}
4647
request[:options] = options if options != {}
4748
request[:session_token] = session_token unless session_token.nil?
49+
request[:session_jwt] = session_jwt unless session_jwt.nil?
4850
request[:session_duration_minutes] = session_duration_minutes unless session_duration_minutes.nil?
4951

5052
post_request("#{PATH}/authenticate", request)

lib/stytch/oauth.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@ def authenticate(
1616
token:,
1717
session_management_type: nil,
1818
session_token: nil,
19+
session_jwt: nil,
1920
session_duration_minutes: nil
2021
)
2122
request = {
2223
token: token
2324
}
2425
request[:session_management_type] = session_management_type unless session_management_type.nil?
2526
request[:session_token] = session_token unless session_token.nil?
27+
request[:session_jwt] = session_jwt unless session_jwt.nil?
2628
request[:session_duration_minutes] = session_duration_minutes unless session_duration_minutes.nil?
2729

2830
post_request("#{PATH}/authenticate", request)

lib/stytch/otps.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ def authenticate(
2424
attributes: {},
2525
options: {},
2626
session_token: nil,
27+
session_jwt: nil,
2728
session_duration_minutes: nil
2829
)
2930
request = {
@@ -34,6 +35,7 @@ def authenticate(
3435
request[:attributes] = attributes if attributes != {}
3536
request[:options] = options if options != {}
3637
request[:session_token] = session_token unless session_token.nil?
38+
request[:session_jwt] = session_jwt unless session_jwt.nil?
3739
request[:session_duration_minutes] = session_duration_minutes unless session_duration_minutes.nil?
3840

3941
post_request("#{PATH}/authenticate", request)

lib/stytch/sessions.rb

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# frozen_string_literal: true
22

3+
require 'jwt'
4+
require 'json/jwt'
5+
6+
require_relative 'errors'
37
require_relative 'request_helper'
48

59
module Stytch
@@ -8,8 +12,12 @@ class Sessions
812

913
PATH = '/v1/sessions'
1014

11-
def initialize(connection)
15+
def initialize(connection, project_id)
1216
@connection = connection
17+
@project_id = project_id
18+
@jwks_loader = ->(options) do
19+
options[:invalidate] ? jwks(project_id: @project_id) : {}
20+
end
1321
end
1422

1523
def get(user_id:)
@@ -23,13 +31,14 @@ def get(user_id:)
2331
end
2432

2533
def authenticate(
26-
session_token:,
34+
session_token: nil,
35+
session_jwt: nil,
2736
session_duration_minutes: nil
2837
)
29-
request = {
30-
session_token: session_token
31-
}
38+
request = {}
3239

40+
request[:session_token] = session_token unless session_token.nil?
41+
request[:session_jwt] = session_jwt unless session_jwt.nil?
3342
request[:session_duration_minutes] = session_duration_minutes unless session_duration_minutes.nil?
3443

3544
post_request("#{PATH}/authenticate", request)
@@ -46,5 +55,73 @@ def revoke(
4655

4756
post_request("#{PATH}/revoke", request)
4857
end
58+
59+
def jwks(project_id:)
60+
request_path = "#{PATH}/jwks/" + project_id
61+
get_request(request_path)
62+
end
63+
64+
# Parse a JWT and verify the signature. If max_token_age_seconds is unset, call the API directly
65+
# If max_token_age_seconds is set and the JWT was issued (based on the "iat" claim) less than
66+
# max_token_age_seconds seconds ago, then just verify locally and don't call the API
67+
# To force remote validation for all tokens, set max_token_age_seconds to 0 or call authenticate()
68+
def authenticate_jwt(
69+
session_jwt,
70+
max_token_age_seconds: nil,
71+
session_duration_minutes: nil
72+
)
73+
if max_token_age_seconds == 0
74+
return authenticate(
75+
session_jwt: session_jwt,
76+
session_duration_minutes: session_duration_minutes,
77+
)
78+
end
79+
80+
decoded_jwt = authenticate_jwt_local(session_jwt)
81+
iat_time = Time.at(decoded_jwt["iat"]).to_datetime
82+
if iat_time + max_token_age_seconds >= Time.now
83+
session = marshal_jwt_into_session(decoded_jwt)
84+
return {"session" => session}
85+
else
86+
return authenticate(
87+
session_jwt: session_jwt,
88+
session_duration_minutes: session_duration_minutes,
89+
)
90+
end
91+
end
92+
93+
# Parse a JWT and verify the signature locally (without calling /authenticate in the API)
94+
# Uses the cached value to get the JWK but if it is unavailable, it calls the get_jwks()
95+
# function to get the JWK
96+
# This method never authenticates a JWT directly with the API
97+
def authenticate_jwt_local(session_jwt)
98+
issuer = "stytch.com/" + @project_id
99+
begin
100+
decoded_token = JWT.decode session_jwt, nil, true,
101+
{ jwks: @jwks_loader, iss: issuer, verify_iss: true, aud: @project_id, verify_aud: true, algorithms: ["RS256"]}
102+
return decoded_token[0]
103+
rescue JWT::InvalidIssuerError
104+
raise JWTInvalidIssuerError
105+
rescue JWT::InvalidAudError
106+
raise JWTInvalidAudienceError
107+
rescue JWT::ExpiredSignature
108+
raise JWTExpiredSignatureError
109+
rescue JWT::IncorrectAlgorithm
110+
raise JWTIncorrectAlgorithmError
111+
end
112+
end
113+
114+
def marshal_jwt_into_session(jwt)
115+
stytch_claim = "https://stytch.com/session"
116+
return {
117+
"session_id" => jwt["jti"],
118+
"user_id" => jwt["sub"],
119+
"started_at" => jwt[stytch_claim]["started_at"],
120+
"last_accessed_at" => jwt[stytch_claim]["last_accessed_at"],
121+
"expires_at" => Time.at(jwt["exp"]).to_datetime.iso8601,
122+
"attributes" => jwt[stytch_claim]["attributes"],
123+
"authentication_factors" => jwt[stytch_claim]["authentication_factors"],
124+
}
125+
end
49126
end
50127
end

lib/stytch/totps.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ def authenticate(
2929
user_id:,
3030
totp_code:,
3131
session_token: nil,
32+
session_jwt: nil,
3233
session_duration_minutes: nil
3334
)
3435
request = {
@@ -37,6 +38,7 @@ def authenticate(
3738
}
3839

3940
request[:session_token] = session_token unless session_token.nil?
41+
request[:session_jwt] = session_jwt unless session_jwt.nil?
4042
request[:session_duration_minutes] = session_duration_minutes unless session_duration_minutes.nil?
4143

4244
post_request("#{PATH}/authenticate", request)
@@ -56,6 +58,7 @@ def recover(
5658
user_id:,
5759
recovery_code:,
5860
session_token: nil,
61+
session_jwt: nil,
5962
session_duration_minutes: nil
6063
)
6164
request = {
@@ -64,6 +67,7 @@ def recover(
6467
}
6568

6669
request[:session_token] = session_token unless session_token.nil?
70+
request[:session_jwt] = session_jwt unless session_jwt.nil?
6771
request[:session_duration_minutes] = session_duration_minutes unless session_duration_minutes.nil?
6872

6973
post_request("#{PATH}/recover", request)

lib/stytch/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# frozen_string_literal: true
22

33
module Stytch
4-
VERSION = '2.11.0'
4+
VERSION = '2.12.0'
55
end

lib/stytch/webauthn.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,15 @@ def authenticate_start(
5656
def authenticate(
5757
public_key_credential:,
5858
session_token: nil,
59+
session_jwt: nil,
5960
session_duration_minutes: nil
6061
)
6162
request = {
6263
public_key_credential: public_key_credential
6364
}
6465

6566
request[:session_token] = session_token unless session_token.nil?
67+
request[:session_jwt] = session_jwt unless session_jwt.nil?
6668
request[:session_duration_minutes] = session_duration_minutes unless session_duration_minutes.nil?
6769

6870
post_request("#{PATH}/authenticate", request)

0 commit comments

Comments
 (0)