1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
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
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
package fxa
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"github.com/armanlife/firefox-vpn-client/core/internal/controlplane"
)
// ErrnoRequestBlocked is FxA's "request blocked" errno: the sign-in looks
// unusual (new location, many attempts) and must be repeated with an unblock
// code that FxA emails on request.
const ErrnoRequestBlocked = 125
// Errno returns the FxA API errno carried by err, or 0.
func Errno(err error) int {
var apiErr *fxaAPIError
if errors.As(err, &apiErr) {
return apiErr.Errno
}
return 0
}
// Message returns the human-readable FxA error message carried by err,
// falling back to the error text.
func Message(err error) string {
var apiErr *fxaAPIError
if errors.As(err, &apiErr) {
var body struct {
Message string `json:"message"`
}
if json.Unmarshal([]byte(apiErr.Body), &body) == nil && body.Message != "" {
return body.Message
}
}
if err == nil {
return ""
}
return err.Error()
}
// SignedInEmail is the email for the rest of a sign-in begun with typed:
// the account's own spelling when FxA corrected its letter case, whether
// the sign-in then succeeded or failed for another reason (such as a block
// that wants an unblock code); else typed.
func SignedInEmail(typed string, resp *LoginResponse, err error) string {
if resp != nil && resp.Email != "" {
return resp.Email
}
var caseErr *emailCaseError
if errors.As(err, &caseErr) {
return caseErr.email
}
return typed
}
// LoginWithUnblockCode repeats a blocked sign-in with the unblock code that
// SendUnblockCode had emailed.
func LoginWithUnblockCode(ctx context.Context, email, password, unblockCode string) (*LoginResponse, error) {
return fxaLogin(ctx, email, password, strings.ToUpper(NormalizeCode(unblockCode)))
}
// SendUnblockCode asks FxA to email an unblock code for a blocked sign-in.
func SendUnblockCode(ctx context.Context, email string) error {
body, _ := json.Marshal(map[string]string{"email": email})
target := fxaAuthServer + "/account/login/send_unblock_code"
resp, err := fxaDo(ctx, func() (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, strings.NewReader(string(body)))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
controlplane.ApplyFirefoxHeaders(req)
return req, nil
})
if err != nil {
return fmt.Errorf("unblock code request: %w", err)
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if resp.StatusCode != http.StatusOK {
return newFxaAPIError(resp.StatusCode, data)
}
return nil
}
// VerifyTOTP verifies an unverified session with a code from the account's
// authenticator app (verification method "totp-2fa").
func VerifyTOTP(ctx context.Context, sessionToken, code string) error {
hawkID, hawkKey, err := deriveHawkCredentials(sessionToken, "sessionToken")
if err != nil {
return fmt.Errorf("deriving hawk credentials: %w", err)
}
body, _ := json.Marshal(map[string]string{"code": NormalizeCode(code)})
target := fxaAuthServer + "/session/verify/totp"
resp, err := fxaDo(ctx, func() (*http.Request, error) {
authHeader, err := hawkHeader(http.MethodPost, target, hawkID, hawkKey, string(body))
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, strings.NewReader(string(body)))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", authHeader)
controlplane.ApplyFirefoxHeaders(req)
return req, nil
})
if err != nil {
return fmt.Errorf("TOTP verification request: %w", err)
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if resp.StatusCode != http.StatusOK {
return newFxaAPIError(resp.StatusCode, data)
}
var result struct {
Success bool `json:"success"`
}
if err := json.Unmarshal(data, &result); err != nil {
return fmt.Errorf("parsing TOTP verification response: %w", err)
}
if !result.Success {
return errors.New("the authenticator code was not accepted")
}
return nil
}
// CaptchaSolver shows a Fastly CAPTCHA image (a data: URL) to a person and
// returns the characters they read. It is called while an FxA request is in
// progress, so it should return within a few minutes.
type CaptchaSolver func(ctx context.Context, imageDataURL string) (string, error)
type captchaSolverKey struct{}
// WithCaptchaSolver lets FxA calls made with the returned context answer
// image CAPTCHAs, which the challenge solver cannot answer by itself.
func WithCaptchaSolver(ctx context.Context, solver CaptchaSolver) context.Context {
return context.WithValue(ctx, captchaSolverKey{}, solver)
}
func captchaSolverFrom(ctx context.Context) CaptchaSolver {
solver, _ := ctx.Value(captchaSolverKey{}).(CaptchaSolver)
return solver
}