aboutsummaryrefslogtreecommitdiffstats
path: root/README.md
blob: 1c6c2ad1d9763eb5a712a40ceafc4241d2c6b776 (plain)
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
# fiken

A Go client for [Fiken](https://fiken.no)'s API v2, and the OAuth2 flow that
gets a token for it.

```sh
go get simenandre.no/fiken
```

Three packages, and the split is the point:

- **`fiken`** knows how to put a bearer token in a header and how to read
  Fiken's answers. Where the token came from is not its business.
- **`fikenauth`** gets and keeps a token: the authorization-code flow over a
  loopback redirect, refreshing an expired grant, and a `0600` file to store it
  in. Refreshing is a side effect of asking for a token rather than a step the
  caller has to remember, which is the only way it gets done everywhere.
- **`fikenconfig`** is the login every program on the machine shares, so
  logging in once, from any of them, logs in all of them.

```go
store := fikenauth.NewStore(filepath.Join(dir, "fiken-token.json"))
source := fikenauth.NewSource(cfg, store)
source.LoginCommand = "myapp fiken login" // named in the error if the grant dies

client, err := fiken.NewWithTokens(source)
```

## One login for every program

`fikenconfig` keeps one OAuth app and a grant per Fiken account in
`~/.config/fiken` (`$XDG_CONFIG_HOME/fiken`, or `$FIKEN_CONFIG_DIR`):

```
config.json                  the app, and each account with the companies it reaches
accounts/<email>.json        each account's grant
```

```json
{
  "app": {
    "client_id": "...",
    "client_secret": "...",
    "redirect_url": "http://localhost:8137/callback"
  },
  "accounts": {
    "simen@sokkel.io": {
      "name": "Simen",
      "companies": [{ "name": "Sokkel AS", "slug": "sokkel-as" }]
    },
    "post@bjerk.io": { "companies": [...], "token": "a personal API token" }
  }
}
```

A program names the company it works for and gets the account that reaches
it. Which company that is stays the program's business:

```go
cfg, err := fikenconfig.Open("")
cfg.LoginCommand = "myapp login"
client, err := cfg.Client(ctx, "sokkel-as")
```

Logging in records the grant under the email of the Fiken user who gave it:

```go
grant, err := cfg.Auth().Login(ctx, announce)
email, err := cfg.AddGrant(ctx, grant)
```

A company added in Fiken after the login is found without logging in again:
when no account has it, `Client` asks each account once more.

Sharing the grant file is the only safe way to share a grant, because Fiken
rotates the refresh token on every renewal and two copies means the first to
renew silently logs out the other. Renewing takes a lock beside the file and
reads it again under the lock, so two programs renewing at once take turns.
`$FIKEN_CLIENT_ID` and `$FIKEN_CLIENT_SECRET` override the app in the file.

## Without the shared login

A personal API token works too, with no OAuth at all:

```go
client, err := fiken.New(os.Getenv("FIKEN_TOKEN"))
```

## What it is careful about

**Amounts are integers.** `DraftLine.UnitPrice` is the invoice currency's minor
unit and `Quantity` is normally 1, so the amount is carried entirely by the
price and no float ever enters the payload. Fiken's minor unit has two decimals
whatever the currency, so a price carried at more precision than that has to be
rounded by the caller — where the rounding rule can be stated and tested,
rather than here where it would be invisible.

**A token never reaches a log line.** `Token.String` hides its own value, so
`%v` on one prints nothing useful.

**404 is its own error.** `ErrNoSuchResource` wraps it, because deleting a draft
that is already gone is usually the state the caller wanted, not a failure.
`ErrUnauthorized` is likewise separate from a transport error: one means log in
again, the other means try again.

**Retries are bounded and only for what is retryable.** A 5xx or 429 is retried;
a 4xx is not, because sending the same bad request again cannot help. Nothing
that creates something is retried at all: a 5xx does not say whether Fiken
acted before failing, and a draft or contact made twice is worse than an
error.

## Status

Extracted from a working invoicing tool and used by three of them, so the
surface is what those needed rather than all of Fiken: companies, contacts,
invoices, drafts, credit notes. Pull requests that widen it are welcome; the
constraints above are not up for negotiation.

Tests run against `httptest` servers — including retries, 4xx, and a declined
consent — and reach nothing real.