Skip to content

Commit f2d0582

Browse files
committed
Add Secure Payment Confirmation (SPC) support to Webauthn
Introduced multiple classes to implement Secure Payment Confirmation including payment extension support and related data structures. Updated testing and integrated a JavaScript example for SPC usage in the payment flow.
1 parent a05dc1a commit f2d0582

File tree

7 files changed

+312
-1
lines changed

7 files changed

+312
-1
lines changed

shop.js

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
/*
2+
* @license
3+
* Copyright 2019 Google Inc. All rights reserved.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* https://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License
16+
*/
17+
import { html, render } from 'https://unpkg.com/[email protected]/lit-html.js?module';
18+
19+
const rp_hostname = 'spc-rp.glitch.me';
20+
const rp_origin = `https://${rp_hostname}`;
21+
22+
export const generateRandomCardNumber = () => {
23+
let number = localStorage.getItem('card_number');
24+
if (!number) {
25+
number = (Math.floor(Math.random()*(10**12))).toString().padStart(12, '0');
26+
}
27+
return number;
28+
}
29+
30+
const snackbar = document.querySelector('#snackbar');
31+
32+
export function showSnackbar(message) {
33+
snackbar.labelText = message;
34+
snackbar.show();
35+
};
36+
37+
export const _fetch = async (path, payload = '') => {
38+
const headers = {
39+
'X-Requested-With': 'XMLHttpRequest',
40+
};
41+
if (payload && !(payload instanceof FormData)) {
42+
headers['Content-Type'] = 'application/json';
43+
payload = JSON.stringify(payload);
44+
}
45+
const res = await fetch(path, {
46+
method: 'POST',
47+
credentials: 'same-origin',
48+
headers: headers,
49+
body: payload,
50+
});
51+
if (res.status === 200) {
52+
// Server authentication succeeded
53+
return res.json();
54+
} else if (res.status === 404) {
55+
return null;
56+
} else {
57+
// Server authentication failed
58+
const result = await res.json();
59+
throw new Error(result.error);
60+
}
61+
};
62+
63+
const isSecurePaymentConfirmationSupported = async () => {
64+
if (!'PaymentRequest' in window) {
65+
return [false, 'Payment Request API is not supported'];
66+
}
67+
68+
try {
69+
// The data below is the minimum required to create the request and
70+
// check if a payment can be made.
71+
const supportedInstruments = [
72+
{
73+
supportedMethods: "secure-payment-confirmation",
74+
data: {
75+
rpId: rp_hostname,
76+
credentialIds: [new Uint8Array(1)],
77+
challenge: new Uint8Array(1),
78+
instrument: {
79+
// Non-empty display name string
80+
displayName: ' ',
81+
// Transparent-black pixel.
82+
icon: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+P+/HgAFhAJ/wlseKgAAAABJRU5ErkJggg==',
83+
},
84+
// A dummy origin
85+
payeeOrigin: 'https://non-existent.example',
86+
}
87+
}
88+
];
89+
90+
const details = {
91+
// Dummy shopping details
92+
total: {label: 'Total', amount: {currency: 'USD', value: '0'}},
93+
};
94+
95+
const request = new PaymentRequest(supportedInstruments, details);
96+
const canMakePayment = await request.canMakePayment();
97+
return [canMakePayment, canMakePayment ? '' : 'SPC is not available'];
98+
} catch (error) {
99+
console.error(error);
100+
return [false, error.message];
101+
}
102+
};
103+
104+
105+
export const pay = async (price, number) => {
106+
// Feature detection
107+
const [spcAvailable, error] = await isSecurePaymentConfirmationSupported();
108+
if (spcAvailable) {
109+
// Check whether any credentials are available.
110+
const url = new URL(`${rp_origin}/auth/apiRequest`);
111+
const requestOptions = await _fetch(url, { number });
112+
// Null means the endpoint returned 404.
113+
// `allowCredentials` with empty array means there's no credentials stored on the server.
114+
if (requestOptions && requestOptions.allowCredentials.length > 0) {
115+
await payWithSPC(requestOptions, price, number);
116+
return true;
117+
}
118+
} else {
119+
return false;
120+
}
121+
};
122+
123+
const payWithSPC = async (requestOptions, price, number) => {
124+
const { challenge, timeout } = requestOptions;
125+
const credentialIds = requestOptions.allowCredentials.map(cred => base64url.decode(cred.id));
126+
127+
const request = new PaymentRequest([{
128+
// Specify `secure-payment-confirmation` as payment method.
129+
supportedMethods: "secure-payment-confirmation",
130+
data: {
131+
// The RP ID
132+
rpId: rp_hostname,
133+
134+
// List of credential IDs obtained from the RP.
135+
credentialIds,
136+
137+
// The challenge is also obtained from the RP.
138+
challenge: base64url.decode(challenge),
139+
140+
// A display name and an icon that represent the payment instrument.
141+
instrument: {
142+
displayName: `${formatLastFour(number)}`,
143+
icon: "https://cdn.glitch.global/94838ffe-241b-4a67-a9e0-290bfe34c351/fancybank_card.png?v=1717768798028",
144+
iconMustBeShown: false
145+
},
146+
147+
// The origin of the payee
148+
payeeOrigin: "https://spc-merchant.glitch.me",
149+
150+
// The name of the payee
151+
payeeName: "SPC Shop",
152+
153+
// The number of milliseconds to timeout.
154+
timeout,
155+
156+
// Experimental parameters (crbug.com/333945861)
157+
issuerInfo: {
158+
name: 'Fancy Bank',
159+
icon: "https://cdn.glitch.me/94838ffe-241b-4a67-a9e0-290bfe34c351%2Fbank.png?v=1639111444422",
160+
},
161+
}}],
162+
// Payment details.
163+
{
164+
total: {
165+
label: "Total",
166+
amount: {
167+
currency: "USD",
168+
value: price,
169+
},
170+
},
171+
});
172+
173+
let response;
174+
try {
175+
response = await request.show();
176+
177+
// response.details is a PublicKeyCredential, with a clientDataJSON that
178+
// contains the transaction data for verification by the issuing bank.
179+
const cred = response.details;
180+
const credential = {};
181+
credential.id = cred.id;
182+
credential.type = cred.type;
183+
credential.rawId = base64url.encode(cred.rawId);
184+
185+
if (cred.response) {
186+
const clientDataJSON =
187+
base64url.encode(cred.response.clientDataJSON);
188+
const authenticatorData =
189+
base64url.encode(cred.response.authenticatorData);
190+
const signature =
191+
base64url.encode(cred.response.signature);
192+
const userHandle =
193+
base64url.encode(cred.response.userHandle);
194+
credential.response = {
195+
clientDataJSON,
196+
authenticatorData,
197+
signature,
198+
userHandle,
199+
};
200+
}
201+
202+
const url = new URL(`${rp_origin}/auth/apiResponse`);
203+
url.searchParams.append('number', number);
204+
await _fetch(url.toString(), credential);
205+
await response.complete('success');
206+
207+
/* send response.details to the issuing bank for verification */
208+
} catch (err) {
209+
await response.complete('fail');
210+
/* SPC cannot be used; merchant should fallback to traditional flows */
211+
console.error(err.message);
212+
throw err;
213+
}
214+
};
215+
216+
const formatLastFour = (cardNumber) => {
217+
return `**** ${cardNumber.slice(-4)}`
218+
};
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Webauthn\AuthenticationExtensions;
6+
7+
use Webauthn\SecurePaymentConfirmation\PaymentCredentialInstrument;
8+
use Webauthn\SecurePaymentConfirmation\PaymentCurrencyAmount;
9+
10+
final class PaymentExtension extends AuthenticationExtension
11+
{
12+
public static function register(string $rpId, string $topOrigin, string $payeeName, string $payeeOrigin, PaymentCurrencyAmount $amount, PaymentCredentialInstrument $instrument): AuthenticationExtension
13+
{
14+
return self::create('payment', [
15+
'isPayment' => true,
16+
'rpId' => $rpId,
17+
'topOrigin' => $topOrigin,
18+
'payeeName' => $payeeName,
19+
'payeeOrigin' => $payeeOrigin,
20+
'currencyAmount' => $amount,
21+
'credentialInstrument' => $instrument,
22+
]);
23+
}
24+
25+
public static function authenticate(): AuthenticationExtension
26+
{
27+
return self::create('payment', ['isPayment' => true]);
28+
}
29+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Webauthn\SecurePaymentConfirmation;
6+
7+
class CollectedClientAdditionalPaymentData
8+
{
9+
public function __construct(
10+
public string $rpId,
11+
public string $topOrigin,
12+
public string $payeeName,
13+
public string $payeeOrigin,
14+
public PaymentCurrencyAmount $total,
15+
public PaymentCredentialInstrument $instrument,
16+
){
17+
}
18+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Webauthn\SecurePaymentConfirmation;
6+
7+
class CollectedClientPaymentData
8+
{
9+
10+
public function __construct(
11+
public CollectedClientAdditionalPaymentData $payment,
12+
)
13+
{
14+
}
15+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Webauthn\SecurePaymentConfirmation;
6+
7+
class PaymentCredentialInstrument
8+
{
9+
public function __construct(
10+
public string $displayName,
11+
public string $icon,
12+
public bool $iconMustBeShown = true,
13+
)
14+
{
15+
}
16+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Webauthn\SecurePaymentConfirmation;
6+
7+
class PaymentCurrencyAmount
8+
{
9+
public function __construct(
10+
public string $currency,
11+
public string $value,
12+
)
13+
{
14+
}
15+
}

tests/library/Functional/AssertionTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ public function aPreviouslyFixedKeyCanBeVerified(): void
208208
$source,
209209
$publicKeyCredential->response,
210210
$publicKeyCredentialRequestOptions,
211-
'tuleap-web.tuleap-aio-dev.docker',
211+
'webauthn.spomky-labs.com',
212212
'101'
213213
);
214214
}

0 commit comments

Comments
 (0)