Skip to content

Commit baff0d6

Browse files
committed
Add support for BankID "order token", not sure about naming
1 parent 9078e1e commit baff0d6

9 files changed

Lines changed: 368 additions & 33 deletions

File tree

.github/workflows/tests.yaml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
name: tests
2+
on:
3+
pull_request:
4+
branches:
5+
- master
6+
push:
7+
branches:
8+
- master
9+
- order-ref-token
10+
workflow_dispatch: {}
11+
jobs:
12+
run-tests:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v5
16+
- uses: actions/setup-go@v5
17+
with:
18+
go-version: 'stable'
19+
- name: run
20+
run: |-
21+
cd test
22+
go test --race -v .

api/models.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package api
22

3+
import "time"
4+
35
type (
46
// BankIdV6Response used as a catch all response for V2
57
// Deprecated: stick to V1 or move to V3
@@ -39,7 +41,7 @@ type (
3941

4042
// BankIdv6AuthSignRequestV3 is used to start either an auth or sign request against BankID
4143
BankIdv6AuthSignRequestV3 struct {
42-
// EndUserIp The user IP address as it is seen by your service
44+
// EndUserIp The user IP address as it is seen by your service. Required.
4345
EndUserIp string `json:"endUserIp"`
4446

4547
// ReturnUrl Orders started on the same device as where the user's BankID is stored (started with autostart
@@ -64,6 +66,9 @@ type (
6466
// Once if true, will start an auth/sign and just return a single QR code, if false, auth/sign endpoint return
6567
// an SSE / NDJSON stream and send a new QR-code each second, for 30 seconds before returning
6668
Once bool `json:"once,omitempty"`
69+
70+
// OrderTokenExpire if order tokens are enabled, sets token expire time.
71+
OrderTokenExpire time.Duration `json:"order_token_expire,omitempty"`
6772
}
6873

6974
// BankIdV6AuthSignResponseV3 is sent as a successful reply to an auth or sign request. If SSE / NDJSON is used, a
@@ -77,6 +82,9 @@ type (
7782

7883
// QR contain the data for the QR-code
7984
QR string `json:"qr"`
85+
86+
// OrderToken is returned if order token support is enabled.
87+
OrderToken string `json:"orderToken,omitempty"`
8088
}
8189

8290
// BankIdv6CollectRequestV3 is used to collect status on a started auth / sign request
@@ -90,6 +98,11 @@ type (
9098
// WaitUntilFinished allows the request to wait until the referenced request has either completed or failed,
9199
// and will not return on state changes during the ongoing process.
92100
WaitUntilFinished bool `json:"waitUntilFinished"`
101+
102+
// OrderToken optionally pass an order token. This is an alternative to OrderRef.
103+
// If you use this the end user IP of the user who triggered collect is required for verification
104+
OrderToken string `json:"orderToken"`
105+
EndUserIp string `json:"endUserIp"`
93106
}
94107

95108
// BankIdV6CollectResponseV3 is sent for a successful collect, if WaitUntilFinished is set in the request, it will

cmd/twoferd/main.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ import (
1313

1414
"github.com/labstack/echo/v4"
1515
"github.com/labstack/echo/v4/middleware"
16-
1716
"github.com/modfin/twofer/internal/config"
1817
"github.com/modfin/twofer/internal/eid/bankid"
1918
"github.com/modfin/twofer/internal/httpserve"
19+
"github.com/modfin/twofer/internal/ordertoken"
2020
"github.com/modfin/twofer/internal/servotp"
2121
"github.com/modfin/twofer/internal/servpwd"
2222
"github.com/modfin/twofer/internal/servqr"
@@ -153,6 +153,16 @@ func startEid(e *echo.Echo) {
153153
return
154154
}
155155

156+
bankIdCfg := config.Get().BankID
157+
var otm *ordertoken.Manager
158+
if bankIdCfg.OrderTokenJwtEc256 != "" || bankIdCfg.OrderTokenJwtEc256Pub != "" || len(bankIdCfg.OrderTokenEncryptionKey) > 0 {
159+
fmt.Println(" - Enabling BankId Order Token support")
160+
otm, err = ordertoken.NewManager(bankIdCfg.OrderTokenJwtEc256, bankIdCfg.OrderTokenJwtEc256Pub, bankIdCfg.OrderTokenEncryptionKey)
161+
if err != nil {
162+
fmt.Printf("failed to initate bankId order token support %v", err)
163+
}
164+
}
165+
156166
//err = bankid.APIv51.Ping()
157167
//if err != nil {
158168
// fmt.Printf(" - Err: Could not ping bankid v5.1. %v", err)
@@ -164,7 +174,7 @@ func startEid(e *echo.Echo) {
164174

165175
fmt.Println(" - Adding BankId v6.0")
166176
fmt.Println(" - BankId Client Cert NotAfter:", bankid.ParsedClientCert().NotAfter)
167-
httpserve.RegisterBankIDServer(e, bankid.APIv60, getStreamEncoder(config.Get().StreamEncoder))
177+
httpserve.RegisterBankIDServer(e, bankid.APIv60, otm, getStreamEncoder(config.Get().StreamEncoder))
168178
err = bankid.APIv60.Ping()
169179
if err != nil {
170180
fmt.Printf(" - Err: Could not ping bankid. %v", err)

internal/config/config.go

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,18 @@ 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"`
47-
PollInterval time.Duration `env:"EID_BANKID_POLL_INTERVAL" envDefault:"2s"` // Poll BankID every two seconds as default (according to their spec)
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)
48+
OrderTokenEncryptionKey []string `env:"EID_BANKID_ORDER_TOKEN_ENCRYPTION_KEY" envSeparator:" "`
49+
OrderTokenJwtEc256 string `env:"EID_BANKID_ORDER_TOKEN_JWT_EC_256"`
50+
OrderTokenJwtEc256Pub string `env:"EID_BANKID_ORDER_TOKEN_JWT_EC_256_PUB"`
4851
}
4952

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

internal/httpserve/bankid.go

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ import (
66
"errors"
77
"fmt"
88
"io"
9+
"net"
910
"net/http"
1011
"strconv"
1112
"time"
1213

1314
"github.com/labstack/echo/v4"
1415
"github.com/modfin/twofer/api"
1516
"github.com/modfin/twofer/internal/bankid"
17+
"github.com/modfin/twofer/internal/ordertoken"
1618
"github.com/modfin/twofer/internal/sse"
1719
"github.com/modfin/twofer/stream"
1820
)
@@ -21,16 +23,16 @@ const qrCodeUpdatePeriod = time.Second
2123

2224
type NewStreamEncoder func(http.ResponseWriter) (stream.Encoder, error)
2325

24-
func RegisterBankIDServer(e *echo.Echo, client *bankid.API, newEncoder NewStreamEncoder) {
26+
func RegisterBankIDServer(e *echo.Echo, client *bankid.API, otm *ordertoken.Manager, newEncoder NewStreamEncoder) {
2527
e.POST("/bankid/v6/auth", auth(client))
2628
e.POST("/bankid/v6/authv2", authSign(client.Auth, client.WatchForChangeV2, qrCodeUpdatePeriod, newEncoder)) // Deprecated: Don't use
27-
e.POST("/bankid/v6/authv3", authSignV3(client.Auth, qrCodeUpdatePeriod, newEncoder)) // Same as 'auth' except won't poll BankID collect API (since a completed/failed orderRef can only be collected once)
29+
e.POST("/bankid/v6/authv3", authSignV3(client.Auth, qrCodeUpdatePeriod, newEncoder, otm)) // Same as 'auth' except won't poll BankID collect API (since a completed/failed orderRef can only be collected once)
2830
e.POST("/bankid/v6/sign", sign(client))
2931
e.POST("/bankid/v6/signv2", authSign(client.Sign, client.WatchForChangeV2, qrCodeUpdatePeriod, newEncoder)) // Deprecated: Don't use
30-
e.POST("/bankid/v6/signv3", authSignV3(client.Sign, qrCodeUpdatePeriod, newEncoder)) // Same as 'sign' except won't poll BankID collect API (since a completed/failed orderRef can only be collected once)
32+
e.POST("/bankid/v6/signv3", authSignV3(client.Sign, qrCodeUpdatePeriod, newEncoder, otm)) // Same as 'sign' except won't poll BankID collect API (since a completed/failed orderRef can only be collected once)
3133
e.POST("/bankid/v6/change", change(client))
3234
e.POST("/bankid/v6/collect", collect(client))
33-
e.POST("/bankid/v6/collectV3", collectV3(client))
35+
e.POST("/bankid/v6/collectV3", collectV3(client, otm))
3436
e.POST("/bankid/v6/cancel", cancel(client))
3537
e.POST("/bankid/v6/cancelV3", cancelV3(client))
3638
}
@@ -490,14 +492,17 @@ func bankIdv6ErrorResponseV3(err error, detail string) api.BankIdv6ErrorResponse
490492
// or change endpoints after the first QR-code has been returned. It also returns one or more
491493
// api.BankIdV6AuthSignResponseV3 structs for a successful auth/sign request. For failed requests,
492494
// a api.BankIdv6ErrorResponseV3 is returned instead.
493-
func authSignV3(authOrSignFn authSignFn, qrPeriod time.Duration, newStreamEncoder NewStreamEncoder) func(echo.Context) error {
495+
func authSignV3(authOrSignFn authSignFn, qrPeriod time.Duration, newStreamEncoder NewStreamEncoder, otm *ordertoken.Manager) func(echo.Context) error {
494496
return func(c echo.Context) error {
495497
request, err := readBody[api.BankIdv6AuthSignRequestV3](c.Request().Body)
496498
if err != nil {
497499
fmt.Printf("ERR: read request body error: %v\n", err)
498500
return c.JSON(http.StatusBadRequest, bankIdv6ErrorResponseV3(err, "read request body error"))
499501
}
500-
502+
if ip := net.ParseIP(request.EndUserIp); ip == nil {
503+
fmt.Printf("ERR: error parsing endUserIp\n")
504+
return c.JSON(http.StatusBadRequest, bankIdv6ErrorResponseV3(nil, "error parsing endUserIp"))
505+
}
501506
// Convert from public API to internal struct
502507
br := bankid.Requirement{
503508
PinCode: request.PinCode,
@@ -518,11 +523,25 @@ func authSignV3(authOrSignFn authSignFn, qrPeriod time.Duration, newStreamEncode
518523
return c.JSON(http.StatusBadRequest, bankIdv6ErrorResponseV3(err, "auth/sign request error"))
519524
}
520525

526+
orderToken := ""
527+
if otm != nil {
528+
t, err := otm.Create(request.OrderTokenExpire, ordertoken.Payload{
529+
OrderRef: res.OrderRef,
530+
EndUserIp: request.EndUserIp,
531+
})
532+
if err != nil {
533+
fmt.Printf("ERR: error creating order token: %v\n", err)
534+
return c.JSON(http.StatusBadRequest, bankIdv6ErrorResponseV3(err, "error creating order token"))
535+
}
536+
orderToken = t
537+
}
538+
521539
bankIdV6AuthSignResponseV3 := func(r *bankid.AuthSignResponse, qrNo int) api.BankIdV6AuthSignResponseV3 {
522540
return api.BankIdV6AuthSignResponseV3{
523-
OrderRef: r.OrderRef,
524-
URI: fmt.Sprintf("bankid:///?autostarttoken=%s&redirect=null", r.AutoStartToken),
525-
QR: r.BuildQrCode(qrNo),
541+
OrderRef: r.OrderRef,
542+
URI: fmt.Sprintf("bankid:///?autostarttoken=%s&redirect=null", r.AutoStartToken),
543+
QR: r.BuildQrCode(qrNo),
544+
OrderToken: orderToken,
526545
}
527546
}
528547

@@ -554,14 +573,25 @@ func authSignV3(authOrSignFn authSignFn, qrPeriod time.Duration, newStreamEncode
554573

555574
// Pretty much the same as collect and change, except that it will return an api.BankIdV6CollectResponseV3 struct for
556575
// successful requests, for failed requests, an api.BankIdv6ErrorResponseV3 is returned instead.
557-
func collectV3(client *bankid.API) func(echo.Context) error {
576+
func collectV3(client *bankid.API, otm *ordertoken.Manager) func(echo.Context) error {
558577
return func(c echo.Context) error {
559578
request, err := readBody[api.BankIdv6CollectRequestV3](c.Request().Body)
560579
if err != nil {
561580
fmt.Printf("ERR: read request body error: %v\n", err)
562581
return c.JSON(http.StatusBadRequest, bankIdv6ErrorResponseV3(err, "read request body error"))
563582
}
564583

584+
if otm != nil {
585+
claims, err := otm.Parse(request.OrderToken, request.EndUserIp)
586+
if err != nil && errors.Is(err, ordertoken.ErrOrderIpMismatch) {
587+
return c.JSON(http.StatusBadRequest, bankIdv6ErrorResponseV3(err, "order token ip mismatch with request ip"))
588+
}
589+
if err != nil {
590+
return c.JSON(http.StatusInternalServerError, bankIdv6ErrorResponseV3(err, "error parsing order token"))
591+
}
592+
request.OrderRef = claims.OrderRef
593+
}
594+
565595
var res *bankid.CollectResponse
566596
if request.WaitForChange || request.WaitUntilFinished {
567597
res, err = client.ChangeV3(c.Request().Context(), &bankid.ChangeRequest{
@@ -575,6 +605,9 @@ func collectV3(client *bankid.API) func(echo.Context) error {
575605
fmt.Printf("ERR: collect request error: %v\n", err)
576606
return c.JSON(http.StatusBadRequest, bankIdv6ErrorResponseV3(err, "collect request error"))
577607
}
608+
if otm != nil && res.CompletionData.Device.IpAddress != request.EndUserIp {
609+
return c.JSON(http.StatusBadRequest, bankIdv6ErrorResponseV3(nil, "order token ip mismatch with device ip"))
610+
}
578611

579612
reply := api.BankIdV6CollectResponseV3{
580613
OrderRef: res.OrderRef,

internal/ordertoken/ordertoken.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package ordertoken
2+
3+
import (
4+
"crypto/ecdsa"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"time"
9+
10+
"github.com/golang-jwt/jwt/v5"
11+
"github.com/modfin/twofer/internal/crypt"
12+
)
13+
14+
type claims struct {
15+
jwt.RegisteredClaims
16+
Payload []byte `json:"payload"`
17+
}
18+
19+
type Payload struct {
20+
OrderRef string `json:"orderRef"`
21+
EndUserIp string `json:"endUserIp"`
22+
}
23+
24+
type Manager struct {
25+
privateKey *ecdsa.PrivateKey
26+
publicKey *ecdsa.PublicKey
27+
cryptStore crypt.Store
28+
}
29+
30+
var ErrOrderIpMismatch = errors.New("order ip mismatch")
31+
32+
func NewManager(ec256 string, ec256pub string, encryptionKey []string) (*Manager, error) {
33+
privateKey, err := jwt.ParseECPrivateKeyFromPEM([]byte(ec256))
34+
if err != nil {
35+
return nil, fmt.Errorf("unable to parse ECDSA private key: %w", err)
36+
}
37+
publicKey, err := jwt.ParseECPublicKeyFromPEM([]byte(ec256pub))
38+
if err != nil {
39+
return nil, fmt.Errorf("unable to parse ECDSA public key: %w", err)
40+
}
41+
s, err := crypt.New(encryptionKey)
42+
if err != nil {
43+
return nil, fmt.Errorf("unable to create crypt store: %w", err)
44+
}
45+
return &Manager{
46+
privateKey: privateKey,
47+
publicKey: publicKey,
48+
cryptStore: s,
49+
}, nil
50+
}
51+
52+
// Parse parses and validates an order token and returns it's encrypted payload
53+
func (m *Manager) Parse(orderToken string, endUserIp string) (Payload, error) {
54+
var claims claims
55+
_, err := jwt.ParseWithClaims(orderToken, &claims, func(token *jwt.Token) (interface{}, error) {
56+
if token.Method.Alg() != jwt.SigningMethodES256.Alg() {
57+
return nil, fmt.Errorf("unexpected jwt signing method=%v", token.Header["alg"])
58+
}
59+
return m.publicKey, nil
60+
})
61+
if err != nil {
62+
return Payload{}, err
63+
}
64+
p, err := m.cryptStore.Decrypt(claims.Payload)
65+
if err != nil {
66+
return Payload{}, fmt.Errorf("failed to decrypt payload: %w", err)
67+
}
68+
var payload Payload
69+
err = json.Unmarshal(p, &payload)
70+
if err != nil {
71+
return Payload{}, fmt.Errorf("failed to unmarshal payload: %w", err)
72+
}
73+
if payload.EndUserIp != endUserIp {
74+
return Payload{}, ErrOrderIpMismatch
75+
}
76+
if payload.OrderRef == "" {
77+
return Payload{}, errors.New("order ref empty")
78+
}
79+
return payload, nil
80+
}
81+
82+
func (m *Manager) Create(expire time.Duration, payload Payload) (string, error) {
83+
if expire <= 0 {
84+
return "", errors.New("order token expire must be positive")
85+
}
86+
b, err := json.Marshal(payload)
87+
if err != nil {
88+
return "", fmt.Errorf("failed to marshal payload: %w", err)
89+
}
90+
p, err := m.cryptStore.Encrypt(b)
91+
if err != nil {
92+
return "", fmt.Errorf("failed to encrypt payload: %w", err)
93+
}
94+
t := time.Now()
95+
c := claims{
96+
RegisteredClaims: jwt.RegisteredClaims{
97+
IssuedAt: &jwt.NumericDate{Time: t},
98+
ExpiresAt: &jwt.NumericDate{Time: t.Add(expire)},
99+
Issuer: "twofer",
100+
},
101+
Payload: p,
102+
}
103+
token, err := jwt.NewWithClaims(jwt.SigningMethodES256, c).SignedString(m.privateKey)
104+
if err != nil {
105+
return "", err
106+
}
107+
return token, nil
108+
}

0 commit comments

Comments
 (0)