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, "client_id") } if c.ClientSecret == "" { missing = append(missing, "client_secret") } if len(missing) > 0 { return fmt.Errorf( "no Fiken OAuth app configured: missing %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("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 }