Skip to content

Commit 9078e1e

Browse files
authored
V3 API: Rework twofer API to avoid calling 'collect' from the auth and sign methods (#20)
A completed or failed order can only be collected a single time, and when calling collect from the auth or sign endpoints, there is a small risk that it will collect the completed/failed orderRef.
1 parent f795e58 commit 9078e1e

13 files changed

Lines changed: 559 additions & 46 deletions

File tree

api/models.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package api
22

33
type (
4+
// BankIdV6Response used as a catch all response for V2
5+
// Deprecated: stick to V1 or move to V3
46
BankIdV6Response struct {
57
OrderRef string `json:"orderRef"`
68
ErrorCode string `json:"errorCode,omitempty"`
@@ -32,11 +34,113 @@ type (
3234
BankIdV6StepUp struct {
3335
MRTD bool `json:"mrtd,omitempty"`
3436
}
37+
38+
// V3 request / response messages
39+
40+
// BankIdv6AuthSignRequestV3 is used to start either an auth or sign request against BankID
41+
BankIdv6AuthSignRequestV3 struct {
42+
// EndUserIp The user IP address as it is seen by your service
43+
EndUserIp string `json:"endUserIp"`
44+
45+
// ReturnUrl Orders started on the same device as where the user's BankID is stored (started with autostart
46+
// token) will call this URL when the order is completed
47+
ReturnUrl string `json:"returnUrl,omitempty"`
48+
49+
// UserNonVisibleData Data that you wish to include but not display to the user
50+
UserNonVisibleData string `json:"userNonVisibleData,omitempty"`
51+
52+
// UserVisibleData Text displayed to the user during the order
53+
UserVisibleData string `json:"userVisibleData,omitempty"`
54+
55+
// UserVisibleDataFormat, 'plaintext' or 'simpleMarkdownV1'
56+
UserVisibleDataFormat string `json:"userVisibleDataFormat,omitempty"`
57+
58+
// PersonalNumber The personal identity number allowed to confirm the identification
59+
PersonalNumber string `json:"personalNumber,omitempty"`
60+
61+
// PinCode User is required to confirm the order with their security code even if they have biometrics activated
62+
PinCode bool `json:"pinCode,omitempty"`
63+
64+
// Once if true, will start an auth/sign and just return a single QR code, if false, auth/sign endpoint return
65+
// an SSE / NDJSON stream and send a new QR-code each second, for 30 seconds before returning
66+
Once bool `json:"once,omitempty"`
67+
}
68+
69+
// BankIdV6AuthSignResponseV3 is sent as a successful reply to an auth or sign request. If SSE / NDJSON is used, a
70+
// new BankIdV6AuthSignResponseV3 is sent each second (for 30 seconds)
71+
BankIdV6AuthSignResponseV3 struct {
72+
// OrderRef The reference ID for an order
73+
OrderRef string `json:"orderRef"`
74+
75+
// URI Start URL, for "BankID on this device"
76+
URI string `json:"uri"`
77+
78+
// QR contain the data for the QR-code
79+
QR string `json:"qr"`
80+
}
81+
82+
// BankIdv6CollectRequestV3 is used to collect status on a started auth / sign request
83+
BankIdv6CollectRequestV3 struct {
84+
// OrderRef A reference ID for an order
85+
OrderRef string `json:"orderRef"`
86+
87+
// WaitForChange allows the request to wait until a change is detected
88+
WaitForChange bool `json:"waitForChange"`
89+
90+
// WaitUntilFinished allows the request to wait until the referenced request has either completed or failed,
91+
// and will not return on state changes during the ongoing process.
92+
WaitUntilFinished bool `json:"waitUntilFinished"`
93+
}
94+
95+
// BankIdV6CollectResponseV3 is sent for a successful collect, if WaitUntilFinished is set in the request, it will
96+
// only return once the order have either completed or failed. If WaitUntilFinished isn't set, it will return once
97+
// a change is detected.
98+
BankIdV6CollectResponseV3 struct {
99+
// OrderRef The reference ID for an order
100+
OrderRef string `json:"orderRef"`
101+
Status string `json:"status,omitempty"`
102+
HintCode string `json:"hintCode,omitempty"`
103+
CompletionData *BankIdV6CompletionData `json:"completionData,omitempty"`
104+
}
105+
106+
// BankIdv6CancelRequestV3 request the cancellation of a pending auth / sign request
107+
BankIdv6CancelRequestV3 struct {
108+
// OrderRef A reference ID for an order
109+
OrderRef string `json:"orderRef"`
110+
}
111+
112+
BankIdv6CancelResponseV3 struct {
113+
Status string `json:"status"`
114+
}
115+
116+
// BankIdv6ErrorResponseV3 is sent when an endpoint return an error (4xx, 5xx) http status code
117+
BankIdv6ErrorResponseV3 struct {
118+
// Origin contains the origin of the error, currently either 'Twofer' or 'BankIDv6'
119+
Origin string `json:"origin"` // Twofer / BankIDv6
120+
121+
// StatusCode contain the original HTTP status code, if the error originates from BankID
122+
StatusCode int `json:"statusCode,omitempty"`
123+
124+
// ErrorCode contain the original error code that we may get from BankID when they return an http 400
125+
Code string `json:"code,omitempty"`
126+
127+
// Detail may contain the original error detail that we may get from BankID when they return an http 400, or it
128+
// can be an error message generated in twofer, for twofer errors
129+
Detail string `json:"detail"`
130+
}
35131
)
36132

133+
// Status codes
37134
const (
38135
StatusPending = "pending"
39136
StatusComplete = "complete"
40137
StatusFailed = "failed"
41138
StatusError = "error"
139+
StatusQrCode = "qrcode"
140+
)
141+
142+
// Error origin codes
143+
const (
144+
ErrorOriginTwofer = "Twofer"
145+
ErrorOriginBankIDv6 = "BankIDv6"
42146
)

cmd/twoferd/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ func startEid(e *echo.Echo) {
146146
PemRootCA: config.Get().BankID.GetRootCA(),
147147
PemClientCert: config.Get().BankID.GetClientCert(),
148148
PemClientKey: config.Get().BankID.GetClientKey(),
149+
PollInterval: config.Get().BankID.PollInterval,
149150
})
150151
if err != nil {
151152
fmt.Printf("failed to initate bankId %v", err)

internal/bankid/bankid_models.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -200,12 +200,13 @@ func (c *CancelRequest) Validate() error {
200200
}
201201

202202
type BankIdError struct {
203-
ErrorCode string `json:"errorCode"`
204-
Details string `json:"details"`
203+
StatusCode int `json:"-"`
204+
ErrorCode string `json:"errorCode"`
205+
Details string `json:"details"`
205206
}
206207

207208
func (e BankIdError) Error() string {
208-
return fmt.Sprintf("bankid: %s, %s", e.ErrorCode, e.Details)
209+
return fmt.Sprintf("bankid: (%d) %s, %s", e.StatusCode, e.ErrorCode, e.Details)
209210
}
210211

211212
type Empty struct{}

internal/bankid/endpoints.go

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"encoding/json"
77
"errors"
88
"fmt"
9+
"github.com/labstack/echo/v4"
910
"io"
1011
"net/http"
1112
"time"
@@ -19,14 +20,16 @@ const (
1920
)
2021

2122
type API struct {
22-
baseURL string
23-
client *http.Client
23+
baseURL string
24+
client *http.Client
25+
pollInterval time.Duration
2426
}
2527

26-
func NewAPI(client *http.Client, baseURL string) *API {
28+
func NewAPI(client *http.Client, baseURL string, pollInterval time.Duration) *API {
2729
return &API{
28-
client: client,
29-
baseURL: baseURL,
30+
client: client,
31+
baseURL: baseURL,
32+
pollInterval: pollInterval,
3033
}
3134
}
3235

@@ -94,7 +97,7 @@ func (a *API) Change(ctx context.Context, r *ChangeRequest) (*CollectResponse, e
9497
case <-ctx.Done():
9598
err = ctx.Err()
9699
return nil, err
97-
case <-time.After(time.Second):
100+
case <-time.After(a.pollInterval):
98101
}
99102

100103
var resp *CollectResponse
@@ -113,6 +116,49 @@ func (a *API) Change(ctx context.Context, r *ChangeRequest) (*CollectResponse, e
113116
}
114117
}
115118

119+
func (a *API) ChangeV3(ctx context.Context, r *ChangeRequest) (*CollectResponse, error) {
120+
err := r.Validate()
121+
if err != nil {
122+
return nil, err
123+
}
124+
125+
collectRequest := &CollectRequest{OrderRef: r.OrderRef}
126+
127+
startState, err := a.Collect(ctx, collectRequest)
128+
if err != nil {
129+
return nil, err
130+
}
131+
132+
if startState.Status == Complete || startState.Status == Failed {
133+
return startState, nil
134+
}
135+
136+
for {
137+
select {
138+
case <-ctx.Done():
139+
return nil, ctx.Err()
140+
case <-time.After(a.pollInterval):
141+
}
142+
143+
var resp *CollectResponse
144+
resp, err = a.Collect(ctx, collectRequest)
145+
if err != nil {
146+
return nil, err
147+
}
148+
149+
if r.WaitUntilFinished {
150+
if resp.Status != Pending {
151+
return resp, nil
152+
}
153+
continue
154+
}
155+
156+
if resp.HintCode != startState.HintCode {
157+
return resp, nil
158+
}
159+
}
160+
}
161+
116162
func (a *API) WatchForChange(ctx context.Context, orderRef string) <-chan WatchResponse {
117163
watch := make(chan WatchResponse)
118164

@@ -253,12 +299,14 @@ func post[Request any, Response any](ctx context.Context, client *http.Client, r
253299
if res.StatusCode != 200 {
254300
fmt.Printf("%s returned status code %d with data: %s\n", url, res.StatusCode, body)
255301
var bidError BankIdError
256-
err = json.Unmarshal(body, &bidError)
257-
if err == nil {
258-
err = bidError
302+
if res.Header.Get(echo.HeaderContentType) == echo.MIMEApplicationJSON {
303+
err = json.Unmarshal(body, &bidError)
304+
if err != nil {
305+
fmt.Printf("failed to unmarshal BankIdError, error: %v\n", err)
306+
}
259307
}
260-
261-
return nil, err
308+
bidError.StatusCode = res.StatusCode
309+
return nil, bidError
262310
}
263311

264312
var response Response

internal/config/config.go

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ type Config struct {
2020
WebAuthn WebAuthn
2121
PWD PWD
2222

23-
StreamEncoder string `env:"STREAM_ENCODER" envDefault:"NDJSON"`
23+
StreamEncoder string `env:"STREAM_ENCODER" envDefault:"SSE"`
2424
}
2525

2626
func (c Config) EIDEnabled() bool {
@@ -36,14 +36,15 @@ type OTP struct {
3636
}
3737

3838
type BankID struct {
39-
Enabled bool `env:"EID_BANKID_ENABLE" envDefault:"FALSE"`
40-
URL *url.URL `env:"EID_BANKID_URL"`
41-
RootCA string `env:"EID_BANKID_ROOT_CA_PEM"`
42-
RootCAFile string `env:"EID_BANKID_ROOT_CA_PEM_FILE,file"`
43-
ClientCert string `env:"EID_BANKID_CLIENT_CERT"`
44-
ClientCertFile string `env:"EID_BANKID_CLIENT_CERT_FILE,file"`
45-
ClientKey string `env:"EID_BANKID_CLIENT_KEY"`
46-
ClientKeyFile string `env:"EID_BANKID_CLIENT_KEY_FILE,file"`
39+
Enabled bool `env:"EID_BANKID_ENABLE" envDefault:"FALSE"`
40+
URL *url.URL `env:"EID_BANKID_URL"`
41+
RootCA string `env:"EID_BANKID_ROOT_CA_PEM"`
42+
RootCAFile string `env:"EID_BANKID_ROOT_CA_PEM_FILE,file"`
43+
ClientCert string `env:"EID_BANKID_CLIENT_CERT"`
44+
ClientCertFile string `env:"EID_BANKID_CLIENT_CERT_FILE,file"`
45+
ClientKey string `env:"EID_BANKID_CLIENT_KEY"`
46+
ClientKeyFile string `env:"EID_BANKID_CLIENT_KEY_FILE,file"`
47+
PollInterval time.Duration `env:"EID_BANKID_POLL_INTERVAL" envDefault:"2s"` // Poll BankID every two seconds as default (according to their spec)
4748
}
4849

4950
func (b BankID) GetRootCA() []byte {

internal/eid/bankid/bankid.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
bankid_v51 "github.com/modfin/twofer/internal/eid/bankid/v5.1"
99
"github.com/modfin/twofer/internal/mtls"
1010
"net/http"
11+
"time"
1112
)
1213

1314
type ClientConfig struct {
@@ -16,6 +17,8 @@ type ClientConfig struct {
1617
PemRootCA []byte
1718
PemClientCert []byte
1819
PemClientKey []byte
20+
21+
PollInterval time.Duration
1922
}
2023

2124
func New(config ClientConfig) (client *BankID, err error) {
@@ -47,7 +50,7 @@ func New(config ClientConfig) (client *BankID, err error) {
4750

4851
client.APIv51 = bankid_v51.NewEid(client.httpClient, config.BaseURL)
4952

50-
client.APIv60 = bankid.NewAPI(client.httpClient, client.baseURL)
53+
client.APIv60 = bankid.NewAPI(client.httpClient, client.baseURL, config.PollInterval)
5154

5255
return
5356
}

0 commit comments

Comments
 (0)