diff options
Diffstat (limited to 'fikenauth/oauth.go')
| -rw-r--r-- | fikenauth/oauth.go | 246 |
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 +} |
