standard_id 0.2.5 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1943644d0423c990ca37c0c666c1bd23f3b968320b3e7ffd573a9c3ce7ee71e1
4
- data.tar.gz: ddd8b3e73ec3f949e0b15b680891c0721aa7ef712c95d1fdacde275688ad5539
3
+ metadata.gz: '088c979a8207e9ed4cf6cdeec8a679985271bea03989b3feeaa187bf28df95f4'
4
+ data.tar.gz: 0aee93fe07e01a10c1c5133bffac61dae72f59933999c279f1fb54ee178e27e0
5
5
  SHA512:
6
- metadata.gz: ee653052de174dc0c3fd6577a1edaaced24fdd530d883e08ded724e5a25a3657aa0e9ea3133d7609563ee2f6c34819278869b4476f97271be6c3e290160dacc3
7
- data.tar.gz: 5c745b656babf628cf122f385f42dc7dbb33ad724e36637039462d457b28aa6edde5f77acd09a5f5e1bc0af27ec191da6ef86ef7137734f45798b45f0d0e9e87
6
+ metadata.gz: 0ea65251e2b8a5599ee837d0e35ce8204ae5c98fd701fbba18fa2e53b34450af6d30fb54b89149c3539ffefbdadc60a93db9688b94469df08957a307ce62884a
7
+ data.tar.gz: bf4af5c23d299ff9488b44a4f5e3a30c59f892a155d4ca01491fb6c3b434946ba43d967f8e2e8c4d4ea4cdaa811d855bebfa8d4632430c7f779339475bdb6004
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StandardId
4
+ module Api
5
+ module WellKnown
6
+ class JwksController < ActionController::API
7
+ def show
8
+ jwks = StandardId::JwtService.jwks
9
+
10
+ if jwks.nil?
11
+ render json: { error: "JWKS not available" }, status: :not_found
12
+ return
13
+ end
14
+
15
+ response.headers["Cache-Control"] = "public, max-age=3600"
16
+ render json: jwks
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
data/config/routes/api.rb CHANGED
@@ -19,5 +19,9 @@ StandardId::ApiEngine.routes.draw do
19
19
  post ":provider", to: "providers#callback", as: :provider
20
20
  end
21
21
  end
22
+
23
+ scope ".well-known", module: :well_known do
24
+ get "jwks.json", to: "jwks#show", as: :jwks
25
+ end
22
26
  end
23
27
  end
@@ -8,6 +8,10 @@ StandardId.configure do |c|
8
8
  # c.logger = Rails.logger
9
9
  # c.web_layout = "application"
10
10
 
11
+ # JWT issuer claim (iss) - typically your application's URL
12
+ # Used in JWT tokens and verified on decode when set
13
+ # c.issuer = "https://auth.example.com"
14
+
11
15
  # Inertia.js support (requires inertia_rails gem)
12
16
  # When enabled, StandardId web controllers will render Inertia components
13
17
  # instead of ERB views. You must create the corresponding components in your
@@ -51,6 +55,32 @@ StandardId.configure do |c|
51
55
  # }
52
56
  # c.oauth.allowed_audiences = %w[web mobile admin] # Empty = no validation
53
57
 
58
+ # JWT Signing Configuration (Asymmetric Algorithms)
59
+ # By default, JWTs are signed with HS256 using Rails.application.secret_key_base.
60
+ # For asymmetric signing, configure a private key and expose the public key
61
+ # via the JWKS endpoint at /.well-known/jwks.json
62
+ #
63
+ # Algorithm choices:
64
+ # ES256 (ECDSA) - Recommended for new projects. Smaller keys, faster, modern.
65
+ # RS256 (RSA) - Wider compatibility with older systems.
66
+ #
67
+ # Generate keys:
68
+ # ES256: openssl ecparam -name prime256v1 -genkey -noout -out signing_key.pem
69
+ # RS256: openssl genrsa -out signing_key.pem 2048
70
+ #
71
+ # Option 1: Rails credentials (recommended)
72
+ # c.oauth.signing_algorithm = :es256
73
+ # c.oauth.signing_key = Rails.application.credentials.dig(:standard_id, :signing_key)
74
+ #
75
+ # Option 2: Environment variable (replace literal \n with newlines)
76
+ # Convert PEM to env-safe format: awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' signing_key.pem
77
+ # c.oauth.signing_algorithm = :es256
78
+ # c.oauth.signing_key = ENV["JWT_SIGNING_KEY"]&.gsub('\n', "\n")
79
+ #
80
+ # Option 3: File path
81
+ # c.oauth.signing_algorithm = :es256
82
+ # c.oauth.signing_key = Rails.root.join("config/signing_key.pem")
83
+
54
84
  # Events
55
85
  # Enable or disable logging emitted via the internal event system
56
86
  # c.events.enable_logging = false
@@ -51,6 +51,16 @@ StandardConfig.schema.draw do
51
51
  field :scope_claims, type: :hash, default: -> { {} }
52
52
  field :claim_resolvers, type: :hash, default: -> { {} }
53
53
  field :allowed_audiences, type: :array, default: -> { [] } # Empty = no validation, any audience allowed
54
+
55
+ # JWT signing configuration (for asymmetric algorithms)
56
+ # If nil, uses HS256 with Rails.application.secret_key_base
57
+ field :signing_key, type: :any, default: nil
58
+
59
+ # Signing algorithm (see JwtService::SUPPORTED_ALGORITHMS for full list)
60
+ # Symmetric (HMAC): :hs256, :hs384, :hs512
61
+ # Asymmetric (RSA): :rs256, :rs384, :rs512
62
+ # Asymmetric (ECDSA): :es256, :es384, :es512
63
+ field :signing_algorithm, type: :symbol, default: :hs256
54
64
  end
55
65
 
56
66
  scope :social do
@@ -1,12 +1,31 @@
1
1
  require "jwt"
2
2
  require "concurrent/delay"
3
+ require "openssl"
4
+ require "digest"
3
5
 
4
6
  module StandardId
5
7
  class JwtService
6
- ALGORITHM = "HS256"
7
8
  RESERVED_JWT_KEYS = %i[sub client_id scope grant_type exp iat aud iss nbf jti]
8
9
  BASE_SESSION_FIELDS = %i[account_id client_id scopes grant_type aud]
9
10
 
11
+ # Supported signing algorithms categorized by type
12
+ # Symmetric: use shared secret (Rails.application.secret_key_base)
13
+ # Asymmetric: use key pairs (RSA or EC private key)
14
+ SUPPORTED_ALGORITHMS = {
15
+ # HMAC (symmetric)
16
+ "HS256" => { type: :symmetric },
17
+ "HS384" => { type: :symmetric },
18
+ "HS512" => { type: :symmetric },
19
+ # RSA (asymmetric)
20
+ "RS256" => { type: :asymmetric, key_class: OpenSSL::PKey::RSA },
21
+ "RS384" => { type: :asymmetric, key_class: OpenSSL::PKey::RSA },
22
+ "RS512" => { type: :asymmetric, key_class: OpenSSL::PKey::RSA },
23
+ # ECDSA (asymmetric)
24
+ "ES256" => { type: :asymmetric, key_class: OpenSSL::PKey::EC },
25
+ "ES384" => { type: :asymmetric, key_class: OpenSSL::PKey::EC },
26
+ "ES512" => { type: :asymmetric, key_class: OpenSSL::PKey::EC }
27
+ }.freeze
28
+
10
29
  SESSION_CLASS = Concurrent::Delay.new do
11
30
  Struct.new(*(BASE_SESSION_FIELDS + claim_resolver_keys), keyword_init: true) do
12
31
  def active?
@@ -19,17 +38,73 @@ module StandardId
19
38
  SESSION_CLASS.value
20
39
  end
21
40
 
41
+ def self.algorithm
42
+ StandardId.config.oauth.signing_algorithm.to_s.upcase
43
+ end
44
+
45
+ def self.algorithm_config
46
+ SUPPORTED_ALGORITHMS[algorithm] || raise(ArgumentError, "Unsupported algorithm: #{algorithm}. Supported: #{SUPPORTED_ALGORITHMS.keys.join(', ')}")
47
+ end
48
+
49
+ def self.asymmetric?
50
+ algorithm_config[:type] == :asymmetric
51
+ end
52
+
53
+ def self.signing_key
54
+ if asymmetric?
55
+ @signing_key_cache ||= parse_private_key(StandardId.config.oauth.signing_key)
56
+ else
57
+ Rails.application.secret_key_base
58
+ end
59
+ end
60
+
61
+ def self.verification_key
62
+ if asymmetric?
63
+ key = signing_key
64
+ # For EC keys, the key itself can be used for verification
65
+ # For RSA keys, we extract the public key
66
+ key.is_a?(OpenSSL::PKey::EC) ? key : key.public_key
67
+ else
68
+ Rails.application.secret_key_base
69
+ end
70
+ end
71
+
72
+ def self.key_id
73
+ return nil unless asymmetric?
74
+
75
+ # Generate stable key ID from public key fingerprint
76
+ # Use public_to_pem which works for both RSA and EC keys
77
+ @key_id ||= Digest::SHA256.hexdigest(signing_key.public_to_pem)[0..7]
78
+ end
79
+
80
+ def self.reset_cached_key!
81
+ @key_id = nil
82
+ @signing_key_cache = nil
83
+ @jwks = nil
84
+ end
85
+
22
86
  def self.encode(payload, expires_in: 1.hour)
23
87
  payload[:exp] = expires_in.from_now.to_i
24
88
  payload[:iat] = Time.current.to_i
89
+ payload[:iss] ||= StandardId.config.issuer if StandardId.config.issuer.present?
90
+
91
+ headers = {}
92
+ headers[:kid] = key_id if asymmetric?
25
93
 
26
- JWT.encode(payload, secret_key, ALGORITHM)
94
+ JWT.encode(payload, signing_key, algorithm, headers)
27
95
  end
28
96
 
29
97
  def self.decode(token)
30
- decoded = JWT.decode(token, secret_key, true, { algorithm: ALGORITHM })
98
+ options = { algorithm: algorithm }
99
+
100
+ if StandardId.config.issuer.present?
101
+ options[:iss] = StandardId.config.issuer
102
+ options[:verify_iss] = true
103
+ end
104
+
105
+ decoded = JWT.decode(token, verification_key, true, options)
31
106
  decoded.first.with_indifferent_access
32
- rescue JWT::DecodeError, JWT::ExpiredSignature, JWT::InvalidIatError
107
+ rescue JWT::DecodeError, JWT::ExpiredSignature, JWT::InvalidIatError, JWT::InvalidIssuerError
33
108
  nil
34
109
  end
35
110
 
@@ -53,10 +128,22 @@ module StandardId
53
128
  )
54
129
  end
55
130
 
131
+ def self.jwks
132
+ return nil unless asymmetric?
133
+
134
+ @jwks ||= begin
135
+ jwk = JWT::JWK.new(verification_key, kid: key_id)
136
+ { keys: [jwk.export] }
137
+ end
138
+ end
139
+
56
140
  private
57
141
 
58
- def self.secret_key
59
- Rails.application.secret_key_base
142
+ def self.parse_private_key(key_source)
143
+ pem = key_source.is_a?(Pathname) ? File.read(key_source) : key_source
144
+ key_class = algorithm_config[:key_class]
145
+
146
+ key_class.new(pem)
60
147
  end
61
148
 
62
149
  def self.claim_resolver_keys
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.2.5"
2
+ VERSION = "0.2.6"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_id
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.5
4
+ version: 0.2.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -80,6 +80,7 @@ files:
80
80
  - app/controllers/standard_id/api/oidc/logout_controller.rb
81
81
  - app/controllers/standard_id/api/passwordless_controller.rb
82
82
  - app/controllers/standard_id/api/userinfo_controller.rb
83
+ - app/controllers/standard_id/api/well_known/jwks_controller.rb
83
84
  - app/controllers/standard_id/web/account_controller.rb
84
85
  - app/controllers/standard_id/web/auth/callback/providers_controller.rb
85
86
  - app/controllers/standard_id/web/base_controller.rb