From c0a01ac65bb492640b169274614e0d8022f5076d Mon Sep 17 00:00:00 2001 From: "Simen A. W. Olsen" Date: Sat, 17 Oct 2026 14:19:09 +0200 Subject: Fiken API client and OAuth2 for Go 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. --- .gitignore | 1 + README.md | 61 +++++++ fiken/fiken.go | 407 ++++++++++++++++++++++++++++++++++++++++++++++ fiken/fiken_test.go | 320 ++++++++++++++++++++++++++++++++++++ fiken/transport.go | 174 ++++++++++++++++++++ fikenauth/login.go | 217 ++++++++++++++++++++++++ fikenauth/oauth.go | 246 ++++++++++++++++++++++++++++ fikenauth/oauth_test.go | 264 ++++++++++++++++++++++++++++++ fikenauth/source.go | 125 ++++++++++++++ fikenauth/source_test.go | 172 ++++++++++++++++++++ fikenauth/store_test.go | 298 +++++++++++++++++++++++++++++++++ fikenauth/token.go | 143 ++++++++++++++++ go.mod | 3 + internal/assert/assert.go | 36 ++++ 14 files changed, 2467 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 fiken/fiken.go create mode 100644 fiken/fiken_test.go create mode 100644 fiken/transport.go create mode 100644 fikenauth/login.go create mode 100644 fikenauth/oauth.go create mode 100644 fikenauth/oauth_test.go create mode 100644 fikenauth/source.go create mode 100644 fikenauth/source_test.go create mode 100644 fikenauth/store_test.go create mode 100644 fikenauth/token.go create mode 100644 go.mod create mode 100644 internal/assert/assert.go 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 "" + html.EscapeString(title) + + "" + + "

" + html.EscapeString(title) + "

" + html.EscapeString(message) + "

" +} + +// 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...)) +} -- cgit v1.3.1