11# frozen_string_literal: true
22
3+ require 'jwt'
4+ require 'json/jwt'
5+
6+ require_relative 'errors'
37require_relative 'request_helper'
48
59module 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
50127end
0 commit comments