summaryrefslogtreecommitdiffstats
path: root/fikenauth/oauth.go
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 /fikenauth/oauth.go
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.
Diffstat (limited to 'fikenauth/oauth.go')
-rw-r--r--fikenauth/oauth.go246
1 files changed, 246 insertions, 0 deletions
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
+}