shakha 0.1.7 → 0.3.0

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: 97e30df4b475b7c45fa0d75ef31ebe049760c19f6a65df602996b6aa2edd3ebe
4
- data.tar.gz: 5e1b4b6cdc4897e74e289411fae040a9ba31e8aedb800460e776011ee4fbfac1
3
+ metadata.gz: 7f2fb69e4f483c981312b4211c9205e9ae5d0d100a0d3d8f0d61f693478c8369
4
+ data.tar.gz: 6dfb50d826c90ea54ed5453d7eebad7a94bf93cea02a1bb407fbd69a61da421e
5
5
  SHA512:
6
- metadata.gz: 1569eafa9f6f7054dd8457301815f6676cb29ba0b3658e5c00edb06896ed4cb700f4e6be3f2daaedc582519d9af12603fc08229170df1e162cd255f60b8bf3d4
7
- data.tar.gz: 2e92274af426cb8eb2ba1ed6279a6ebc89cee3c67d4c3085ad623015df5e8d2ed0756a023a74cb396a133fd4bbcd6ea2ed8058ba94132d0439078293d9451c61
6
+ metadata.gz: 1bac5c8e1d92fb2997713d5ad19d7b16327fc21ec7e7520adf09d736bbe2c15c1cb72ef7c859798cba62a8a012819a1fb4863bd6c49830cd10d98f3082d4afc9
7
+ data.tar.gz: 8e9ae9652bb51b48bc30623305113e75c2a1c56676ec17a4e9503f4dc9694f55cf87d1d58fa9a2aaee956e47fa8ee1ecdc35024f3500c616e228942f42efbfac
data/README.md CHANGED
@@ -28,12 +28,13 @@ class CreateShakhaTables < ActiveRecord::Migration[7.1]
28
28
 
29
29
  create_table :shakha_users do |t|
30
30
  t.references :client, null: false, foreign_key: { to_table: :shakha_clients }
31
- t.string :pairwise_sub, null: false
31
+ t.string :provider, null: false
32
+ t.string :uid, null: false
32
33
  t.string :email
33
34
  t.string :name
34
35
  t.string :picture
35
36
  t.timestamps
36
- t.index :pairwise_sub, unique: true
37
+ t.index [:provider, :uid], unique: true
37
38
  t.index :email
38
39
  end
39
40
 
@@ -41,10 +42,10 @@ class CreateShakhaTables < ActiveRecord::Migration[7.1]
41
42
  t.references :user, foreign_key: { to_table: :shakha_users }
42
43
  t.references :client, null: false, foreign_key: { to_table: :shakha_clients }
43
44
  t.string :token, null: false
44
- t.string :jti, null: false
45
+ t.string :ip_address
46
+ t.string :user_agent
45
47
  t.timestamps
46
48
  t.index :token, unique: true
47
- t.index :jti, unique: true
48
49
  t.index :created_at
49
50
  end
50
51
  end
@@ -14,7 +14,7 @@ module Shakha
14
14
 
15
15
  private
16
16
 
17
- def invalid_csrf_token(exception)
17
+ def invalid_csrf_token(_exception)
18
18
  render json: { error: "Invalid CSRF token" }, status: :unprocessable_entity
19
19
  end
20
20
  end
@@ -6,183 +6,92 @@ require "uri"
6
6
  module Shakha
7
7
  class AuthController < ApplicationController
8
8
  include PKCEMixin
9
- include Auditable
10
9
 
11
- skip_before_action :verify_authenticity_token, only: [:callback, :token]
10
+ skip_before_action :verify_authenticity_token, only: [:callback]
12
11
 
13
12
  def new
14
13
  @client = find_or_create_client
15
14
  @return_to = sanitize_return_to(params[:return_to])
15
+ @providers = Shakha.config.providers
16
16
  end
17
17
 
18
18
  def authorize
19
- params[:return_to] = sanitize_return_to(params[:return_to])
19
+ provider = resolve_provider
20
20
  pkce = create_pkce_bundle
21
- @client = find_or_create_client
22
21
 
23
- google_auth_url = build_google_auth_url(pkce)
22
+ redirect_uri = "#{Shakha.config.app_origin}/auth/shakha/#{provider.provider_name}/callback"
23
+ auth_url = provider.authorize_url(
24
+ state: pkce[:state],
25
+ code_challenge: pkce[:challenge],
26
+ redirect_uri: redirect_uri
27
+ )
24
28
 
25
- redirect_to google_auth_url, allow_other_host: true
29
+ redirect_to auth_url, allow_other_host: true
26
30
  end
27
31
 
28
32
  def callback
29
- pkce_result = verify_pkce!(params[:code], params[:state])
30
- exchange_code_for_tokens(params[:code], pkce_result[:verifier], pkce_result[:return_to])
31
- rescue PKCEError, GoogleOAuthError => e
32
- ActiveSupport::Notifications.instrument("shakha.sign_in_failed", {
33
- reason: e.class.name,
34
- ip: request.remote_ip
35
- })
36
- Rails.logger.warn("[Shakha] Auth error: #{e.class}: #{e.message}")
37
- redirect_to "/auth/shakha/error?message=#{URI.encode_www_form_component(user_facing_error(e))}"
38
- end
39
-
40
- def token
41
- code = params[:code]
42
- verifier = params[:code_verifier]
43
-
44
- raise PKCEError, "Missing code" unless code
45
- raise PKCEError, "Missing code_verifier" unless verifier
46
-
47
- id_token = exchange_code_for_id_token(code, verifier)
48
-
49
- render json: {
50
- id_token: id_token,
51
- pairwise_sub: id_token_payload(id_token)[:sub],
52
- expires_in: 24.hours.to_i
53
- }
54
- rescue PKCEError, JWTError, GoogleOAuthError => e
55
- render json: { error: e.message }, status: :unauthorized
56
- end
57
-
58
- def error
59
- @message = params[:message] || "Authentication failed"
60
- end
33
+ provider = resolve_provider
34
+ pkce_result = verify_pkce!(params[:state])
61
35
 
62
- private
36
+ token_response = provider.exchange_code(
37
+ code: params[:code],
38
+ code_verifier: pkce_result[:verifier],
39
+ redirect_uri: "#{Shakha.config.app_origin}/auth/shakha/#{provider.provider_name}/callback"
40
+ )
63
41
 
64
- def sanitize_return_to(raw)
65
- return "/" if raw.blank?
42
+ identity = provider.identity_from_response(token_response)
43
+ user = find_or_create_user(provider.provider_name, identity)
44
+ session_record = create_session(user)
45
+ set_session_cookie(session_record)
46
+ redirect_to build_return_url(pkce_result[:return_to], session_record)
66
47
 
67
- uri = URI.parse(raw)
68
- return "/" if uri.host.present? && ![app_origin_host, client_origin_host].include?(uri.host)
69
- return "/" unless uri.path.present? && uri.path.start_with?("/")
70
-
71
- uri.path
72
- rescue URI::InvalidURIError
73
- "/"
48
+ rescue PKCEError, OAuthError => e
49
+ handle_auth_failure(e, pkce_result)
74
50
  end
75
51
 
76
- def app_origin_host
77
- URI.parse(Shakha.config.app_origin).host
78
- end
52
+ def destroy
53
+ current_session&.destroy
54
+ cookies.delete(:shakha_session_token)
79
55
 
80
- def client_origin_host
81
- URI.parse(Shakha.config.service_base_url).host
82
- rescue URI::InvalidURIError
83
- nil
84
- end
85
-
86
- def user_facing_error(exception)
87
- case exception
88
- when PKCEError
89
- "Authentication failed. Please try again."
90
- when GoogleOAuthError
91
- "Unable to sign in with Google. Please try again later."
92
- else
93
- "An unexpected error occurred. Please try again."
56
+ respond_to do |format|
57
+ format.html { redirect_to params[:return_to].presence || "/" }
58
+ format.json { render json: { status: "signed_out" } }
94
59
  end
95
60
  end
96
61
 
97
- def find_or_create_client
98
- origin = request.origin || Shakha.config.app_origin
99
- origin_uri = URI.parse(origin).origin
100
-
101
- if Shakha.config.embedded?
102
- Shakha::Client.find_or_create_by!(origin: origin_uri) do |client|
103
- client.name = URI.parse(origin).host
104
- end
105
- else
106
- Shakha::Client.find_by!(origin: origin_uri)
107
- end
108
- rescue ActiveRecord::RecordNotFound
109
- raise ConfigurationError, "Unknown client origin: #{origin_uri}. Register this origin in shakha_clients first."
62
+ def error
63
+ @message = params[:message] || "Authentication failed"
110
64
  end
111
65
 
112
- def build_google_auth_url(pkce)
113
- client_id = Shakha.config.google_client_id || ENV["GOOGLE_CLIENT_ID"]
114
- base_url = Shakha.config.service_base_url || "http://localhost:3000"
115
- redirect_uri = "#{base_url}/auth/shakha/callback"
116
-
117
- scopes = ["openid", "email", "profile"].join(" ")
118
- scopes += " https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile" if params[:request_pii]
119
-
120
- params = {
121
- client_id: client_id,
122
- redirect_uri: redirect_uri,
123
- response_type: "code",
124
- scope: scopes,
125
- code_challenge: pkce[:challenge],
126
- code_challenge_method: "S256",
127
- state: pkce[:state],
128
- access_type: "offline",
129
- prompt: "consent"
130
- }
131
-
132
- URI.parse("https://accounts.google.com/o/oauth2/v2/auth").tap do |uri|
133
- uri.query = URI.encode_www_form(params)
134
- end.to_s
135
- end
136
-
137
- def exchange_code_for_tokens(code, verifier, return_to = "/")
138
- client_id = Shakha.config.google_client_id || ENV["GOOGLE_CLIENT_ID"]
139
- client_secret = Shakha.config.google_client_secret || ENV["GOOGLE_CLIENT_SECRET"]
140
- base_url = Shakha.config.service_base_url || "http://localhost:3000"
141
- redirect_uri = "#{base_url}/auth/shakha/callback"
142
-
143
- response = http_post(
144
- "https://oauth2.googleapis.com/token",
145
- {
146
- code: code,
147
- client_id: client_id,
148
- client_secret: client_secret,
149
- redirect_uri: redirect_uri,
150
- grant_type: "authorization_code",
151
- code_verifier: verifier
152
- }
153
- )
154
-
155
- tokens = JSON.parse(response.body)
156
- id_token = tokens["id_token"]
157
- access_token = tokens["access_token"]
158
-
159
- raise GoogleOAuthError, "No id_token received" unless id_token
160
-
161
- payload = decode_id_token(id_token)
162
- google_sub = payload["sub"]
163
-
164
- client = find_or_create_client
165
- pairwise_sub = Shakha.derive_pairwise_sub(google_sub, client.client_id)
66
+ private
166
67
 
167
- user = Shakha::User.find_or_initialize_by(pairwise_sub: pairwise_sub, client: client)
68
+ def resolve_provider
69
+ provider_name = (params[:provider] || :google).to_sym
70
+ Shakha::Providers.resolve(provider_name)
71
+ end
168
72
 
169
- if payload["email"]
170
- user.assign_attributes(
171
- email: payload["email"],
172
- name: payload["name"],
173
- picture: payload["picture"]
174
- )
73
+ def find_or_create_user(provider_name, identity)
74
+ Shakha::User.find_or_create_by!(
75
+ provider: provider_name.to_s,
76
+ uid: identity[:uid]
77
+ ) do |user|
78
+ user.client = find_or_create_client
79
+ user.email = identity[:email]
80
+ user.name = identity[:name]
81
+ user.picture = identity[:picture]
175
82
  end
176
- user.save!
83
+ end
177
84
 
178
- session_record = Shakha::Session.create!(
85
+ def create_session(user)
86
+ Shakha::Session.create!(
179
87
  user: user,
180
- client: client,
181
- jti: SecureRandom.uuid,
88
+ client: find_or_create_client,
182
89
  ip_address: request.remote_ip,
183
90
  user_agent: request.user_agent
184
91
  )
92
+ end
185
93
 
94
+ def set_session_cookie(session_record)
186
95
  cookies.encrypted[:shakha_session_token] = {
187
96
  value: session_record.token,
188
97
  httponly: true,
@@ -190,49 +99,66 @@ module Shakha
190
99
  same_site: :lax,
191
100
  expires: Shakha.config.session_lifetime.from_now
192
101
  }
193
-
194
- redirect_to sanitize_return_to(return_to)
195
102
  end
196
103
 
197
- def exchange_code_for_id_token(code, verifier)
198
- client_id = Shakha.config.google_client_id || ENV["GOOGLE_CLIENT_ID"]
199
- client_secret = Shakha.config.google_client_secret || ENV["GOOGLE_CLIENT_SECRET"]
200
- redirect_uri = "#{Shakha.config.service_base_url}/auth/shakha/callback"
104
+ def build_return_url(return_to, session_record)
105
+ uri = URI.parse(return_to || "/")
106
+ existing = URI.decode_www_form(uri.query || "").to_h
107
+ existing["token"] = session_record.token
108
+ existing["expires_at"] = session_record.expires_at.iso8601
109
+ uri.query = URI.encode_www_form(existing)
110
+ uri.to_s
111
+ end
201
112
 
202
- response = http_post(
203
- "https://oauth2.googleapis.com/token",
204
- {
205
- code: code,
206
- client_id: client_id,
207
- client_secret: client_secret,
208
- redirect_uri: redirect_uri,
209
- grant_type: "authorization_code",
210
- code_verifier: verifier
211
- }
212
- )
113
+ def handle_auth_failure(exception, pkce_result)
114
+ return_to = pkce_result&.dig(:return_to) || "/"
213
115
 
214
- tokens = JSON.parse(response.body)
215
- tokens["id_token"] || raise(GoogleOAuthError, "No id_token in response")
116
+ if request.format.json? || api_request?
117
+ render json: { error: user_facing_error(exception) }, status: :unauthorized
118
+ else
119
+ redirect_to "#{return_to}?error=#{URI.encode_www_form_component(user_facing_error(exception))}"
120
+ end
216
121
  end
217
122
 
218
- def id_token_payload(id_token)
219
- JWT.decode(id_token, nil, false)[0]
123
+ def api_request?
124
+ request.headers["Accept"]&.include?("application/json")
220
125
  end
221
126
 
222
- def decode_id_token(id_token)
223
- JWT.decode(id_token, nil, false)[0]
127
+ def sanitize_return_to(raw)
128
+ return "/" if raw.blank?
129
+
130
+ uri = URI.parse(raw)
131
+ app_host = URI.parse(Shakha.config.app_origin).host
132
+
133
+ return "/" unless uri.path.present? && uri.path.start_with?("/")
134
+
135
+ if uri.host.present? && uri.host != app_host && !allowed_origin?(uri.origin)
136
+ return "/"
137
+ end
138
+
139
+ raw
140
+ rescue URI::InvalidURIError
141
+ "/"
224
142
  end
225
143
 
226
- def http_post(url, body)
227
- uri = URI.parse(url)
228
- http = Net::HTTP.new(uri.host, uri.port)
229
- http.use_ssl = uri.scheme == "https"
144
+ def allowed_origin?(origin)
145
+ Shakha.config.allowed_redirect_origins&.include?(origin) || false
146
+ end
230
147
 
231
- request = Net::HTTP::Post.new(uri.request_uri)
232
- request["Content-Type"] = "application/x-www-form-urlencoded"
233
- request.body = URI.encode_www_form(body)
148
+ def find_or_create_client
149
+ origin = request.origin || Shakha.config.app_origin
150
+ origin_uri = URI.parse(origin).origin
151
+ Shakha::Client.find_or_create_by!(origin: origin_uri) do |client|
152
+ client.name = URI.parse(origin).host
153
+ end
154
+ end
234
155
 
235
- http.request(request)
156
+ def user_facing_error(exception)
157
+ case exception
158
+ when PKCEError then "Authentication failed. Please try again."
159
+ when OAuthError then "Unable to sign in. Please try again later."
160
+ else "An unexpected error occurred. Please try again."
161
+ end
236
162
  end
237
163
  end
238
- end
164
+ end
@@ -2,76 +2,31 @@
2
2
 
3
3
  module Shakha
4
4
  class SessionController < ApplicationController
5
- include Auditable
6
- skip_before_action :verify_authenticity_token, only: [:check]
7
-
8
- def index
9
- return render json: { error: "Authentication required" }, status: :unauthorized unless signed_in?
10
-
11
- sessions = current_user.sessions.active.order(created_at: :desc)
5
+ def show
6
+ unless signed_in?
7
+ return render json: { error: "Authentication required" }, status: :unauthorized
8
+ end
12
9
 
13
10
  render json: {
14
- current_token: current_session.token,
15
- sessions: sessions.map { |s|
16
- {
17
- id: s.id,
18
- token: s.token,
19
- created_at: s.created_at.iso8601,
20
- expires_at: s.expires_at.iso8601,
21
- current: s.token == current_session.token
22
- }
11
+ user: {
12
+ id: current_user.id,
13
+ email: current_user.email,
14
+ name: current_user.name,
15
+ picture: current_user.picture,
16
+ provider: current_user.provider
17
+ },
18
+ session: {
19
+ expires_at: current_session.expires_at.iso8601
23
20
  }
24
21
  }
25
22
  end
26
23
 
27
- def show
28
- render json: {
29
- user_id: current_user&.pairwise_sub,
30
- email: current_user&.email,
31
- name: current_user&.name,
32
- expires_at: current_session&.expires_at&.iso8601
33
- }
34
- end
35
-
36
24
  def check
37
25
  if signed_in?
38
26
  render json: { status: "active" }
39
27
  else
40
- render json: {
41
- status: "login_required",
42
- reason: "no_session"
43
- }, status: :unauthorized
44
- end
45
- end
46
-
47
- def destroy
48
- current_session&.destroy
49
- cookies.delete(:shakha_session_token)
50
-
51
- respond_to do |format|
52
- format.html { redirect_to params[:return_to].presence || "/" }
53
- format.json { render json: { status: "signed_out" } }
28
+ render json: { status: "expired" }, status: :unauthorized
54
29
  end
55
30
  end
56
-
57
- def list
58
- return redirect_to "/auth/shakha" unless signed_in?
59
-
60
- @sessions = current_user.sessions.active.order(created_at: :desc)
61
- @current_token = current_session&.token
62
- end
63
-
64
- def revoke
65
- return render json: { error: "Authentication required" }, status: :unauthorized unless signed_in?
66
-
67
- session = current_user.sessions.find(params[:id])
68
- session.destroy
69
-
70
- cookies.delete(:shakha_session_token) if session.token == current_session&.token
71
-
72
- log_session_revoked(session)
73
-
74
- render json: { status: "revoked" }
75
- end
76
31
  end
77
- end
32
+ end
@@ -9,10 +9,6 @@ module Shakha
9
9
 
10
10
  validates :origin, presence: true, uniqueness: true
11
11
 
12
- def client_id
13
- "origin:#{origin}"
14
- end
15
-
16
12
  def self.find_by_origin!(origin)
17
13
  find_by!(origin: URI.parse(origin).origin)
18
14
  end
@@ -8,7 +8,6 @@ module Shakha
8
8
  belongs_to :client, class_name: "Shakha::Client"
9
9
 
10
10
  before_create :generate_token
11
- before_create :generate_jti
12
11
 
13
12
  scope :active, -> { where("created_at > ?", Shakha.config.session_lifetime.ago) }
14
13
 
@@ -25,9 +24,5 @@ module Shakha
25
24
  def generate_token
26
25
  self.token ||= SecureRandom.urlsafe_base64(32)
27
26
  end
28
-
29
- def generate_jti
30
- self.jti ||= SecureRandom.uuid
31
- end
32
27
  end
33
28
  end
@@ -7,11 +7,9 @@ module Shakha
7
7
  belongs_to :client, class_name: "Shakha::Client"
8
8
  has_many :sessions, class_name: "Shakha::Session", dependent: :destroy
9
9
 
10
- validates :pairwise_sub, presence: true
10
+ validates :provider, presence: true
11
+ validates :uid, presence: true
12
+ validates :uid, uniqueness: { scope: :provider }
11
13
  validates :email, uniqueness: { scope: :client_id }, allow_blank: true
12
-
13
- def can_access?(resource)
14
- true
15
- end
16
14
  end
17
15
  end
@@ -16,25 +16,13 @@
16
16
  </div>
17
17
 
18
18
  <div class="sh-card__body">
19
- <%= link_to shakha.authorize_path(request_pii: 1),
20
- class: "sh-btn sh-btn--google",
21
- data: { turbo: false } do %>
22
- <svg class="sh-btn__icon" viewBox="0 0 18 18" aria-hidden="true">
23
- <path fill="#4285F4" d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844c-.209 1.125-.843 2.078-1.796 2.717v2.258h2.908c1.702-1.567 2.684-3.875 2.684-6.615z"/>
24
- <path fill="#34A853" d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.258c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18z"/>
25
- <path fill="#FBBC05" d="M3.964 10.71A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.042l3.007-2.332z"/>
26
- <path fill="#EA4335" d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z"/>
27
- </svg>
28
- Continue with Google
19
+ <% @providers.each do |provider| %>
20
+ <%= link_to shakha.send("#{provider}_authorize_path"),
21
+ class: "sh-btn sh-btn--#{provider}",
22
+ data: { turbo: false } do %>
23
+ Continue with <%= provider.to_s.titleize %>
24
+ <% end %>
29
25
  <% end %>
30
-
31
- <div class="sh-divider">or</div>
32
-
33
- <p class="sh-text-center" style="color: var(--sh-text-tertiary); font-size: var(--sh-text-sm);">
34
- By signing in, you agree to our
35
- <a href="#">Terms</a> and
36
- <a href="#">Privacy Policy</a>.
37
- </p>
38
26
  </div>
39
27
 
40
28
  <div class="sh-card__footer">
data/lib/shakha/config.rb CHANGED
@@ -3,42 +3,19 @@
3
3
  module Shakha
4
4
  class Config
5
5
  attr_accessor :app_origin,
6
- :service_url,
7
- :service_secret,
8
6
  :google_client_id,
9
7
  :google_client_secret,
10
- :issuer,
8
+ :github_client_id,
9
+ :github_client_secret,
10
+ :providers,
11
11
  :session_lifetime,
12
- :signing_key,
13
- :verification_key,
14
- :key_id,
15
- :rate_limiting_enabled
12
+ :rate_limiting_enabled,
13
+ :allowed_redirect_origins
16
14
 
17
15
  def initialize
18
16
  @session_lifetime = 30.days
19
- @issuer = "https://shakha.dev"
20
17
  @rate_limiting_enabled = false
21
- end
22
-
23
- def embedded?
24
- service_url.blank?
25
- end
26
-
27
- def service_base_url
28
- return app_origin if embedded?
29
-
30
- service_url.chomp("/")
31
- end
32
-
33
- def client_id
34
- return @client_id if defined?(@client_id)
35
-
36
- origin = URI.parse(app_origin).origin
37
- @client_id = "origin:#{origin}"
38
- end
39
-
40
- def audience
41
- client_id
18
+ @providers = [:google]
42
19
  end
43
20
  end
44
- end
21
+ end
@@ -5,10 +5,9 @@ module Shakha
5
5
  class << self
6
6
  def validate!(config)
7
7
  missing = []
8
- missing << "SHAKHA_APP_ORIGIN" unless config.app_origin.present?
8
+ missing << "APP_ORIGIN" unless config.app_origin.present?
9
9
  missing << "GOOGLE_CLIENT_ID" unless config.google_client_id.present?
10
10
  missing << "GOOGLE_CLIENT_SECRET" unless config.google_client_secret.present?
11
- missing << "SHAKHA_SERVICE_SECRET" unless config.service_secret.present?
12
11
 
13
12
  unless missing.empty?
14
13
  message = "Shakha: missing required configuration: #{missing.join(', ')}"