summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorSimen A. W. Olsen <hello@simenandre.no>2026-10-17 14:19:09 +0200
committerSimen A. W. Olsen <hello@simenandre.no>2026-10-17 14:19:09 +0200
commitc0a01ac65bb492640b169274614e0d8022f5076d (patch)
treef7d3f0e81d1a9dc05288ba24f1ecc57387f3e0d5
downloadfiken-c0a01ac65bb492640b169274614e0d8022f5076d.tar.gz
fiken-c0a01ac65bb492640b169274614e0d8022f5076d.zip
Fiken API client and OAuth2 for Gov0.1.0
Extracted from gcbill, where it was internal/fiken and internal/fikenauth, so adaa and sokkel-billing can use the same client instead of each carrying its own. The login command named when a grant cannot be renewed is now injected (Source.LoginCommand): it was hardcoded to gcbill's, which would have sent every other caller's operator to a command that does not exist.
-rw-r--r--.gitignore1
-rw-r--r--README.md61
-rw-r--r--fiken/fiken.go407
-rw-r--r--fiken/fiken_test.go320
-rw-r--r--fiken/transport.go174
-rw-r--r--fikenauth/login.go217
-rw-r--r--fikenauth/oauth.go246
-rw-r--r--fikenauth/oauth_test.go264
-rw-r--r--fikenauth/source.go125
-rw-r--r--fikenauth/source_test.go172
-rw-r--r--fikenauth/store_test.go298
-rw-r--r--fikenauth/token.go143
-rw-r--r--go.mod3
-rw-r--r--internal/assert/assert.go36
14 files changed, 2467 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ae3c172
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/bin/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..7e59e41
--- /dev/null
+++ b/README.md
@@ -0,0 +1,61 @@
+# 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
+```
+
+Two 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.
+
+```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)
+```
+
+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.
+
+## 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.
diff --git a/fiken/fiken.go b/fiken/fiken.go
new file mode 100644
index 0000000..3179a62
--- /dev/null
+++ b/fiken/fiken.go
@@ -0,0 +1,407 @@
+// Package fiken is a small client for the parts of the Fiken API v2 that this
+// program uses: companies, contacts, invoices, drafts and credit notes.
+//
+// It is hand-written rather than generated. The surface is five calls, and a
+// generated client for a 324 KB specification would be far more code than the
+// thing it replaces.
+package fiken
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+
+ "simenandre.no/fiken/internal/assert"
+)
+
+// DefaultBaseURL is Fiken's production API.
+const DefaultBaseURL = "https://api.fiken.no/api/v2"
+
+// Bounds on every request, stated once.
+const (
+ requestTimeout = 30 * time.Second
+ maxAttempts = 3
+ retryBackoff = 500 * time.Millisecond
+ maxBodyBytes = 8 << 20 // 8 MiB; a contact list is far smaller
+ maxPageSize = 100
+ maxPages = 100 // 10 000 contacts is already implausible
+)
+
+// ErrUnauthorized means the token is missing, wrong, or revoked.
+var ErrUnauthorized = errors.New("fiken rejected the API token")
+
+// ErrNoSuchResource means Fiken has nothing at that path. On a delete it is
+// not a failure: whatever was there is gone, which is what was asked for.
+var ErrNoSuchResource = errors.New("fiken has no such thing")
+
+// ErrNoCreditNoteCounter means the company has never issued a credit note, so
+// Fiken has no number to give the first one. Starting the series is a
+// bookkeeping decision, which is why it is never made implicitly.
+var ErrNoCreditNoteCounter = errors.New("fiken has no credit note numbering for this company yet")
+
+// TokenSource supplies the bearer token for a request. A personal API token is
+// a constant; an OAuth2 token is not, so the client asks for one per call
+// rather than holding a string that may have expired since it was fetched.
+type TokenSource interface {
+ Token(ctx context.Context) (string, error)
+}
+
+// StaticToken is a token that never changes, such as a personal API token.
+type StaticToken string
+
+// Token returns the constant.
+func (t StaticToken) Token(context.Context) (string, error) {
+ if strings.TrimSpace(string(t)) == "" {
+ return "", errors.New("no Fiken API token")
+ }
+ return string(t), nil
+}
+
+// Client talks to Fiken on behalf of one credential.
+type Client struct {
+ baseURL string
+ tokens TokenSource
+ http *http.Client
+ sleep func(time.Duration)
+}
+
+// Option adjusts a client at construction.
+type Option func(*Client)
+
+// WithBaseURL points the client somewhere else, which is how the tests aim it
+// at an httptest server.
+func WithBaseURL(u string) Option {
+ return func(c *Client) { c.baseURL = strings.TrimSuffix(u, "/") }
+}
+
+// WithHTTPClient replaces the underlying HTTP client.
+func WithHTTPClient(h *http.Client) Option {
+ return func(c *Client) { c.http = h }
+}
+
+// withSleep makes retry backoff instant in tests.
+func withSleep(f func(time.Duration)) Option {
+ return func(c *Client) { c.sleep = f }
+}
+
+// New returns a client authenticating with a fixed token, which is what a
+// personal API token is.
+func New(token string, opts ...Option) (*Client, error) {
+ if strings.TrimSpace(token) == "" {
+ return nil, errors.New("no Fiken API token: set FIKEN_API_TOKEN or fiken.token_file")
+ }
+ return NewWithTokens(StaticToken(token), opts...)
+}
+
+// NewWithTokens returns a client that asks source for a token per request.
+// Fiken accepts a personal API token and an OAuth2 access token in the same
+// Authorization header, so nothing below this point knows which is in use.
+func NewWithTokens(source TokenSource, opts ...Option) (*Client, error) {
+ if source == nil {
+ return nil, errors.New("no Fiken credential")
+ }
+ c := &Client{
+ baseURL: DefaultBaseURL,
+ tokens: source,
+ http: &http.Client{Timeout: requestTimeout},
+ sleep: time.Sleep,
+ }
+ for _, opt := range opts {
+ opt(c)
+ }
+ return c, nil
+}
+
+// Company is a Fiken company. Its Slug appears in every other URL.
+type Company struct {
+ Name string `json:"name"`
+ Slug string `json:"slug"`
+ OrganizationNumber string `json:"organizationNumber"`
+}
+
+// Contact is someone who can be invoiced.
+type Contact struct {
+ ContactID int64 `json:"contactId"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+ OrganizationNumber string `json:"organizationNumber"`
+ CustomerNumber int32 `json:"customerNumber"`
+ Customer bool `json:"customer"`
+ Inactive bool `json:"inactive"`
+}
+
+// DraftLine is one line of an invoice draft. UnitPrice is in the invoice
+// currency's minor unit — øre, cents — 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 with more precision than that has to be rounded by the caller, where
+// the rule can be stated.
+type DraftLine struct {
+ Description string `json:"description,omitempty"`
+ Comment string `json:"comment,omitempty"`
+ UnitPrice int64 `json:"unitPrice"`
+ Quantity int `json:"quantity"`
+ VATType string `json:"vatType"`
+ IncomeAccount string `json:"incomeAccount,omitempty"`
+}
+
+// Draft is an invoice draft to create.
+type Draft struct {
+ Type string `json:"type"`
+ IssueDate string `json:"issueDate,omitempty"`
+ DaysUntilDueDate int `json:"daysUntilDueDate"`
+ InvoiceText string `json:"invoiceText,omitempty"`
+ OurReference string `json:"ourReference,omitempty"`
+ YourReference string `json:"yourReference,omitempty"`
+ CustomerID int64 `json:"customerId"`
+ Currency string `json:"currency,omitempty"`
+ Lines []DraftLine `json:"lines"`
+}
+
+// Companies lists the companies the token can act for.
+func (c *Client) Companies(ctx context.Context) ([]Company, error) {
+ return page[Company](ctx, c, "/companies", nil)
+}
+
+// Customers lists a company's active contacts that are customers.
+func (c *Client) Customers(ctx context.Context, companySlug string) ([]Contact, error) {
+ assert.True(companySlug != "", "company slug must not be empty")
+ query := url.Values{"customer": {"true"}, "inactive": {"false"}}
+ return page[Contact](ctx, c, "/companies/"+companySlug+"/contacts", query)
+}
+
+// Invoice is an invoice that already exists in Fiken. Only the fields needed
+// to recognise one and match it to a month are decoded; Fiken sends a great
+// deal more.
+type Invoice struct {
+ InvoiceID int64 `json:"invoiceId"`
+ InvoiceNumber int64 `json:"invoiceNumber"`
+ IssueDate string `json:"issueDate"` // YYYY-MM-DD
+ DueDate string `json:"dueDate"`
+ Customer Contact `json:"customer"`
+ Currency string `json:"currency"`
+ InvoiceText string `json:"invoiceText"`
+
+ // OurReference is what the sender wrote on the invoice. A caller puts its
+ // own mark there when it creates a draft, and Fiken carries it onto the
+ // invoice the draft becomes — which is the only thing tying the two
+ // together once the draft is gone.
+ OurReference string `json:"ourReference"`
+
+ // Gross is the total including VAT, in minor units. Fiken reports amounts
+ // as integers, which is the only reason no float appears anywhere here.
+ Gross int64 `json:"gross"`
+ Net int64 `json:"net"`
+ GrossInNok int64 `json:"grossInNok"`
+ Settled bool `json:"settled"`
+
+ // Lines are what the invoice actually says. An invoice covering more than
+ // one month usually says so here and nowhere else, so this is the only
+ // thing that can tell a reader which periods it settles.
+ Lines []InvoiceLine `json:"lines"`
+}
+
+// InvoiceLine is one line of an existing invoice. Amounts are minor units, as
+// everywhere else in Fiken.
+type InvoiceLine struct {
+ Description string `json:"description"`
+ ProductName string `json:"productName"`
+ // Comment is where a month ends up. Fiken has no field for the period a
+ // line covers, so an invoice that settles several months says so here.
+ Comment string `json:"comment"`
+ Net int64 `json:"net"`
+ Gross int64 `json:"gross"`
+}
+
+// Text is what a line says, preferring the description Fiken shows over the
+// product name behind it.
+func (l InvoiceLine) Text() string {
+ for _, candidate := range []string{l.Description, l.ProductName, l.Comment} {
+ if strings.TrimSpace(candidate) != "" {
+ return strings.TrimSpace(candidate)
+ }
+ }
+ return ""
+}
+
+// Invoices lists the invoices a company has made out to one contact. It is
+// what an invoice importer reads, a customer at a time: the caller already
+// knows which contact each customer is invoiced as, so Fiken does the matching
+// rather than the caller sifting a whole company's invoices.
+//
+// A zero contactID asks for every invoice the company has.
+func (c *Client) Invoices(ctx context.Context, companySlug string, contactID int64) ([]Invoice, error) {
+ assert.True(companySlug != "", "company slug must not be empty")
+ query := url.Values{}
+ if contactID != 0 {
+ query.Set("customerId", strconv.FormatInt(contactID, 10))
+ }
+ return page[Invoice](ctx, c, "/companies/"+companySlug+"/invoices", query)
+}
+
+// InvoiceDraft is a draft as Fiken lists it: enough to tell which of the
+// drafts are still sitting there unissued.
+type InvoiceDraft struct {
+ DraftID int64 `json:"draftId"`
+ OurReference string `json:"ourReference"`
+ Net int64 `json:"net"`
+}
+
+// Drafts lists a company's unissued invoice drafts. What is missing from it is
+// the useful part: a draft the caller created that is no longer here was either
+// issued or thrown away.
+func (c *Client) Drafts(ctx context.Context, companySlug string) ([]InvoiceDraft, error) {
+ assert.True(companySlug != "", "company slug must not be empty")
+ return page[InvoiceDraft](ctx, c, "/companies/"+companySlug+"/invoices/drafts", nil)
+}
+
+// CreateDraft creates an invoice draft and returns its id. Fiken reports the
+// new resource only in a Location header, so the id is read back out of it.
+func (c *Client) CreateDraft(ctx context.Context, companySlug string, draft Draft) (int64, error) {
+ assert.True(companySlug != "", "company slug must not be empty")
+ assert.True(draft.CustomerID > 0, "draft needs a Fiken customer id")
+ assert.True(len(draft.Lines) > 0, "refusing to create an empty invoice draft")
+
+ body, err := json.Marshal(draft)
+ if err != nil {
+ return 0, fmt.Errorf("encode invoice draft: %w", err)
+ }
+ resp, err := c.do(ctx, http.MethodPost, "/companies/"+companySlug+"/invoices/drafts", nil, body)
+ if err != nil {
+ return 0, err
+ }
+ defer resp.Body.Close()
+ io.Copy(io.Discard, io.LimitReader(resp.Body, maxBodyBytes)) //nolint:errcheck
+
+ return idFromLocation(resp.Header.Get("Location"))
+}
+
+// DeleteDraft removes an invoice draft.
+func (c *Client) DeleteDraft(ctx context.Context, companySlug string, draftID int64) error {
+ assert.True(draftID > 0, "draft id must be positive, got %d", draftID)
+ path := fmt.Sprintf("/companies/%s/invoices/drafts/%d", companySlug, draftID)
+ resp, err := c.do(ctx, http.MethodDelete, path, nil, nil)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ io.Copy(io.Discard, io.LimitReader(resp.Body, maxBodyBytes)) //nolint:errcheck
+ return nil
+}
+
+// CreateInvoiceFromDraft turns a draft into a real invoice and returns its id.
+// It cannot be undone except by a credit note.
+func (c *Client) CreateInvoiceFromDraft(ctx context.Context, companySlug string, draftID int64) (int64, error) {
+ assert.True(draftID > 0, "draft id must be positive, got %d", draftID)
+ path := fmt.Sprintf("/companies/%s/invoices/drafts/%d/createInvoice", companySlug, draftID)
+ resp, err := c.do(ctx, http.MethodPost, path, nil, nil)
+ if err != nil {
+ return 0, err
+ }
+ defer resp.Body.Close()
+ io.Copy(io.Discard, io.LimitReader(resp.Body, maxBodyBytes)) //nolint:errcheck
+
+ return idFromLocation(resp.Header.Get("Location"))
+}
+
+// CreditNote cancels an issued invoice in full. Fiken copies the invoice's
+// lines and amounts itself, so nothing here carries money.
+type CreditNote struct {
+ InvoiceID int64 `json:"invoiceId"`
+ IssueDate string `json:"issueDate"` // YYYY-MM-DD
+ Text string `json:"creditNoteText,omitempty"`
+}
+
+// IssuedCreditNote is a credit note as Fiken lists it: enough to tell which
+// invoice it cancels.
+type IssuedCreditNote struct {
+ CreditNoteID int64 `json:"creditNoteId"`
+ CreditNoteNumber int64 `json:"creditNoteNumber"`
+ IssueDate string `json:"issueDate"`
+ AssociatedInvoiceID int64 `json:"associatedInvoiceId"`
+ Net int64 `json:"net"`
+}
+
+// CreditNotes lists every credit note the company has issued.
+func (c *Client) CreditNotes(ctx context.Context, companySlug string) ([]IssuedCreditNote, error) {
+ assert.True(companySlug != "", "company slug must not be empty")
+ return page[IssuedCreditNote](ctx, c, "/companies/"+companySlug+"/creditNotes", nil)
+}
+
+// MaxCreditNoteText is Fiken's limit on a credit note's text.
+const MaxCreditNoteText = 500
+
+// CreateFullCreditNote credits the whole of an invoice and returns the credit
+// note's id. Like issuing, it cannot be undone, and like issuing it does not
+// send anything to the customer.
+func (c *Client) CreateFullCreditNote(ctx context.Context, companySlug string, note CreditNote) (int64, error) {
+ assert.True(companySlug != "", "company slug must not be empty")
+ assert.True(note.InvoiceID > 0, "credit note needs an invoice id, got %d", note.InvoiceID)
+ assert.True(note.IssueDate != "", "credit note needs an issue date")
+ assert.True(len(note.Text) <= MaxCreditNoteText, "credit note text is %d characters, over %d",
+ len(note.Text), MaxCreditNoteText)
+
+ body, err := json.Marshal(note)
+ if err != nil {
+ return 0, fmt.Errorf("encode credit note: %w", err)
+ }
+ // Never retried: a second attempt after a 5xx Fiken did act on would
+ // credit the invoice twice.
+ resp, err := c.doOnce(ctx, http.MethodPost, "/companies/"+companySlug+"/creditNotes/full", nil, body)
+ if err != nil {
+ // Fiken says this only in prose, with a 409 it uses for other
+ // conflicts too.
+ var apiErr *APIError
+ if errors.As(err, &apiErr) && apiErr.Status == http.StatusConflict &&
+ strings.Contains(strings.ToLower(apiErr.Message), "counter not initialized") {
+ return 0, fmt.Errorf("%w: %s", ErrNoCreditNoteCounter, apiErr.Message)
+ }
+ return 0, err
+ }
+ defer resp.Body.Close()
+ io.Copy(io.Discard, io.LimitReader(resp.Body, maxBodyBytes)) //nolint:errcheck
+
+ return idFromLocation(resp.Header.Get("Location"))
+}
+
+// StartCreditNoteCounter begins a company's credit note numbering, so the next
+// credit note is numbered first.
+func (c *Client) StartCreditNoteCounter(ctx context.Context, companySlug string, first int64) error {
+ assert.True(companySlug != "", "company slug must not be empty")
+ assert.True(first >= 1, "first credit note number must be positive, got %d", first)
+
+ // Fiken stores the number before the first, and counts up from it.
+ body, err := json.Marshal(struct {
+ Value int64 `json:"value"`
+ }{first - 1})
+ if err != nil {
+ return fmt.Errorf("encode credit note counter: %w", err)
+ }
+ resp, err := c.do(ctx, http.MethodPost, "/companies/"+companySlug+"/creditNotes/counter", nil, body)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ io.Copy(io.Discard, io.LimitReader(resp.Body, maxBodyBytes)) //nolint:errcheck
+ return nil
+}
+
+// idFromLocation reads the trailing numeric id out of a Location URL.
+func idFromLocation(location string) (int64, error) {
+ if location == "" {
+ return 0, errors.New("fiken did not return a Location header for the new resource")
+ }
+ trimmed := strings.TrimSuffix(location, "/")
+ id, err := strconv.ParseInt(trimmed[strings.LastIndex(trimmed, "/")+1:], 10, 64)
+ if err != nil {
+ return 0, fmt.Errorf("fiken returned an unreadable Location %q", location)
+ }
+ return id, nil
+}
diff --git a/fiken/fiken_test.go b/fiken/fiken_test.go
new file mode 100644
index 0000000..2962e4a
--- /dev/null
+++ b/fiken/fiken_test.go
@@ -0,0 +1,320 @@
+package fiken
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+)
+
+func newTestClient(t *testing.T, handler http.HandlerFunc) *Client {
+ t.Helper()
+ server := httptest.NewServer(handler)
+ t.Cleanup(server.Close)
+
+ c, err := New("test-token",
+ WithBaseURL(server.URL),
+ WithHTTPClient(server.Client()),
+ withSleep(func(time.Duration) {}))
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ return c
+}
+
+func TestNewRequiresAToken(t *testing.T) {
+ if _, err := New(" "); err == nil {
+ t.Fatal("New accepted an empty token")
+ }
+}
+
+func TestCustomersSendsTheTokenAndFilters(t *testing.T) {
+ var gotAuth, gotQuery string
+ c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+ gotAuth = r.Header.Get("Authorization")
+ gotQuery = r.URL.RawQuery
+ w.Header().Set("Fiken-Api-Page-Count", "1")
+ json.NewEncoder(w).Encode([]Contact{{ContactID: 7340852, Name: "Tvimenning AS", Customer: true}})
+ })
+
+ got, err := c.Customers(context.Background(), "sokkel-as")
+ if err != nil {
+ t.Fatalf("Customers: %v", err)
+ }
+ if gotAuth != "Bearer test-token" {
+ t.Errorf("Authorization = %q", gotAuth)
+ }
+ for _, want := range []string{"customer=true", "inactive=false", "pageSize=100"} {
+ if !strings.Contains(gotQuery, want) {
+ t.Errorf("query %q is missing %q", gotQuery, want)
+ }
+ }
+ if len(got) != 1 || got[0].ContactID != 7340852 {
+ t.Errorf("Customers = %+v", got)
+ }
+}
+
+func TestCustomersFollowsPages(t *testing.T) {
+ c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+ pageNo, _ := strconv.Atoi(r.URL.Query().Get("page"))
+ w.Header().Set("Fiken-Api-Page-Count", "3")
+ json.NewEncoder(w).Encode([]Contact{{ContactID: int64(pageNo + 1), Name: fmt.Sprintf("Page %d", pageNo)}})
+ })
+
+ got, err := c.Customers(context.Background(), "sokkel-as")
+ if err != nil {
+ t.Fatalf("Customers: %v", err)
+ }
+ if len(got) != 3 {
+ t.Fatalf("got %d contacts across pages, want 3", len(got))
+ }
+ if got[2].ContactID != 3 {
+ t.Errorf("last contact = %+v", got[2])
+ }
+}
+
+func TestCreateDraftReadsTheLocationHeader(t *testing.T) {
+ var body Draft
+ c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ t.Errorf("method = %s, want POST", r.Method)
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ t.Errorf("decode body: %v", err)
+ }
+ w.Header().Set("Location", "https://api.fiken.no/api/v2/companies/sokkel-as/invoices/drafts/778899")
+ w.WriteHeader(http.StatusCreated)
+ })
+
+ draft := Draft{
+ Type: "invoice", CustomerID: 7340852, DaysUntilDueDate: 14, Currency: "NOK",
+ Lines: []DraftLine{{Description: "Compute Engine", UnitPrice: 821044, Quantity: 1, VATType: "HIGH"}},
+ }
+ id, err := c.CreateDraft(context.Background(), "sokkel-as", draft)
+ if err != nil {
+ t.Fatalf("CreateDraft: %v", err)
+ }
+ if id != 778899 {
+ t.Errorf("draft id = %d, want 778899", id)
+ }
+ if body.Lines[0].UnitPrice != 821044 || body.Lines[0].Quantity != 1 {
+ t.Errorf("line was sent as %+v, want an integer øre price at quantity 1", body.Lines[0])
+ }
+}
+
+func TestCreateDraftWithoutLocationIsAnError(t *testing.T) {
+ c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusCreated)
+ })
+ _, err := c.CreateDraft(context.Background(), "sokkel-as", Draft{
+ CustomerID: 1, Lines: []DraftLine{{UnitPrice: 1, Quantity: 1}},
+ })
+ if err == nil || !strings.Contains(err.Error(), "Location") {
+ t.Fatalf("err = %v, want a complaint about the missing Location header", err)
+ }
+}
+
+// Rate limits and server faults are worth another try; the caller never sees them.
+func TestRetriesRateLimitAndServerErrors(t *testing.T) {
+ for _, status := range []int{http.StatusTooManyRequests, http.StatusBadGateway} {
+ attempts := 0
+ c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+ attempts++
+ if attempts < 3 {
+ w.WriteHeader(status)
+ return
+ }
+ w.Header().Set("Fiken-Api-Page-Count", "1")
+ json.NewEncoder(w).Encode([]Company{{Slug: "sokkel-as"}})
+ })
+
+ got, err := c.Companies(context.Background())
+ if err != nil {
+ t.Fatalf("status %d: Companies: %v", status, err)
+ }
+ if attempts != 3 {
+ t.Errorf("status %d: made %d attempts, want 3", status, attempts)
+ }
+ if len(got) != 1 {
+ t.Errorf("status %d: got %+v", status, got)
+ }
+ }
+}
+
+func TestGivesUpAfterMaxAttempts(t *testing.T) {
+ attempts := 0
+ c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+ attempts++
+ w.WriteHeader(http.StatusServiceUnavailable)
+ })
+ if _, err := c.Companies(context.Background()); err == nil {
+ t.Fatal("Companies eventually succeeded, want an error")
+ }
+ if attempts != maxAttempts {
+ t.Errorf("made %d attempts, want %d", attempts, maxAttempts)
+ }
+}
+
+// A bad request is a decision, not a hiccup: report it once, immediately.
+func TestClientErrorsAreNotRetried(t *testing.T) {
+ attempts := 0
+ c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+ attempts++
+ w.WriteHeader(http.StatusBadRequest)
+ w.Write([]byte(`{"validationMessages":["customerId is required","lines must not be empty"]}`))
+ })
+
+ _, err := c.CreateDraft(context.Background(), "sokkel-as", Draft{
+ CustomerID: 1, Lines: []DraftLine{{UnitPrice: 1, Quantity: 1}},
+ })
+ if err == nil {
+ t.Fatal("expected an error")
+ }
+ if attempts != 1 {
+ t.Errorf("made %d attempts, want 1", attempts)
+ }
+ if !strings.Contains(err.Error(), "customerId is required") {
+ t.Errorf("error %q should carry what Fiken said", err)
+ }
+
+ var apiErr *APIError
+ if !errors.As(err, &apiErr) || apiErr.Status != http.StatusBadRequest {
+ t.Errorf("err = %v, want an APIError with status 400", err)
+ }
+}
+
+func TestUnauthorized(t *testing.T) {
+ c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusUnauthorized)
+ w.Write([]byte(`{"message":"Unauthorized"}`))
+ })
+ if _, err := c.Companies(context.Background()); !errors.Is(err, ErrUnauthorized) {
+ t.Errorf("err = %v, want ErrUnauthorized", err)
+ }
+}
+
+func TestDeleteAndIssue(t *testing.T) {
+ var deleted, issued bool
+ c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodDelete:
+ deleted = true
+ w.WriteHeader(http.StatusNoContent)
+ case strings.HasSuffix(r.URL.Path, "/createInvoice"):
+ issued = true
+ w.Header().Set("Location", "https://api.fiken.no/api/v2/companies/sokkel-as/invoices/5150")
+ w.WriteHeader(http.StatusCreated)
+ default:
+ t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
+ }
+ })
+
+ if err := c.DeleteDraft(context.Background(), "sokkel-as", 778899); err != nil {
+ t.Fatalf("DeleteDraft: %v", err)
+ }
+ id, err := c.CreateInvoiceFromDraft(context.Background(), "sokkel-as", 778899)
+ if err != nil {
+ t.Fatalf("CreateInvoiceFromDraft: %v", err)
+ }
+ if id != 5150 {
+ t.Errorf("invoice id = %d, want 5150", id)
+ }
+ if !deleted || !issued {
+ t.Errorf("deleted = %v, issued = %v", deleted, issued)
+ }
+}
+
+func TestCreateFullCreditNote(t *testing.T) {
+ var got map[string]any
+ c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost || r.URL.Path != "/companies/sokkel-as/creditNotes/full" {
+ t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
+ }
+ json.NewDecoder(r.Body).Decode(&got) //nolint:errcheck
+ w.Header().Set("Location", "https://api.fiken.no/api/v2/companies/sokkel-as/creditNotes/6160")
+ w.WriteHeader(http.StatusCreated)
+ })
+
+ id, err := c.CreateFullCreditNote(context.Background(), "sokkel-as",
+ CreditNote{InvoiceID: 5150, IssueDate: "2026-10-17", Text: "Wrong recipient"})
+ if err != nil {
+ t.Fatalf("CreateFullCreditNote: %v", err)
+ }
+ if id != 6160 {
+ t.Errorf("credit note id = %d, want 6160", id)
+ }
+ if got["invoiceId"] != float64(5150) || got["issueDate"] != "2026-10-17" || got["creditNoteText"] != "Wrong recipient" {
+ t.Errorf("body = %v", got)
+ }
+}
+
+// A 5xx does not say whether Fiken credited the invoice before failing, so
+// trying again could credit it twice.
+func TestCreditNoteIsNeverRetried(t *testing.T) {
+ calls := 0
+ c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+ calls++
+ w.WriteHeader(http.StatusBadGateway)
+ })
+
+ _, err := c.CreateFullCreditNote(context.Background(), "sokkel-as",
+ CreditNote{InvoiceID: 5150, IssueDate: "2026-10-17"})
+ if err == nil {
+ t.Fatal("CreateFullCreditNote succeeded on a 502")
+ }
+ if calls != 1 {
+ t.Errorf("made %d requests, want exactly one", calls)
+ }
+}
+
+func TestCreditNotes(t *testing.T) {
+ c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/companies/sokkel-as/creditNotes" {
+ t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
+ }
+ w.Header().Set("Fiken-Api-Page-Count", "1")
+ fmt.Fprint(w, `[{"creditNoteId":6160,"creditNoteNumber":12,"associatedInvoiceId":5150,"net":-25880}]`)
+ })
+
+ got, err := c.CreditNotes(context.Background(), "sokkel-as")
+ if err != nil {
+ t.Fatalf("CreditNotes: %v", err)
+ }
+ if len(got) != 1 || got[0].CreditNoteID != 6160 || got[0].AssociatedInvoiceID != 5150 {
+ t.Errorf("CreditNotes = %+v", got)
+ }
+}
+
+func TestCreditNoteWithoutNumberingIsRecognised(t *testing.T) {
+ var counter map[string]any
+ c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case strings.HasSuffix(r.URL.Path, "/creditNotes/full"):
+ w.WriteHeader(http.StatusConflict)
+ fmt.Fprint(w, `{"error_description":"Company credit note counter not initialized, create a credit note in Fiken to set base number OR use POST /creditNotes/counter to set the base number","error":"409"}`)
+ case strings.HasSuffix(r.URL.Path, "/creditNotes/counter"):
+ json.NewDecoder(r.Body).Decode(&counter) //nolint:errcheck
+ w.WriteHeader(http.StatusCreated)
+ default:
+ t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
+ }
+ })
+
+ _, err := c.CreateFullCreditNote(context.Background(), "sokkel-as",
+ CreditNote{InvoiceID: 5150, IssueDate: "2026-10-17"})
+ if !errors.Is(err, ErrNoCreditNoteCounter) {
+ t.Fatalf("err = %v, want ErrNoCreditNoteCounter", err)
+ }
+ if err := c.StartCreditNoteCounter(context.Background(), "sokkel-as", 10001); err != nil {
+ t.Fatalf("StartCreditNoteCounter: %v", err)
+ }
+ if counter["value"] != float64(10000) {
+ t.Errorf("counter = %v, want the number before the first", counter)
+ }
+}
diff --git a/fiken/transport.go b/fiken/transport.go
new file mode 100644
index 0000000..9c27871
--- /dev/null
+++ b/fiken/transport.go
@@ -0,0 +1,174 @@
+package fiken
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "simenandre.no/fiken/internal/assert"
+)
+
+// APIError is a request Fiken refused. It carries what Fiken said, because the
+// operator can usually act on it directly.
+type APIError struct {
+ Status int
+ Method string
+ Path string
+ Message string
+}
+
+func (e *APIError) Error() string {
+ if e.Message == "" {
+ return fmt.Sprintf("fiken %s %s: HTTP %d", e.Method, e.Path, e.Status)
+ }
+ return fmt.Sprintf("fiken %s %s: HTTP %d: %s", e.Method, e.Path, e.Status, e.Message)
+}
+
+// do performs one API call, retrying only what is safe to retry: rate limits
+// and server faults. A 4xx is the caller's problem and is returned at once.
+func (c *Client) do(ctx context.Context, method, path string, query url.Values, body []byte) (*http.Response, error) {
+ return c.send(ctx, method, path, query, body, maxAttempts)
+}
+
+// doOnce is do without retries, for a call that is not safe to repeat: a 5xx
+// does not say whether Fiken acted on the request before failing.
+func (c *Client) doOnce(ctx context.Context, method, path string, query url.Values, body []byte) (*http.Response, error) {
+ return c.send(ctx, method, path, query, body, 1)
+}
+
+func (c *Client) send(ctx context.Context, method, path string, query url.Values, body []byte, attempts int) (*http.Response, error) {
+ assert.True(attempts >= 1 && attempts <= maxAttempts, "attempts %d out of range", attempts)
+ assert.True(strings.HasPrefix(path, "/"), "path %q must start with a slash", path)
+
+ target := c.baseURL + path
+ if len(query) > 0 {
+ target += "?" + query.Encode()
+ }
+
+ // Fetched once, before the retry loop: a refresh in the middle of a backoff
+ // would be answering a question nobody asked.
+ token, err := c.tokens.Token(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ var lastErr error
+ for attempt := 1; attempt <= attempts; attempt++ {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+
+ var reader io.Reader
+ if body != nil {
+ reader = bytes.NewReader(body)
+ }
+ req, err := http.NewRequestWithContext(ctx, method, target, reader)
+ if err != nil {
+ return nil, fmt.Errorf("build request for %s: %w", path, err)
+ }
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Accept", "application/json")
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ lastErr = fmt.Errorf("fiken %s %s: %w", method, path, err)
+ } else if retryable(resp.StatusCode) {
+ lastErr = c.apiError(resp, method, path)
+ } else if resp.StatusCode >= 400 {
+ return nil, c.apiError(resp, method, path)
+ } else {
+ return resp, nil
+ }
+
+ if attempt < attempts {
+ c.sleep(retryBackoff * (1 << (attempt - 1)))
+ }
+ }
+ return nil, lastErr
+}
+
+// retryable reports whether a status is worth trying again. A 429 means slow
+// down and a 5xx means Fiken is having a moment; everything else is a decision.
+func retryable(status int) bool {
+ return status == http.StatusTooManyRequests || status >= 500
+}
+
+// apiError consumes the response body and turns it into a readable error. It
+// always closes the body, so the caller cannot leak it on the error path.
+func (c *Client) apiError(resp *http.Response, method, path string) error {
+ defer resp.Body.Close()
+
+ raw, _ := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
+ err := &APIError{Status: resp.StatusCode, Method: method, Path: path, Message: message(raw)}
+ switch resp.StatusCode {
+ case http.StatusUnauthorized, http.StatusForbidden:
+ return fmt.Errorf("%w: %s", ErrUnauthorized, err.Message)
+ case http.StatusNotFound:
+ return fmt.Errorf("%w: %s", ErrNoSuchResource, err.Message)
+ }
+ return err
+}
+
+// message pulls something human out of Fiken's error body, falling back to the
+// raw text when it is not the shape we expect.
+func message(raw []byte) string {
+ var body struct {
+ Message string `json:"message"`
+ Description string `json:"description"`
+ ValidationMessages []string `json:"validationMessages"`
+ }
+ if err := json.Unmarshal(raw, &body); err == nil {
+ switch {
+ case len(body.ValidationMessages) > 0:
+ return strings.Join(body.ValidationMessages, "; ")
+ case body.Message != "":
+ return body.Message
+ case body.Description != "":
+ return body.Description
+ }
+ }
+ return strings.TrimSpace(string(raw))
+}
+
+// page walks a paginated collection to the end. Fiken reports the total page
+// count in a header, and the loop is bounded by maxPages regardless of what it
+// says, so a misbehaving server cannot spin here forever.
+func page[T any](ctx context.Context, c *Client, path string, query url.Values) ([]T, error) {
+ if query == nil {
+ query = url.Values{}
+ }
+ query.Set("pageSize", strconv.Itoa(maxPageSize))
+
+ var out []T
+ for pageNo := 0; pageNo < maxPages; pageNo++ {
+ query.Set("page", strconv.Itoa(pageNo))
+
+ resp, err := c.do(ctx, http.MethodGet, path, query, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var batch []T
+ err = json.NewDecoder(io.LimitReader(resp.Body, maxBodyBytes)).Decode(&batch)
+ resp.Body.Close()
+ if err != nil {
+ return nil, fmt.Errorf("fiken GET %s: unreadable response: %w", path, err)
+ }
+ out = append(out, batch...)
+
+ total, convErr := strconv.Atoi(resp.Header.Get("Fiken-Api-Page-Count"))
+ if convErr != nil || pageNo+1 >= total || len(batch) == 0 {
+ return out, nil
+ }
+ }
+ return nil, fmt.Errorf("fiken GET %s: more than %d pages", path, maxPages)
+}
diff --git a/fikenauth/login.go b/fikenauth/login.go
new file mode 100644
index 0000000..e438755
--- /dev/null
+++ b/fikenauth/login.go
@@ -0,0 +1,217 @@
+package fikenauth
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "html"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+// loginTimeout bounds how long the local server waits for the browser. Long
+// enough to find a password, short enough that a forgotten terminal frees the
+// port again.
+const loginTimeout = 5 * time.Minute
+
+// shutdownGrace is how long the local server is given to finish writing the
+// page the browser is waiting on.
+const shutdownGrace = 5 * time.Second
+
+// NewState returns an unguessable value to tie the redirect back to this run.
+func NewState() (string, error) {
+ buf := make([]byte, 32)
+ if _, err := rand.Read(buf); err != nil {
+ return "", fmt.Errorf("generate an OAuth state: %w", err)
+ }
+ return base64.RawURLEncoding.EncodeToString(buf), nil
+}
+
+// callback is what the browser came back with.
+type callback struct {
+ code string
+ state string
+ err error
+}
+
+// Login runs the loopback flow: a local server listens on the redirect URL's
+// port, announce is called with the URL to visit, and the token is exchanged
+// once the browser returns.
+//
+// The redirect URL must be registered on the Fiken app exactly as configured.
+func (c Config) Login(ctx context.Context, announce func(authURL string)) (Token, error) {
+ if err := c.Validate(); err != nil {
+ return Token{}, err
+ }
+
+ redirect, err := url.Parse(c.redirectURL())
+ if err != nil {
+ return Token{}, fmt.Errorf("fiken.redirect_url %q is not a URL: %w", c.redirectURL(), err)
+ }
+ if !isLoopback(redirect.Hostname()) {
+ return Token{}, fmt.Errorf(
+ "fiken.redirect_url %q does not point at this machine; "+
+ "use a localhost URL, or log in with --manual", c.redirectURL())
+ }
+
+ listener, err := net.Listen("tcp", net.JoinHostPort(redirect.Hostname(), portOf(redirect)))
+ if err != nil {
+ return Token{}, fmt.Errorf(
+ "listen on %s for the Fiken redirect: %w; "+
+ "close whatever is using the port, or log in with --manual", redirect.Host, err)
+ }
+
+ state, err := NewState()
+ if err != nil {
+ return Token{}, err
+ }
+
+ results := make(chan callback, 1)
+ server := &http.Server{
+ Handler: handler(redirect.Path, results),
+ ReadHeaderTimeout: 10 * time.Second,
+ }
+ go server.Serve(listener) //nolint:errcheck // Serve always ends in an error on close
+
+ // Shut down rather than close: the browser is still being sent the page
+ // that says how it went, and closing the moment the code arrives leaves
+ // the operator looking at a blank tab.
+ defer func() {
+ grace, cancel := context.WithTimeout(context.WithoutCancel(ctx), shutdownGrace)
+ defer cancel()
+ server.Shutdown(grace) //nolint:errcheck // nothing useful to do if it will not
+ }()
+
+ announce(c.AuthCodeURL(state))
+
+ timeout := time.NewTimer(loginTimeout)
+ defer timeout.Stop()
+
+ select {
+ case <-ctx.Done():
+ return Token{}, ctx.Err()
+ case <-timeout.C:
+ return Token{}, fmt.Errorf("gave up waiting for the Fiken login after %s", loginTimeout)
+ case result := <-results:
+ if result.err != nil {
+ return Token{}, result.err
+ }
+ if result.state != state {
+ return Token{}, errors.New("the Fiken redirect carried the wrong state; the login was not completed here")
+ }
+ return c.Exchange(ctx, result.code, state)
+ }
+}
+
+// handler answers the one request that matters and ignores the rest, so a
+// browser fetching a favicon does not end the login.
+func handler(path string, results chan<- callback) http.Handler {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ if path != "" && r.URL.Path != path {
+ http.NotFound(w, r)
+ return
+ }
+
+ result := fromQuery(r.URL.Query())
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if result.err != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ io.WriteString(w, page("Innlogging avbrutt", result.err.Error())) //nolint:errcheck
+ } else {
+ io.WriteString(w, page("Innlogging fullført", //nolint:errcheck
+ "Du kan lukke dette vinduet og gå tilbake til terminalen."))
+ }
+
+ select {
+ case results <- result:
+ default: // a second request cannot change the outcome
+ }
+ })
+ return mux
+}
+
+// fromQuery reads the redirect's parameters, turning Fiken's error reply into
+// an error rather than an empty code.
+func fromQuery(query url.Values) callback {
+ if code := query.Get("error"); code != "" {
+ return callback{err: &Error{Code: code, Description: query.Get("error_description")}}
+ }
+ code := query.Get("code")
+ if code == "" {
+ return callback{err: errors.New("the Fiken redirect carried no authorization code")}
+ }
+ return callback{code: code, state: query.Get("state")}
+}
+
+// page renders the one-shot browser response. The message can contain an error
+// straight from Fiken, so it is escaped rather than trusted.
+func page(title, message string) string {
+ return "<!doctype html><meta charset=\"utf-8\"><title>" + html.EscapeString(title) +
+ "</title><body style=\"font-family:system-ui;margin:4rem auto;max-width:32rem\">" +
+ "<h1>" + html.EscapeString(title) + "</h1><p>" + html.EscapeString(message) + "</p>"
+}
+
+// ManualAuthCodeURL starts a login that does not need a local server: the user
+// visits the URL, then pastes back where the browser ended up. This is the way
+// in over SSH, or when the redirect port is unavailable.
+func (c Config) ManualAuthCodeURL() (authURL, state string, err error) {
+ if err := c.Validate(); err != nil {
+ return "", "", err
+ }
+ state, err = NewState()
+ if err != nil {
+ return "", "", err
+ }
+ return c.AuthCodeURL(state), state, nil
+}
+
+// CompleteManual finishes a manual login from the URL the browser was
+// redirected to. A bare authorization code is accepted too, for the case where
+// the browser showed one instead of navigating.
+func (c Config) CompleteManual(ctx context.Context, state, redirected string) (Token, error) {
+ redirected = strings.TrimSpace(redirected)
+ if redirected == "" {
+ return Token{}, errors.New("nothing pasted")
+ }
+
+ // A bare authorization code is accepted as well as a whole URL, because
+ // some browsers show the code rather than navigating anywhere.
+ if !strings.Contains(redirected, "?") && !strings.Contains(redirected, "://") {
+ return c.Exchange(ctx, redirected, state)
+ }
+
+ parsed, err := url.Parse(redirected)
+ if err != nil {
+ return Token{}, fmt.Errorf("that is not a URL: %w", err)
+ }
+ result := fromQuery(parsed.Query())
+ if result.err != nil {
+ return Token{}, result.err
+ }
+ if result.state != "" && result.state != state {
+ return Token{}, errors.New("the pasted URL is from a different login attempt")
+ }
+ return c.Exchange(ctx, result.code, state)
+}
+
+func isLoopback(host string) bool {
+ if host == "localhost" {
+ return true
+ }
+ ip := net.ParseIP(host)
+ return ip != nil && ip.IsLoopback()
+}
+
+func portOf(u *url.URL) string {
+ if port := u.Port(); port != "" {
+ return port
+ }
+ return "80"
+}
diff --git a/fikenauth/oauth.go b/fikenauth/oauth.go
new file mode 100644
index 0000000..f899ccb
--- /dev/null
+++ b/fikenauth/oauth.go
@@ -0,0 +1,246 @@
+package fikenauth
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+// Fiken's OAuth2 endpoints. They live on fiken.no, not on the API host.
+const (
+ DefaultAuthorizeURL = "https://fiken.no/oauth/authorize"
+ DefaultTokenURL = "https://fiken.no/oauth/token"
+ DefaultRevokeURL = "https://fiken.no/oauth/revoke"
+)
+
+// DefaultRedirectURL is where the browser is sent back to. It must be
+// registered on the app in Fiken, character for character.
+const DefaultRedirectURL = "http://localhost:8137/callback"
+
+const (
+ tokenRequestTimeout = 30 * time.Second
+ maxResponseBytes = 1 << 20
+)
+
+// Config is an OAuth2 app registration plus where to send the browser.
+type Config struct {
+ ClientID string
+ ClientSecret string
+ RedirectURL string
+
+ // Endpoints default to Fiken's. They are fields so that the tests can point
+ // the flow at a local server.
+ AuthorizeURL string
+ TokenURL string
+ RevokeURL string
+
+ HTTPClient *http.Client
+ Now func() time.Time
+}
+
+// Error is a refusal from the OAuth endpoints, carrying what Fiken said.
+type Error struct {
+ Code string `json:"error"`
+ Description string `json:"error_description"`
+}
+
+func (e *Error) Error() string {
+ if e.Description == "" {
+ return "fiken oauth: " + e.Code
+ }
+ return fmt.Sprintf("fiken oauth: %s: %s", e.Code, e.Description)
+}
+
+// AccessDenied reports whether the user declined the consent screen, which is a
+// decision rather than a fault.
+func (e *Error) AccessDenied() bool { return e.Code == "access_denied" }
+
+// Validate reports what is missing from an app registration.
+func (c Config) Validate() error {
+ var missing []string
+ if c.ClientID == "" {
+ missing = append(missing, "fiken.client_id")
+ }
+ if c.ClientSecret == "" {
+ missing = append(missing, "fiken.client_secret_file (or FIKEN_CLIENT_SECRET)")
+ }
+ if len(missing) > 0 {
+ return fmt.Errorf(
+ "no Fiken OAuth app configured: set %s. Create the app in Fiken under "+
+ "Brukerinnstillinger -> API, registering %s as the redirect URL",
+ strings.Join(missing, " and "), c.redirectURL())
+ }
+ if _, err := url.Parse(c.redirectURL()); err != nil {
+ return fmt.Errorf("fiken.redirect_url %q is not a URL: %w", c.redirectURL(), err)
+ }
+ return nil
+}
+
+func (c Config) redirectURL() string { return orDefault(c.RedirectURL, DefaultRedirectURL) }
+func (c Config) authorizeURL() string {
+ return orDefault(c.AuthorizeURL, DefaultAuthorizeURL)
+}
+func (c Config) tokenURL() string { return orDefault(c.TokenURL, DefaultTokenURL) }
+func (c Config) revokeURL() string { return orDefault(c.RevokeURL, DefaultRevokeURL) }
+
+func (c Config) now() time.Time {
+ if c.Now == nil {
+ return time.Now()
+ }
+ return c.Now()
+}
+
+func (c Config) httpClient() *http.Client {
+ if c.HTTPClient != nil {
+ return c.HTTPClient
+ }
+ return &http.Client{Timeout: tokenRequestTimeout}
+}
+
+func orDefault(value, fallback string) string {
+ if value == "" {
+ return fallback
+ }
+ return value
+}
+
+// AuthCodeURL is where the user is sent to grant consent. The state is
+// unguessable and is checked when the browser comes back, which is what stops
+// somebody else's redirect being accepted as this one.
+func (c Config) AuthCodeURL(state string) string {
+ query := url.Values{
+ "response_type": {"code"},
+ "client_id": {c.ClientID},
+ "redirect_uri": {c.redirectURL()},
+ "state": {state},
+ }
+ return c.authorizeURL() + "?" + query.Encode()
+}
+
+// Exchange turns an authorization code into a token.
+func (c Config) Exchange(ctx context.Context, code, state string) (Token, error) {
+ if code == "" {
+ return Token{}, errors.New("fiken returned no authorization code")
+ }
+ return c.postToken(ctx, url.Values{
+ "grant_type": {"authorization_code"},
+ "code": {code},
+ "redirect_uri": {c.redirectURL()},
+ "state": {state},
+ })
+}
+
+// Refresh exchanges a refresh token for a fresh access token. Fiken may return
+// a new refresh token as well, so the whole result is what gets stored.
+func (c Config) Refresh(ctx context.Context, refreshToken string) (Token, error) {
+ if refreshToken == "" {
+ return Token{}, ErrNoToken
+ }
+ token, err := c.postToken(ctx, url.Values{
+ "grant_type": {"refresh_token"},
+ "refresh_token": {refreshToken},
+ })
+ if err != nil {
+ return Token{}, err
+ }
+ // Fiken need not reissue the refresh token; keep the one that still works.
+ if token.RefreshToken == "" {
+ token.RefreshToken = refreshToken
+ }
+ return token, nil
+}
+
+// Revoke disables an access token and the refresh token behind it.
+func (c Config) Revoke(ctx context.Context, accessToken string) error {
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.revokeURL(), nil)
+ if err != nil {
+ return fmt.Errorf("build revoke request: %w", err)
+ }
+ req.Header.Set("Authorization", "Bearer "+accessToken)
+
+ resp, err := c.httpClient().Do(req)
+ if err != nil {
+ return fmt.Errorf("revoke Fiken token: %w", err)
+ }
+ defer resp.Body.Close()
+
+ raw, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
+ if resp.StatusCode >= 400 {
+ if oauthErr := decodeError(raw); oauthErr != nil {
+ return oauthErr
+ }
+ return fmt.Errorf("revoke Fiken token: HTTP %d", resp.StatusCode)
+ }
+ return nil
+}
+
+// tokenResponse is what the token endpoint returns. expires_in is relative, so
+// it is turned into an absolute time here, once, against this run's clock.
+type tokenResponse struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ TokenType string `json:"token_type"`
+ ExpiresIn int64 `json:"expires_in"`
+}
+
+// postToken calls the token endpoint. It is protected with HTTP Basic
+// authentication using the app's client id and secret.
+func (c Config) postToken(ctx context.Context, form url.Values) (Token, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.tokenURL(),
+ strings.NewReader(form.Encode()))
+ if err != nil {
+ return Token{}, fmt.Errorf("build token request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Accept", "application/json")
+ req.SetBasicAuth(c.ClientID, c.ClientSecret)
+
+ resp, err := c.httpClient().Do(req)
+ if err != nil {
+ return Token{}, fmt.Errorf("reach the Fiken token endpoint: %w", err)
+ }
+ defer resp.Body.Close()
+
+ raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
+ if err != nil {
+ return Token{}, fmt.Errorf("read the Fiken token response: %w", err)
+ }
+ if oauthErr := decodeError(raw); oauthErr != nil {
+ return Token{}, oauthErr
+ }
+ if resp.StatusCode >= 400 {
+ return Token{}, fmt.Errorf("fiken token endpoint: HTTP %d: %s",
+ resp.StatusCode, strings.TrimSpace(string(raw)))
+ }
+
+ var body tokenResponse
+ if err := json.Unmarshal(raw, &body); err != nil {
+ return Token{}, fmt.Errorf("unreadable Fiken token response: %w", err)
+ }
+ if body.AccessToken == "" {
+ return Token{}, errors.New("fiken returned no access token")
+ }
+
+ return Token{
+ AccessToken: body.AccessToken,
+ RefreshToken: body.RefreshToken,
+ TokenType: orDefault(body.TokenType, "bearer"),
+ ExpiresAt: c.now().Add(time.Duration(body.ExpiresIn) * time.Second),
+ }, nil
+}
+
+// decodeError returns an *Error if the body is one, and nil otherwise. Fiken
+// reports OAuth failures in the body, sometimes with a 200.
+func decodeError(raw []byte) error {
+ var body Error
+ if err := json.Unmarshal(raw, &body); err != nil || body.Code == "" {
+ return nil
+ }
+ return &body
+}
diff --git a/fikenauth/oauth_test.go b/fikenauth/oauth_test.go
new file mode 100644
index 0000000..fd9234e
--- /dev/null
+++ b/fikenauth/oauth_test.go
@@ -0,0 +1,264 @@
+package fikenauth
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+)
+
+var fixedNow = time.Date(2026, 9, 5, 12, 0, 0, 0, time.UTC)
+
+// authServer stands in for fiken.no's OAuth endpoints.
+type authServer struct {
+ server *httptest.Server
+
+ gotBasicUser string
+ gotBasicPass string
+ gotForm url.Values
+ gotRevokeAuth string
+
+ reply string
+ replyCode int
+ refreshOut string
+}
+
+func newAuthServer(t *testing.T) *authServer {
+ t.Helper()
+ s := &authServer{
+ reply: `{"access_token":"access-1","refresh_token":"refresh-1","token_type":"bearer","expires_in":86157}`,
+ }
+ mux := http.NewServeMux()
+ mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) {
+ s.gotBasicUser, s.gotBasicPass, _ = r.BasicAuth()
+ r.ParseForm() //nolint:errcheck
+ s.gotForm = r.PostForm
+
+ body := s.reply
+ if r.PostForm.Get("grant_type") == "refresh_token" && s.refreshOut != "" {
+ body = s.refreshOut
+ }
+ if s.replyCode != 0 {
+ w.WriteHeader(s.replyCode)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(body)) //nolint:errcheck
+ })
+ mux.HandleFunc("/oauth/revoke", func(w http.ResponseWriter, r *http.Request) {
+ s.gotRevokeAuth = r.Header.Get("Authorization")
+ if s.replyCode != 0 {
+ w.WriteHeader(s.replyCode)
+ w.Write([]byte(s.reply)) //nolint:errcheck
+ }
+ })
+ s.server = httptest.NewServer(mux)
+ t.Cleanup(s.server.Close)
+ return s
+}
+
+func (s *authServer) config() Config {
+ return Config{
+ ClientID: "client-abc",
+ ClientSecret: "secret-xyz",
+ RedirectURL: "http://localhost:8137/callback",
+ AuthorizeURL: s.server.URL + "/oauth/authorize",
+ TokenURL: s.server.URL + "/oauth/token",
+ RevokeURL: s.server.URL + "/oauth/revoke",
+ HTTPClient: s.server.Client(),
+ Now: func() time.Time { return fixedNow },
+ }
+}
+
+func TestValidateNamesWhatIsMissing(t *testing.T) {
+ err := Config{}.Validate()
+ if err == nil {
+ t.Fatal("an empty config validated")
+ }
+ for _, want := range []string{"client_id", "client_secret", DefaultRedirectURL} {
+ if !strings.Contains(err.Error(), want) {
+ t.Errorf("error %q should mention %q", err, want)
+ }
+ }
+ if err := (Config{ClientID: "a", ClientSecret: "b"}).Validate(); err != nil {
+ t.Errorf("a complete config was rejected: %v", err)
+ }
+}
+
+func TestAuthCodeURL(t *testing.T) {
+ got, err := url.Parse(newAuthServer(t).config().AuthCodeURL("state-123"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ query := got.Query()
+ for field, want := range map[string]string{
+ "response_type": "code",
+ "client_id": "client-abc",
+ "redirect_uri": "http://localhost:8137/callback",
+ "state": "state-123",
+ } {
+ if query.Get(field) != want {
+ t.Errorf("%s = %q, want %q", field, query.Get(field), want)
+ }
+ }
+}
+
+// The token endpoint is protected with HTTP Basic authentication using the app
+// credentials, not by putting them in the form.
+func TestExchangeUsesBasicAuth(t *testing.T) {
+ server := newAuthServer(t)
+ token, err := server.config().Exchange(context.Background(), "auth-code", "state-123")
+ if err != nil {
+ t.Fatalf("Exchange: %v", err)
+ }
+
+ if server.gotBasicUser != "client-abc" || server.gotBasicPass != "secret-xyz" {
+ t.Errorf("basic auth = %q:%q", server.gotBasicUser, server.gotBasicPass)
+ }
+ if got := server.gotForm.Get("grant_type"); got != "authorization_code" {
+ t.Errorf("grant_type = %q", got)
+ }
+ if got := server.gotForm.Get("code"); got != "auth-code" {
+ t.Errorf("code = %q", got)
+ }
+ if got := server.gotForm.Get("redirect_uri"); got != "http://localhost:8137/callback" {
+ t.Errorf("redirect_uri = %q", got)
+ }
+ if token.AccessToken != "access-1" || token.RefreshToken != "refresh-1" {
+ t.Errorf("token = %+v", token)
+ }
+
+ // expires_in is relative; it becomes absolute here, once.
+ want := fixedNow.Add(86157 * time.Second)
+ if !token.ExpiresAt.Equal(want) {
+ t.Errorf("ExpiresAt = %s, want %s", token.ExpiresAt, want)
+ }
+}
+
+func TestExchangeRejectsAnEmptyCode(t *testing.T) {
+ if _, err := newAuthServer(t).config().Exchange(context.Background(), "", "s"); err == nil {
+ t.Fatal("Exchange accepted an empty code")
+ }
+}
+
+// Fiken need not reissue the refresh token, and throwing away a working one
+// would force a fresh login for no reason.
+func TestRefreshKeepsTheOldRefreshTokenWhenNoneIsReturned(t *testing.T) {
+ server := newAuthServer(t)
+ server.refreshOut = `{"access_token":"access-2","token_type":"bearer","expires_in":3600}`
+
+ token, err := server.config().Refresh(context.Background(), "refresh-1")
+ if err != nil {
+ t.Fatalf("Refresh: %v", err)
+ }
+ if token.AccessToken != "access-2" {
+ t.Errorf("AccessToken = %q", token.AccessToken)
+ }
+ if token.RefreshToken != "refresh-1" {
+ t.Errorf("RefreshToken = %q, want the one that still works", token.RefreshToken)
+ }
+ if got := server.gotForm.Get("grant_type"); got != "refresh_token" {
+ t.Errorf("grant_type = %q", got)
+ }
+}
+
+func TestRefreshTakesANewRefreshTokenWhenGiven(t *testing.T) {
+ server := newAuthServer(t)
+ server.refreshOut = `{"access_token":"access-2","refresh_token":"refresh-2","expires_in":3600}`
+
+ token, err := server.config().Refresh(context.Background(), "refresh-1")
+ if err != nil {
+ t.Fatalf("Refresh: %v", err)
+ }
+ if token.RefreshToken != "refresh-2" {
+ t.Errorf("RefreshToken = %q, want the reissued one", token.RefreshToken)
+ }
+}
+
+func TestRefreshWithoutATokenIsNoToken(t *testing.T) {
+ if _, err := newAuthServer(t).config().Refresh(context.Background(), ""); !errors.Is(err, ErrNoToken) {
+ t.Errorf("err = %v, want ErrNoToken", err)
+ }
+}
+
+func TestOAuthErrorsAreCarried(t *testing.T) {
+ server := newAuthServer(t)
+ server.replyCode = http.StatusBadRequest
+ server.reply = `{"error":"invalid_grant","error_description":"The authorization code is invalid or expired."}`
+
+ _, err := server.config().Exchange(context.Background(), "stale", "state")
+ var oauthErr *Error
+ if !errors.As(err, &oauthErr) {
+ t.Fatalf("err = %v, want an *Error", err)
+ }
+ if oauthErr.Code != "invalid_grant" {
+ t.Errorf("Code = %q", oauthErr.Code)
+ }
+ if !strings.Contains(err.Error(), "invalid or expired") {
+ t.Errorf("error %q should carry Fiken's description", err)
+ }
+ if oauthErr.AccessDenied() {
+ t.Error("invalid_grant is not access_denied")
+ }
+}
+
+func TestRevoke(t *testing.T) {
+ server := newAuthServer(t)
+ if err := server.config().Revoke(context.Background(), "access-1"); err != nil {
+ t.Fatalf("Revoke: %v", err)
+ }
+ if server.gotRevokeAuth != "Bearer access-1" {
+ t.Errorf("revoke Authorization = %q", server.gotRevokeAuth)
+ }
+}
+
+func TestRevokeReportsFailure(t *testing.T) {
+ server := newAuthServer(t)
+ server.replyCode = http.StatusUnauthorized
+ server.reply = `{"error":"invalid_token","error_description":"nope"}`
+
+ err := server.config().Revoke(context.Background(), "access-1")
+ var oauthErr *Error
+ if !errors.As(err, &oauthErr) || oauthErr.Code != "invalid_token" {
+ t.Errorf("err = %v, want an invalid_token *Error", err)
+ }
+}
+
+// A token must never reach a log line through %v.
+func TestTokenStringHidesSecrets(t *testing.T) {
+ token := Token{AccessToken: "super-secret", RefreshToken: "also-secret", ExpiresAt: fixedNow}
+ rendered := token.String()
+ if strings.Contains(rendered, "secret") {
+ t.Errorf("String() leaked a credential: %q", rendered)
+ }
+
+ // The JSON form does carry them: that is the file format.
+ raw, err := json.Marshal(token)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(raw), "super-secret") {
+ t.Error("the stored form should keep the token")
+ }
+}
+
+func TestTokenValidity(t *testing.T) {
+ token := Token{AccessToken: "a", ExpiresAt: fixedNow.Add(time.Hour)}
+ if !token.Valid(fixedNow) {
+ t.Error("a token good for an hour reported invalid")
+ }
+ // The skew makes a token that dies in a moment count as already dead.
+ if token.Valid(fixedNow.Add(time.Hour - time.Minute)) {
+ t.Error("the expiry skew was not applied")
+ }
+ if (Token{ExpiresAt: fixedNow.Add(time.Hour)}).Valid(fixedNow) {
+ t.Error("a token with no access token is not valid")
+ }
+ if token.Renewable() {
+ t.Error("a token with no refresh token is not renewable")
+ }
+}
diff --git a/fikenauth/source.go b/fikenauth/source.go
new file mode 100644
index 0000000..f6289c2
--- /dev/null
+++ b/fikenauth/source.go
@@ -0,0 +1,125 @@
+package fikenauth
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "sync"
+)
+
+// Source hands out a usable access token, refreshing and re-storing it when the
+// stored one has expired. It satisfies fiken.TokenSource.
+//
+// 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 can be got right everywhere.
+type Source struct {
+ cfg Config
+ store *Store
+
+ // LoginCommand is what to tell the operator to run when the grant can no
+ // longer be renewed. Every program embedding this spells its own login
+ // differently, and an error naming the wrong one sends the reader to a
+ // command that does not exist.
+ LoginCommand string
+
+ mu sync.Mutex
+ token Token
+ loaded bool
+}
+
+// NewSource returns a token source backed by store.
+func NewSource(cfg Config, store *Store) *Source {
+ return &Source{cfg: cfg, store: store}
+}
+
+// loginHint is how to ask for a new grant, in words that are true whatever the
+// caller called its command.
+func (s *Source) loginHint() string {
+ if s.LoginCommand == "" {
+ return "log in to Fiken again"
+ }
+ return "run `" + s.LoginCommand + "` again"
+}
+
+// Token returns an access token that is valid now.
+func (s *Source) Token(ctx context.Context) (string, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if err := s.load(); err != nil {
+ return "", err
+ }
+ if s.token.Valid(s.cfg.now()) {
+ return s.token.AccessToken, nil
+ }
+ if !s.token.Renewable() {
+ return "", fmt.Errorf("the Fiken access token has expired and cannot be renewed: %w", ErrNoToken)
+ }
+
+ if err := s.cfg.Validate(); err != nil {
+ return "", err
+ }
+ refreshed, err := s.cfg.Refresh(ctx, s.token.RefreshToken)
+ if err != nil {
+ var oauthErr *Error
+ if errors.As(err, &oauthErr) {
+ return "", fmt.Errorf(
+ "could not renew the Fiken login (%s); %s", oauthErr.Code, s.loginHint())
+ }
+ return "", err
+ }
+
+ // Save before returning: a token used but not stored would be refreshed
+ // again on the next run, and Fiken invalidates the old refresh token.
+ if err := s.store.Save(refreshed); err != nil {
+ return "", err
+ }
+ s.token = refreshed
+ return refreshed.AccessToken, nil
+}
+
+// Current returns the stored token as it stands, without refreshing it, which
+// is what a `fiken status` command reports on.
+func (s *Source) Current() (Token, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if err := s.load(); err != nil {
+ return Token{}, err
+ }
+ return s.token, nil
+}
+
+// Logout revokes the grant at Fiken and removes the stored token. The file is
+// removed even if the revoke fails, because a token that cannot be revoked is
+// exactly the one not to keep lying around.
+func (s *Source) Logout(ctx context.Context) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if err := s.load(); err != nil {
+ return err
+ }
+ revokeErr := s.cfg.Revoke(ctx, s.token.AccessToken)
+
+ s.token, s.loaded = Token{}, false
+ if err := s.store.Delete(); err != nil {
+ return err
+ }
+ return revokeErr
+}
+
+// Store is where this source keeps its token, for messages.
+func (s *Source) Store() *Store { return s.store }
+
+// load reads the token once per process.
+func (s *Source) load() error {
+ if s.loaded {
+ return nil
+ }
+ token, err := s.store.Load()
+ if err != nil {
+ return err
+ }
+ s.token, s.loaded = token, true
+ return nil
+}
diff --git a/fikenauth/source_test.go b/fikenauth/source_test.go
new file mode 100644
index 0000000..1c960d6
--- /dev/null
+++ b/fikenauth/source_test.go
@@ -0,0 +1,172 @@
+package fikenauth
+
+import (
+ "errors"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestSourceReturnsAValidTokenWithoutRefreshing(t *testing.T) {
+ server := newAuthServer(t)
+ store := tempStore(t)
+ if err := store.Save(Token{
+ AccessToken: "still-good", RefreshToken: "r1",
+ ExpiresAt: fixedNow.Add(time.Hour),
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ got, err := NewSource(server.config(), store).Token(t.Context())
+ if err != nil {
+ t.Fatalf("Token: %v", err)
+ }
+ if got != "still-good" {
+ t.Errorf("Token = %q", got)
+ }
+ if server.gotForm != nil {
+ t.Error("the token endpoint was called for a token that was still valid")
+ }
+}
+
+// An expired token is renewed and the new one stored, because Fiken invalidates
+// the refresh token once it is used: not saving would strand the next run.
+func TestSourceRefreshesAndPersists(t *testing.T) {
+ server := newAuthServer(t)
+ server.refreshOut = `{"access_token":"access-2","refresh_token":"refresh-2","expires_in":3600}`
+ store := tempStore(t)
+ if err := store.Save(Token{
+ AccessToken: "expired", RefreshToken: "refresh-1",
+ ExpiresAt: fixedNow.Add(-time.Hour),
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ source := NewSource(server.config(), store)
+ got, err := source.Token(t.Context())
+ if err != nil {
+ t.Fatalf("Token: %v", err)
+ }
+ if got != "access-2" {
+ t.Errorf("Token = %q, want the refreshed one", got)
+ }
+ if sent := server.gotForm.Get("refresh_token"); sent != "refresh-1" {
+ t.Errorf("refreshed with %q", sent)
+ }
+
+ stored, err := store.Load()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stored.AccessToken != "access-2" || stored.RefreshToken != "refresh-2" {
+ t.Errorf("stored %+v, want the refreshed grant", stored)
+ }
+
+ // A second call within the same run reuses what it already has.
+ server.gotForm = nil
+ if _, err := source.Token(t.Context()); err != nil {
+ t.Fatalf("second Token: %v", err)
+ }
+ if server.gotForm != nil {
+ t.Error("the token was refreshed twice in one run")
+ }
+}
+
+func TestSourceWithoutAStoredToken(t *testing.T) {
+ _, err := NewSource(newAuthServer(t).config(), tempStore(t)).Token(t.Context())
+ if !errors.Is(err, ErrNoToken) {
+ t.Errorf("err = %v, want ErrNoToken", err)
+ }
+}
+
+func TestSourceCannotRenewWithoutARefreshToken(t *testing.T) {
+ store := tempStore(t)
+ if err := store.Save(Token{AccessToken: "expired", ExpiresAt: fixedNow.Add(-time.Hour)}); err != nil {
+ t.Fatal(err)
+ }
+ _, err := NewSource(newAuthServer(t).config(), store).Token(t.Context())
+ if !errors.Is(err, ErrNoToken) {
+ t.Errorf("err = %v, want ErrNoToken", err)
+ }
+}
+
+// A revoked grant should tell the operator what to do, not report a raw
+// OAuth code at them.
+func TestSourceExplainsARevokedGrant(t *testing.T) {
+ server := newAuthServer(t)
+ server.replyCode = http.StatusBadRequest
+ server.reply = `{"error":"invalid_grant","error_description":"revoked"}`
+ store := tempStore(t)
+ if err := store.Save(Token{
+ AccessToken: "expired", RefreshToken: "r1",
+ ExpiresAt: fixedNow.Add(-time.Hour),
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ // A caller that says what its login command is gets named in the error,
+ // because every program embedding this spells it differently.
+ source := NewSource(server.config(), store)
+ source.LoginCommand = "bill fiken login"
+ _, err := source.Token(t.Context())
+ if err == nil || !strings.Contains(err.Error(), "run `bill fiken login` again") {
+ t.Fatalf("err = %v, want it to name the caller's login command", err)
+ }
+
+ // One that does not still has to say what to do, without inventing a
+ // command that does not exist.
+ _, err = NewSource(server.config(), store).Token(t.Context())
+ switch {
+ case err == nil || !strings.Contains(err.Error(), "log in to Fiken again"):
+ t.Fatalf("err = %v, want it to say how to recover", err)
+ case strings.Contains(err.Error(), "`"):
+ t.Errorf("err = %v, want no command quoted when none was given", err)
+ }
+}
+
+func TestLogoutRevokesAndForgets(t *testing.T) {
+ server := newAuthServer(t)
+ store := tempStore(t)
+ if err := store.Save(Token{
+ AccessToken: "access-1", RefreshToken: "r1",
+ ExpiresAt: fixedNow.Add(time.Hour),
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ source := NewSource(server.config(), store)
+ if err := source.Logout(t.Context()); err != nil {
+ t.Fatalf("Logout: %v", err)
+ }
+ if server.gotRevokeAuth != "Bearer access-1" {
+ t.Errorf("revoke Authorization = %q", server.gotRevokeAuth)
+ }
+ if _, err := store.Load(); !errors.Is(err, ErrNoToken) {
+ t.Errorf("the token file survived logout: %v", err)
+ }
+}
+
+// A token that cannot be revoked is exactly the one not to keep on disk.
+func TestLogoutForgetsEvenWhenRevokeFails(t *testing.T) {
+ server := newAuthServer(t)
+ server.replyCode = http.StatusInternalServerError
+ store := tempStore(t)
+ if err := store.Save(Token{AccessToken: "access-1", ExpiresAt: fixedNow.Add(time.Hour)}); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := NewSource(server.config(), store).Logout(t.Context()); err == nil {
+ t.Error("Logout hid the revoke failure")
+ }
+ if _, err := store.Load(); !errors.Is(err, ErrNoToken) {
+ t.Errorf("the token file survived logout: %v", err)
+ }
+}
+
+func TestLogoutWithoutATokenIsNoToken(t *testing.T) {
+ err := NewSource(newAuthServer(t).config(), tempStore(t)).Logout(t.Context())
+ if !errors.Is(err, ErrNoToken) {
+ t.Errorf("err = %v, want ErrNoToken", err)
+ }
+}
diff --git a/fikenauth/store_test.go b/fikenauth/store_test.go
new file mode 100644
index 0000000..bcd940b
--- /dev/null
+++ b/fikenauth/store_test.go
@@ -0,0 +1,298 @@
+package fikenauth
+
+import (
+ "errors"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func tempStore(t *testing.T) *Store {
+ t.Helper()
+ return NewStore(filepath.Join(t.TempDir(), "nested", "fiken-token.json"))
+}
+
+func TestStoreRoundTrip(t *testing.T) {
+ store := tempStore(t)
+ if _, err := store.Load(); !errors.Is(err, ErrNoToken) {
+ t.Fatalf("err = %v, want ErrNoToken before anything is stored", err)
+ }
+
+ want := Token{
+ AccessToken: "access-1",
+ RefreshToken: "refresh-1",
+ TokenType: "bearer",
+ ExpiresAt: fixedNow.Add(time.Hour).UTC(),
+ }
+ if err := store.Save(want); err != nil {
+ t.Fatalf("Save: %v", err)
+ }
+ got, err := store.Load()
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ if got.AccessToken != want.AccessToken || got.RefreshToken != want.RefreshToken {
+ t.Errorf("round trip changed the token: %+v", got)
+ }
+ if !got.ExpiresAt.Equal(want.ExpiresAt) {
+ t.Errorf("ExpiresAt = %s, want %s", got.ExpiresAt, want.ExpiresAt)
+ }
+}
+
+// The file holds a live credential, so nobody else on the machine may read it.
+func TestStoredTokenIsPrivate(t *testing.T) {
+ store := tempStore(t)
+ if err := store.Save(Token{AccessToken: "a"}); err != nil {
+ t.Fatal(err)
+ }
+
+ info, err := os.Stat(store.Path())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if mode := info.Mode().Perm(); mode != 0o600 {
+ t.Errorf("token file mode is %o, want 600", mode)
+ }
+ dir, err := os.Stat(filepath.Dir(store.Path()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if mode := dir.Mode().Perm(); mode != 0o700 {
+ t.Errorf("token directory mode is %o, want 700", mode)
+ }
+}
+
+func TestSaveReplaces(t *testing.T) {
+ store := tempStore(t)
+ if err := store.Save(Token{AccessToken: "first", RefreshToken: "r1"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := store.Save(Token{AccessToken: "second"}); err != nil {
+ t.Fatal(err)
+ }
+ got, err := store.Load()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.AccessToken != "second" || got.RefreshToken != "" {
+ t.Errorf("got %+v, want the second token entirely", got)
+ }
+
+ // The temporary file used for the atomic write must not survive.
+ entries, err := os.ReadDir(filepath.Dir(store.Path()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(entries) != 1 {
+ t.Errorf("directory holds %d files, want just the token", len(entries))
+ }
+}
+
+func TestDeleteIsIdempotent(t *testing.T) {
+ store := tempStore(t)
+ if err := store.Save(Token{AccessToken: "a"}); err != nil {
+ t.Fatal(err)
+ }
+ for i := 0; i < 2; i++ {
+ if err := store.Delete(); err != nil {
+ t.Fatalf("Delete %d: %v", i, err)
+ }
+ }
+ if _, err := store.Load(); !errors.Is(err, ErrNoToken) {
+ t.Errorf("err = %v, want ErrNoToken", err)
+ }
+}
+
+func TestLoadRejectsRubbish(t *testing.T) {
+ store := tempStore(t)
+ if err := os.MkdirAll(filepath.Dir(store.Path()), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(store.Path(), []byte("not json"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ _, err := store.Load()
+ if err == nil || errors.Is(err, ErrNoToken) {
+ t.Fatalf("err = %v, want a complaint about the file", err)
+ }
+ if !strings.Contains(err.Error(), "log in again") {
+ t.Errorf("error %q should say how to fix it", err)
+ }
+}
+
+func TestLoadTreatsAnEmptyTokenAsAbsent(t *testing.T) {
+ store := tempStore(t)
+ if err := os.MkdirAll(filepath.Dir(store.Path()), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(store.Path(), []byte(`{"access_token":""}`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.Load(); !errors.Is(err, ErrNoToken) {
+ t.Errorf("err = %v, want ErrNoToken", err)
+ }
+}
+
+// Login runs a one-shot local server; the browser is simulated by fetching the
+// authorization URL's redirect target directly.
+func TestLoginLoopback(t *testing.T) {
+ server := newAuthServer(t)
+ cfg := server.config()
+ cfg.RedirectURL = "http://127.0.0.1:18137/callback"
+
+ token, err := cfg.Login(t.Context(), func(authURL string) {
+ go visitAsBrowser(t, server, authURL, "the-code", "")
+ })
+ if err != nil {
+ t.Fatalf("Login: %v", err)
+ }
+ if token.AccessToken != "access-1" {
+ t.Errorf("token = %+v", token)
+ }
+ if got := server.gotForm.Get("code"); got != "the-code" {
+ t.Errorf("exchanged code %q, want the one from the redirect", got)
+ }
+}
+
+// A redirect carrying somebody else's state must not complete this login.
+func TestLoginRejectsAWrongState(t *testing.T) {
+ server := newAuthServer(t)
+ cfg := server.config()
+ cfg.RedirectURL = "http://127.0.0.1:18138/callback"
+
+ _, err := cfg.Login(t.Context(), func(authURL string) {
+ go visitAsBrowser(t, server, authURL, "the-code", "not-the-state")
+ })
+ if err == nil || !strings.Contains(err.Error(), "wrong state") {
+ t.Fatalf("err = %v, want a state mismatch", err)
+ }
+}
+
+func TestLoginSurfacesADeclinedConsent(t *testing.T) {
+ server := newAuthServer(t)
+ cfg := server.config()
+ cfg.RedirectURL = "http://127.0.0.1:18139/callback"
+
+ _, err := cfg.Login(t.Context(), func(authURL string) {
+ go func() {
+ redirect := redirectTargetOf(t, authURL)
+ get(t, server, redirect+"?error=access_denied&error_description=Nei")
+ }()
+ })
+ var oauthErr *Error
+ if !errors.As(err, &oauthErr) || !oauthErr.AccessDenied() {
+ t.Fatalf("err = %v, want an access_denied *Error", err)
+ }
+}
+
+// A redirect URL that is not on this machine cannot be listened on; say so
+// rather than failing obscurely.
+func TestLoginRefusesARemoteRedirect(t *testing.T) {
+ cfg := newAuthServer(t).config()
+ cfg.RedirectURL = "https://example.com/callback"
+
+ _, err := cfg.Login(t.Context(), func(string) {})
+ if err == nil || !strings.Contains(err.Error(), "--manual") {
+ t.Fatalf("err = %v, want it to point at --manual", err)
+ }
+}
+
+func TestManualLogin(t *testing.T) {
+ server := newAuthServer(t)
+ cfg := server.config()
+
+ authURL, state, err := cfg.ManualAuthCodeURL()
+ if err != nil {
+ t.Fatalf("ManualAuthCodeURL: %v", err)
+ }
+ if !strings.Contains(authURL, url.QueryEscape(state)) {
+ t.Errorf("the URL %q should carry the state", authURL)
+ }
+
+ pasted := "http://localhost:8137/callback?code=pasted-code&state=" + url.QueryEscape(state)
+ token, err := cfg.CompleteManual(t.Context(), state, " "+pasted+" ")
+ if err != nil {
+ t.Fatalf("CompleteManual: %v", err)
+ }
+ if token.AccessToken != "access-1" {
+ t.Errorf("token = %+v", token)
+ }
+ if got := server.gotForm.Get("code"); got != "pasted-code" {
+ t.Errorf("exchanged %q", got)
+ }
+}
+
+// Some browsers show the code instead of navigating, so a bare code is taken.
+func TestManualLoginAcceptsABareCode(t *testing.T) {
+ server := newAuthServer(t)
+ token, err := server.config().CompleteManual(t.Context(), "state-1", "just-the-code")
+ if err != nil {
+ t.Fatalf("CompleteManual: %v", err)
+ }
+ if token.AccessToken != "access-1" {
+ t.Errorf("token = %+v", token)
+ }
+ if got := server.gotForm.Get("code"); got != "just-the-code" {
+ t.Errorf("exchanged %q, want the bare code", got)
+ }
+}
+
+func TestManualLoginRejects(t *testing.T) {
+ cfg := newAuthServer(t).config()
+ cases := []struct{ name, pasted, want string }{
+ {"nothing", " ", "nothing pasted"},
+ {"another attempt", "http://localhost:8137/callback?code=c&state=other", "different login attempt"},
+ {"an error redirect", "http://localhost:8137/callback?error=access_denied", "access_denied"},
+ {"no code", "http://localhost:8137/callback?foo=bar", "no authorization code"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ _, err := cfg.CompleteManual(t.Context(), "state-1", tc.pasted)
+ if err == nil {
+ t.Fatalf("CompleteManual accepted %s", tc.name)
+ }
+ if !strings.Contains(err.Error(), tc.want) {
+ t.Errorf("error %q should mention %q", err, tc.want)
+ }
+ })
+ }
+}
+
+// visitAsBrowser plays the part of the browser: it reads the redirect target
+// and state out of the authorization URL and calls back with a code.
+func visitAsBrowser(t *testing.T, server *authServer, authURL, code, state string) {
+ t.Helper()
+ parsed, err := url.Parse(authURL)
+ if err != nil {
+ t.Errorf("parse %q: %v", authURL, err)
+ return
+ }
+ query := parsed.Query()
+ if state == "" {
+ state = query.Get("state")
+ }
+ get(t, server, query.Get("redirect_uri")+
+ "?code="+url.QueryEscape(code)+"&state="+url.QueryEscape(state))
+}
+
+func redirectTargetOf(t *testing.T, authURL string) string {
+ t.Helper()
+ parsed, err := url.Parse(authURL)
+ if err != nil {
+ t.Fatalf("parse %q: %v", authURL, err)
+ }
+ return parsed.Query().Get("redirect_uri")
+}
+
+func get(t *testing.T, server *authServer, target string) {
+ t.Helper()
+ resp, err := server.server.Client().Get(target)
+ if err != nil {
+ t.Errorf("GET %s: %v", target, err)
+ return
+ }
+ resp.Body.Close()
+}
diff --git a/fikenauth/token.go b/fikenauth/token.go
new file mode 100644
index 0000000..dcda79a
--- /dev/null
+++ b/fikenauth/token.go
@@ -0,0 +1,143 @@
+// Package fikenauth implements Fiken's OAuth2 authorization code flow.
+//
+// It is kept apart from package fiken, which only knows how to put a bearer
+// token in a header. Where that token came from — a personal API token typed
+// into an environment variable, or a consent screen and a refresh token on
+// disk — is this package's business and nobody else's.
+package fikenauth
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "simenandre.no/fiken/internal/assert"
+)
+
+// ErrNoToken means nobody has logged in yet, or the stored token was removed.
+var ErrNoToken = errors.New("not logged in to Fiken")
+
+// expirySkew is how early a token is treated as expired. A token that dies
+// halfway through an invoice run is worse than one refreshed a minute early.
+const expirySkew = 2 * time.Minute
+
+// maxTokenFileBytes bounds what will be read as a token file, so pointing the
+// setting at the wrong path fails instead of consuming a database.
+const maxTokenFileBytes = 64 << 10
+
+// Token is a Fiken OAuth2 grant. It is written to disk, so the field names are
+// part of the file format.
+type Token struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ TokenType string `json:"token_type"`
+ ExpiresAt time.Time `json:"expires_at"`
+}
+
+// Valid reports whether the access token can still be used at now, allowing for
+// the skew.
+func (t Token) Valid(now time.Time) bool {
+ return t.AccessToken != "" && now.Add(expirySkew).Before(t.ExpiresAt)
+}
+
+// Renewable reports whether a new access token can be obtained without asking
+// the user to log in again.
+func (t Token) Renewable() bool { return t.RefreshToken != "" }
+
+// String deliberately hides the secrets. A token reaching a log line by way of
+// %v is a credential leak, and this is the cheapest place to prevent it.
+func (t Token) String() string {
+ return fmt.Sprintf("fiken oauth token (expires %s)", t.ExpiresAt.Format(time.RFC3339))
+}
+
+// Store is where the token lives between runs: one file, readable only by its
+// owner, outside the database.
+type Store struct{ path string }
+
+// NewStore returns a store backed by the file at path.
+func NewStore(path string) *Store {
+ assert.True(path != "", "token store path must not be empty")
+ return &Store{path: path}
+}
+
+// Path is the file the token is kept in.
+func (s *Store) Path() string { return s.path }
+
+// Load reads the stored token, or returns ErrNoToken if there is none.
+func (s *Store) Load() (Token, error) {
+ info, err := os.Stat(s.path)
+ if errors.Is(err, os.ErrNotExist) {
+ return Token{}, ErrNoToken
+ }
+ if err != nil {
+ return Token{}, fmt.Errorf("read %s: %w", s.path, err)
+ }
+ if info.Size() > maxTokenFileBytes {
+ return Token{}, fmt.Errorf("%s is %d bytes, which is not a token file", s.path, info.Size())
+ }
+
+ raw, err := os.ReadFile(s.path)
+ if err != nil {
+ return Token{}, fmt.Errorf("read %s: %w", s.path, err)
+ }
+ var token Token
+ if err := json.Unmarshal(raw, &token); err != nil {
+ return Token{}, fmt.Errorf("read %s: %w; log in again to replace it", s.path, err)
+ }
+ if token.AccessToken == "" {
+ return Token{}, ErrNoToken
+ }
+ return token, nil
+}
+
+// Save writes the token, replacing any previous one. The file is written to a
+// temporary name and renamed, so an interrupted write cannot leave a truncated
+// credential behind.
+func (s *Store) Save(token Token) error {
+ assert.True(token.AccessToken != "", "refusing to store an empty access token")
+
+ dir := filepath.Dir(s.path)
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return fmt.Errorf("create %s: %w", dir, err)
+ }
+
+ raw, err := json.MarshalIndent(token, "", " ")
+ if err != nil {
+ return fmt.Errorf("encode token: %w", err)
+ }
+
+ temp, err := os.CreateTemp(dir, ".fiken-token-*")
+ if err != nil {
+ return fmt.Errorf("write %s: %w", s.path, err)
+ }
+ defer os.Remove(temp.Name()) //nolint:errcheck // already renamed on success
+
+ if err := temp.Chmod(0o600); err != nil {
+ temp.Close()
+ return fmt.Errorf("write %s: %w", s.path, err)
+ }
+ if _, err := temp.Write(append(raw, '\n')); err != nil {
+ temp.Close()
+ return fmt.Errorf("write %s: %w", s.path, err)
+ }
+ if err := temp.Close(); err != nil {
+ return fmt.Errorf("write %s: %w", s.path, err)
+ }
+ if err := os.Rename(temp.Name(), s.path); err != nil {
+ return fmt.Errorf("write %s: %w", s.path, err)
+ }
+ return nil
+}
+
+// Delete removes the stored token. Deleting one that is not there succeeds:
+// logging out twice is not an error.
+func (s *Store) Delete() error {
+ err := os.Remove(s.path)
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return fmt.Errorf("remove %s: %w", s.path, err)
+ }
+ return nil
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..8305bbb
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module simenandre.no/fiken
+
+go 1.26.8
diff --git a/internal/assert/assert.go b/internal/assert/assert.go
new file mode 100644
index 0000000..cb0063c
--- /dev/null
+++ b/internal/assert/assert.go
@@ -0,0 +1,36 @@
+// Package assert provides programmer-error checks that abort the process.
+//
+// An assertion failure is never a user error: it means the program reached a
+// state its author believed impossible. User errors are returned as values.
+package assert
+
+import "fmt"
+
+// True aborts unless cond holds.
+func True(cond bool, format string, args ...any) {
+ if !cond {
+ panic("assertion failed: " + fmt.Sprintf(format, args...))
+ }
+}
+
+// Equal aborts unless got == want.
+func Equal[T comparable](got, want T, format string, args ...any) {
+ if got != want {
+ panic(fmt.Sprintf("assertion failed: %s: got %v, want %v",
+ fmt.Sprintf(format, args...), got, want))
+ }
+}
+
+// NoError aborts if err is non-nil. Use only where an error is structurally
+// impossible, never to discard an error a caller could handle.
+func NoError(err error, format string, args ...any) {
+ if err != nil {
+ panic(fmt.Sprintf("assertion failed: %s: %v",
+ fmt.Sprintf(format, args...), err))
+ }
+}
+
+// Unreachable aborts unconditionally.
+func Unreachable(format string, args ...any) {
+ panic("unreachable: " + fmt.Sprintf(format, args...))
+}