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,]+))/

Class Method Summary collapse

Class Method Details

.b64_decode(encoded) ⇒ Object



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

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



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

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

.escape_quoted(str) ⇒ Object



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

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

  str.gsub("\\", "\\\\\\\\").gsub('"', '\\"')
end

.format_authorization(credential) ⇒ Object



171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
# File 'lib/mpp/parsing.rb', line 171

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



232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
# File 'lib/mpp/parsing.rb', line 232

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



113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'lib/mpp/parsing.rb', line 113

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



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

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



137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
# File 'lib/mpp/parsing.rb', line 137

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")

  echo = Mpp::ChallengeEcho.new(
    id: challenge_data["id"].to_s,
    realm: (challenge_data["realm"] || "").to_s,
    method: (challenge_data["method"] || "").to_s,
    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



204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
# File 'lib/mpp/parsing.rb', line 204

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)

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

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

.parse_timestamp(value) ⇒ Object



195
196
197
198
199
200
# File 'lib/mpp/parsing.rb', line 195

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



70
71
72
73
74
75
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
# File 'lib/mpp/parsing.rb', line 70

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?

  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



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

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