standard_id 0.2.4 → 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: 144da6f9e7281b9b22e80c7ecdee847eb8d41f85571ab07846558f9ee565df83
4
- data.tar.gz: 5e3bdb2356d73520039fa35387a49beb55fe34501a5787510ff0359a4143470e
3
+ metadata.gz: '088c979a8207e9ed4cf6cdeec8a679985271bea03989b3feeaa187bf28df95f4'
4
+ data.tar.gz: 0aee93fe07e01a10c1c5133bffac61dae72f59933999c279f1fb54ee178e27e0
5
5
  SHA512:
6
- metadata.gz: 2b6daa2a3da9c30167cb2648c3adce23613913226c56a722106e5fc924631ee666c444273cfd5f1e603f16f6026d29183521704c947683c4cb011250c2af23e5
7
- data.tar.gz: a70e979d184c85620203291f1a67521342b2b7bb130814ecdb96f2eef384c233d5e285e557f59c6d6ac49a4512e71b7e112a1f13b73d3ce2e9b50876d6b501b2
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
@@ -44,8 +48,38 @@ StandardId.configure do |c|
44
48
  # email: ->(account:) { account.email },
45
49
  # display_name: ->(account:, client:) {
46
50
  # "#{account.name} for #{client.client_id}"
51
+ # },
52
+ # profile_id: ->(account:, audience:) {
53
+ # account.profiles.find_by(type: audience)&.id
47
54
  # }
48
55
  # }
56
+ # c.oauth.allowed_audiences = %w[web mobile admin] # Empty = no validation
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")
49
83
 
50
84
  # Events
51
85
  # Enable or disable logging emitted via the internal event system
@@ -50,6 +50,17 @@ StandardConfig.schema.draw do
50
50
  field :client_secret, type: :string, default: nil
51
51
  field :scope_claims, type: :hash, default: -> { {} }
52
52
  field :claim_resolvers, type: :hash, default: -> { {} }
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
53
64
  end
54
65
 
55
66
  scope :social do
@@ -1,11 +1,30 @@
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
- BASE_SESSION_FIELDS = %i[account_id client_id scopes grant_type]
9
+ BASE_SESSION_FIELDS = %i[account_id client_id scopes grant_type aud]
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
9
28
 
10
29
  SESSION_CLASS = Concurrent::Delay.new do
11
30
  Struct.new(*(BASE_SESSION_FIELDS + claim_resolver_keys), keyword_init: true) do
@@ -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
 
@@ -49,13 +124,26 @@ module StandardId
49
124
  client_id: payload[:client_id],
50
125
  scopes: scopes,
51
126
  grant_type: payload[:grant_type],
127
+ aud: payload[:aud]
52
128
  )
53
129
  end
54
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
+
55
140
  private
56
141
 
57
- def self.secret_key
58
- 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)
59
147
  end
60
148
 
61
149
  def self.claim_resolver_keys
@@ -66,6 +66,10 @@ module StandardId
66
66
  def token_account
67
67
  @authorization_code&.account
68
68
  end
69
+
70
+ def audience
71
+ @authorization_code&.audience
72
+ end
69
73
  end
70
74
  end
71
75
  end
@@ -41,6 +41,11 @@ module StandardId
41
41
  true
42
42
  end
43
43
 
44
+ # Audience is bound to the refresh token - cannot be changed on refresh
45
+ def audience
46
+ @refresh_payload[:aud]
47
+ end
48
+
44
49
  def validate_scope_narrowing!
45
50
  return unless params[:scope].present?
46
51
 
@@ -35,6 +35,7 @@ module StandardId
35
35
  end
36
36
 
37
37
  def generate_token_response
38
+ validate_audience!
38
39
  emit_token_issuing
39
40
  expires_in = token_expiry
40
41
  payload = build_jwt_payload(expires_in)
@@ -77,8 +78,9 @@ module StandardId
77
78
  sub: subject_id,
78
79
  client_id: client_id,
79
80
  scope: token_scope,
81
+ aud: audience,
80
82
  grant_type: "refresh_token"
81
- }
83
+ }.compact
82
84
  StandardId::JwtService.encode(payload, expires_in: refresh_token_expiry)
83
85
  end
84
86
 
@@ -110,6 +112,20 @@ module StandardId
110
112
  params[:audience]
111
113
  end
112
114
 
115
+ def validate_audience!
116
+ allowed = StandardId.config.oauth.allowed_audiences
117
+ return if allowed.blank? # No restriction configured
118
+ return if audience.blank? # Audience not provided (optional)
119
+
120
+ # aud can be string or array per JWT spec
121
+ requested = Array(audience)
122
+ invalid = requested - allowed
123
+
124
+ if invalid.any?
125
+ raise StandardId::InvalidRequestError, "Invalid audience: #{invalid.join(', ')}"
126
+ end
127
+ end
128
+
113
129
  def claims_from_scope_mapping
114
130
  scope_claims = StandardId.config.oauth.scope_claims.with_indifferent_access
115
131
  resolvers = StandardId.config.oauth.claim_resolvers.with_indifferent_access
@@ -152,7 +168,8 @@ module StandardId
152
168
  @claim_resolvers_context ||= {
153
169
  client: token_client,
154
170
  account: token_account,
155
- request: request
171
+ request: request,
172
+ audience: audience
156
173
  }
157
174
  end
158
175
 
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.2.4"
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.4
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