Module: Mpp::Parsing

Extended by:
T::Sig
Defined in:
lib/mpp/parsing.rb

Constant Summary collapse

MAX_HEADER_PAYLOAD_SIZE =
T.let(16 * 1024, Integer)
AUTH_PARAM_RE =

RFC 9110 auth-param regex: key=“value” or key=token

/([a-zA-Z_][\w-]*+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]++))/
PAYMENT_METHOD_ID_RE =
/\A[a-z]+\z/

Class Method Summary collapse

Class Method Details

.b64_decode(encoded) ⇒ Object



29
30
31
32
33
34
35
36
37
38
39
40
# File 'lib/mpp/parsing.rb', line 29

def b64_decode(encoded)
  Kernel.raise Mpp::ParseError, "Header payload exceeds maximum size" if encoded.length > MAX_HEADER_PAYLOAD_SIZE

  padded = encoded + ("=" * ((-encoded.length) % 4))
  decoded = Base64.urlsafe_decode64(padded)
  obj = JSON.parse(decoded)
  Kernel.raise Mpp::ParseError, "Expected JSON object" unless obj.is_a?(Hash)

  obj
rescue ArgumentError, JSON::ParserError
  Kernel.raise Mpp::ParseError, "Invalid base64 or JSON encoding"
end

.b64_encode(data) ⇒ Object



22
23
24
25
# File 'lib/mpp/parsing.rb', line 22

def b64_encode(data)
  compact_json = Mpp::Json.compact_encode(data)
  Base64.urlsafe_encode64(compact_json, padding: false)
end

.escape_quoted(str) ⇒ Object



44
45
46
47
48
# File 'lib/mpp/parsing.rb', line 44

def escape_quoted(str)
  Kernel.raise Mpp::ParseError, "Header value contains invalid CRLF characters" if str.include?("\r") || str.include?("\n")

  str.gsub(/[\\"]/) { |c| "\\#{c}" }
end

.format_authorization(credential) ⇒ Object



181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
# File 'lib/mpp/parsing.rb', line 181

def format_authorization(credential)
  challenge_dict = {
    "id" => credential.challenge.id,
    "realm" => credential.challenge.realm,
    "method" => credential.challenge.method,
    "intent" => credential.challenge.intent,
    "request" => credential.challenge.request
  }
  challenge_dict["expires"] = credential.challenge.expires if credential.challenge.expires
  challenge_dict["digest"] = credential.challenge.digest if credential.challenge.digest
  challenge_dict["opaque"] = credential.challenge.opaque if credential.challenge.opaque

  payload = {
    "challenge" => challenge_dict,
    "payload" => credential.payload
  }
  payload["source"] = credential.source if credential.source

  encoded = b64_encode(payload)
  "Payment #{encoded}"
end

.format_payment_receipt(receipt) ⇒ Object



244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
# File 'lib/mpp/parsing.rb', line 244

def format_payment_receipt(receipt)
  t = receipt.timestamp.utc
  timestamp_str = if t.usec == 0
    t.strftime("%Y-%m-%dT%H:%M:%SZ")
  else
    t.strftime("%Y-%m-%dT%H:%M:%S.%LZ")
  end

  payload = {
    "method" => receipt.method,
    "reference" => receipt.reference,
    "status" => receipt.status,
    "timestamp" => timestamp_str
  }
  payload["externalId"] = receipt.external_id if receipt.external_id
  payload["extra"] = receipt.extra if receipt.extra

  b64_encode(payload)
end

.format_www_authenticate(challenge, realm) ⇒ Object



120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/mpp/parsing.rb', line 120

def format_www_authenticate(challenge, realm)
  request_b64 = b64_encode(challenge.request)

  parts = [
    "id=\"#{escape_quoted(challenge.id)}\"",
    "realm=\"#{escape_quoted(realm)}\"",
    "method=\"#{escape_quoted(challenge.method)}\"",
    "intent=\"#{escape_quoted(challenge.intent)}\"",
    "request=\"#{request_b64}\""
  ]

  parts << "digest=\"#{escape_quoted(challenge.digest)}\"" if challenge.digest
  parts << "expires=\"#{escape_quoted(challenge.expires)}\"" if challenge.expires
  parts << "description=\"#{escape_quoted(challenge.description)}\"" if challenge.description
  if challenge.opaque
    opaque_b64 = b64_encode(challenge.opaque)
    parts << "opaque=\"#{opaque_b64}\""
  end

  "Payment #{parts.join(", ")}"
end

.parse_auth_params(params_str) ⇒ Object



58
59
60
61
62
63
64
65
66
67
# File 'lib/mpp/parsing.rb', line 58

def parse_auth_params(params_str)
  params = {}
  params_str.scan(AUTH_PARAM_RE) do |key, quoted_val, token_val|
    Kernel.raise Mpp::ParseError, "Duplicate parameter: #{key}" if params.key?(key)

    value = quoted_val.nil? ? token_val : unescape_quoted(quoted_val)
    params[key] = value
  end
  params
end

.parse_authorization(header) ⇒ Object



144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
# File 'lib/mpp/parsing.rb', line 144

def parse_authorization(header)
  header = header.strip
  Kernel.raise Mpp::ParseError, "Expected 'Payment' authentication scheme" unless header.downcase.start_with?("payment ")

  credential_b64 = header[8..].strip
  data = b64_decode(credential_b64)

  Kernel.raise Mpp::ParseError, "Credential missing required field: challenge" unless data.key?("challenge")
  Kernel.raise Mpp::ParseError, "Credential missing required field: payload" unless data.key?("payload")

  challenge_data = data["challenge"]
  Kernel.raise Mpp::ParseError, "Credential challenge must be an object" unless challenge_data.is_a?(Hash)
  Kernel.raise Mpp::ParseError, "Credential challenge missing required field: id" unless challenge_data.key?("id")

  method = challenge_data["method"]
  validate_payment_method_id(method)

  echo = Mpp::ChallengeEcho.new(
    id: challenge_data["id"].to_s,
    realm: (challenge_data["realm"] || "").to_s,
    method: method,
    intent: (challenge_data["intent"] || "").to_s,
    request: (challenge_data["request"] || "").to_s,
    expires: challenge_data["expires"]&.to_s,
    digest: challenge_data["digest"]&.to_s,
    opaque: challenge_data["opaque"]&.to_s
  )

  Mpp::Credential.new(
    challenge: echo,
    payload: data["payload"],
    source: data["source"]&.to_s
  )
end

.parse_payment_receipt(header) ⇒ Object



214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
# File 'lib/mpp/parsing.rb', line 214

def parse_payment_receipt(header)
  header = header.strip
  data = b64_decode(header)

  required = %w[status timestamp reference method]
  missing = required - data.keys
  Kernel.raise Mpp::ParseError, "Receipt missing required fields: #{missing}" unless missing.empty?

  status = data["status"]
  Kernel.raise Mpp::ParseError, "Invalid receipt status" unless status == "success"

  timestamp = parse_timestamp(data["timestamp"].to_s)
  method = data["method"]
  validate_payment_method_id(method)

  extra = data["extra"]
  extra = nil unless extra.is_a?(Hash)

  Mpp::Receipt.new(
    status: status,
    timestamp: timestamp,
    reference: data["reference"].to_s,
    method: method,
    external_id: data["externalId"]&.to_s,
    extra: extra
  )
end

.parse_timestamp(value) ⇒ Object



205
206
207
208
209
210
# File 'lib/mpp/parsing.rb', line 205

def parse_timestamp(value)
  ts_str = value.gsub("Z", "+00:00")
  Time.iso8601(ts_str)
rescue ArgumentError
  Kernel.raise Mpp::ParseError, "Invalid timestamp format"
end

.parse_www_authenticate(header) ⇒ Object



76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# File 'lib/mpp/parsing.rb', line 76

def parse_www_authenticate(header)
  header = header.strip
  Kernel.raise Mpp::ParseError, "Expected 'Payment' authentication scheme" unless header.downcase.start_with?("payment ")

  params_str = header[8..].strip
  params = parse_auth_params(params_str)

  id = params["id"]
  Kernel.raise Mpp::ParseError, "Missing 'id' field" unless id && !id.empty?

  realm = params["realm"]
  Kernel.raise Mpp::ParseError, "Missing 'realm' field" unless realm && !realm.empty?

  method = params["method"]
  Kernel.raise Mpp::ParseError, "Missing 'method' field" unless method && !method.empty?
  validate_payment_method_id(method)

  intent = params["intent"]
  Kernel.raise Mpp::ParseError, "Missing 'intent' field" unless intent && !intent.empty?

  request_b64 = params["request"]
  Kernel.raise Mpp::ParseError, "Missing 'request' field" unless request_b64 && !request_b64.empty?

  request = b64_decode(request_b64)

  opaque_b64 = params["opaque"]
  opaque = (opaque_b64 && !opaque_b64.empty?) ? b64_decode(opaque_b64) : nil

  Mpp::Challenge.new(
    id: id,
    method: method,
    intent: intent,
    request: request,
    realm: realm,
    request_b64: request_b64,
    digest: params["digest"],
    expires: params["expires"],
    description: params["description"],
    opaque: opaque
  )
end

.unescape_quoted(str) ⇒ Object



52
53
54
# File 'lib/mpp/parsing.rb', line 52

def unescape_quoted(str)
  str.gsub(/\\(.)/, '\1')
end

.validate_payment_method_id(method) ⇒ Object



70
71
72
# File 'lib/mpp/parsing.rb', line 70

def validate_payment_method_id(method)
  Kernel.raise Mpp::ParseError, "Invalid payment method ID" unless method.is_a?(String) && PAYMENT_METHOD_ID_RE.match?(method)
end