Skip to content
Snippets Groups Projects
Verified Commit d9723f82 authored by Janne Mareike Koschinski's avatar Janne Mareike Koschinski
Browse files

feat: initial implementation

parent 91cedffe
Branches main
Tags v0.1.0
No related merge requests found
Showing
with 1169 additions and 0 deletions
package api
import (
"fmt"
"time"
)
type TimeDto time.Time
func (dto TimeDto) MarshalJSON() ([]byte, error) {
if y := time.Time(dto).Year(); y < 0 || y >= 10000 {
// RFC 3339 is clear that years are 4 digits exactly.
// See golang.org/issue/4556#c15 for more discussion.
return nil, fmt.Errorf("api.time: year outside of range [0,9999]")
}
b := make([]byte, 0, len(time.RFC3339Nano)+2)
b = append(b, '"')
b = time.Time(dto).AppendFormat(b, time.RFC3339Nano)
b = append(b, '"')
return b, nil
}
func (dto *TimeDto) UnmarshalJSON(data []byte) error {
// Ignore null, like in the main JSON package.
if string(data) == "null" {
return nil
}
// Fractional seconds are handled implicitly by Parse.
var err error
result, err := time.Parse(`"`+time.RFC1123+`"`, string(data))
if err != nil {
return err
}
*dto = TimeDto(result)
return nil
}
package api
type WrappedRef[T any] struct {
Ref T `json:"$ref,omitempty"`
}
package deviceauth
import (
"fmt"
"golang.org/x/oauth2"
"net/url"
"scaleway-dedibox-api/httputil"
"strings"
"time"
)
const (
errAuthorizationPending = "authorization_pending"
errSlowDown = "slow_down"
errAccessDenied = "access_denied"
errExpiredToken = "expired_token"
)
type Endpoint struct {
AuthURL string
TokenURL string
DeviceCodeURL string
// AuthStyle optionally specifies how the endpoint wants the
// client ID & client secret sent. The zero value means to
// auto-detect.
AuthStyle AuthStyle
}
type DeviceAuthConfig struct {
Client *httputil.Client
// ClientID is the application's ID.
ClientID string
// ClientSecret is the application's secret.
ClientSecret string
// Endpoint contains the resource server's token endpoint URLs.
Endpoint Endpoint
// Scope specifies optional requested permissions.
Scopes []string
}
const maximumBodySize = 1 << 2
// AuthDevice returns a device auth struct which contains a device code
// and authorization information provided for users to enter on another device.
func (config *DeviceAuthConfig) AuthDevice(opts ...AuthCodeOption) (*DeviceAuth, error) {
v := url.Values{
"client_id": {config.ClientID},
}
if len(config.Scopes) > 0 {
v.Set("scope", strings.Join(config.Scopes, " "))
}
for _, opt := range opts {
opt.setValue(v)
}
v.Set("new_credentials", "yes")
now := time.Now()
da, err := config.retrieveDeviceAuth(v)
if err != nil {
return nil, fmt.Errorf("deviceauth: unable to authenticate device\n %w", err)
}
verificationUri := da.VerificationURI
if verificationUri == "" {
verificationUri = da.VerificationURL
}
return &DeviceAuth{
DeviceCode: da.DeviceCode,
UserCode: da.UserCode,
VerificationURI: verificationUri,
VerificationURIComplete: da.VerificationURIComplete,
Expires: da.ExpiresIn.expiry(now),
Interval: time.Duration(da.Interval) * time.Second,
}, nil
}
// Poll polls to exchange a device code for a token.
func (config *DeviceAuthConfig) Poll(devideAuth *DeviceAuth, options ...AuthCodeOption) (*oauth2.Token, error) {
params := url.Values{
"client_id": {config.ClientID},
"grant_type": {"http://oauth.net/grant_type/device/1.0"},
"device_code": {devideAuth.DeviceCode},
"code": {devideAuth.DeviceCode},
}
if len(config.Scopes) > 0 {
params.Set("scope", strings.Join(config.Scopes, " "))
}
for _, option := range options {
option.setValue(params)
}
// If no interval was provided, the client MUST use a reasonable default polling interval.
// See https://tools.ietf.org/html/draft-ietf-oauth-device-flow-07#section-3.5
interval := devideAuth.Interval
if interval == 0 {
interval = 5 * time.Second
}
for {
tok, err := config.retrieveToken(params, config.Endpoint.AuthStyle)
if err == nil {
return tok, nil
}
errTyp := parseError(err)
switch errTyp {
case errAccessDenied, errExpiredToken:
return tok, fmt.Errorf("deviceauth: unable to poll token: %s\n %w", errTyp, err)
case errSlowDown:
interval += 5 * time.Second
fallthrough
case errAuthorizationPending:
time.Sleep(interval)
}
}
}
package deviceauth
import "net/url"
var (
// AccessTypeOnline and AccessTypeOffline are options passed
// to the Options.AuthCodeURL method. They modify the
// "access_type" field that gets sent in the URL returned by
// AuthCodeURL.
//
// Online is the default if neither is specified. If your
// application needs to refresh access tokens when the user
// is not present at the browser, then use offline. This will
// result in your application obtaining a refresh token the
// first time your application exchanges an authorization
// code for a user.
AccessTypeOnline AuthCodeOption = SetAuthURLParam("access_type", "online")
AccessTypeOffline AuthCodeOption = SetAuthURLParam("access_type", "offline")
// ApprovalForce forces the users to view the consent dialog
// and confirm the permissions request at the URL returned
// from AuthCodeURL, even if they've already done so.
ApprovalForce AuthCodeOption = SetAuthURLParam("prompt", "consent")
)
// An AuthCodeOption is passed to Config.AuthCodeURL.
type AuthCodeOption interface {
setValue(url.Values)
}
type setParam struct{ k, v string }
func (p setParam) setValue(m url.Values) { m.Set(p.k, p.v) }
// SetAuthURLParam builds an AuthCodeOption which passes key/value parameters
// to a provider's authorization endpoint.
func SetAuthURLParam(key, value string) AuthCodeOption {
return setParam{key, value}
}
package deviceauth
// AuthStyle is a copy of the golang.org/x/oauth2 package's AuthStyle type.
type AuthStyle int
const (
AuthStyleInParams AuthStyle = 1
AuthStyleInHeader AuthStyle = 2
)
package deviceauth
import "time"
type DeviceAuth struct {
DeviceCode string `json:"device-code"`
UserCode string `json:"user-code"`
VerificationURI string `json:"verification-uri"`
VerificationURIComplete string `json:"verification-uri-complete"`
Expires time.Time `json:"expires"`
Interval time.Duration `json:"interval"`
}
package deviceauth
import (
"encoding/json"
"math"
"time"
)
type durationDto int32
func (d durationDto) expiry(now time.Time) (t time.Time) {
if d != 0 {
return now.Add(time.Duration(d) * time.Second)
}
return
}
func (d *durationDto) UnmarshalJSON(b []byte) error {
if len(b) == 0 || string(b) == "null" {
return nil
}
var n json.Number
err := json.Unmarshal(b, &n)
if err != nil {
return err
}
i, err := n.Int64()
if err != nil {
return err
}
if i > math.MaxInt32 {
i = math.MaxInt32
}
*d = durationDto(i)
return nil
}
package deviceauth
import (
"encoding/json"
"fmt"
"golang.org/x/oauth2"
"io"
"io/ioutil"
"net/http"
"net/url"
"scaleway-dedibox-api/httputil"
"strings"
)
type jsonDeviceAuth struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
VerificationURL string `json:"verification_url"`
VerificationURIComplete string `json:"verification_uri_complete,omitempty"`
ExpiresIn durationDto `json:"expires_in"`
Interval durationDto `json:"interval,omitempty"`
}
func (config *DeviceAuthConfig) retrieveDeviceAuth(params url.Values) (*jsonDeviceAuth, error) {
request, err := http.NewRequest("POST", config.Endpoint.DeviceCodeURL, strings.NewReader(params.Encode()))
if err != nil {
return nil, fmt.Errorf("deviceauth.request_deviceauth: unable to build device auth request\n %w", err)
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
reponse, err := config.Client.Do(request)
if err != nil {
return nil, fmt.Errorf("deviceauth.request_deviceauth: unable to authenticate device\n %w", err)
}
body := io.LimitReader(reponse.Body, maximumBodySize)
if !httputil.IsHttpStatusSuccess(reponse.StatusCode) {
fullBody, err := ioutil.ReadAll(body)
if err != nil {
return nil, fmt.Errorf("deviceauth.request_deviceauth: unable to authenticate device\n %w", err)
}
return nil, &oauth2.RetrieveError{
Response: reponse,
Body: fullBody,
}
}
var deviceAuth = &jsonDeviceAuth{}
err = json.NewDecoder(body).Decode(&deviceAuth)
if err != nil {
return nil, fmt.Errorf("deviceauth.request_deviceauth: unable to deserialize device auth response\n %w", err)
}
return deviceAuth, nil
}
func parseError(err error) string {
e, ok := err.(*oauth2.RetrieveError)
if ok {
eResp := make(map[string]string)
_ = json.Unmarshal(e.Body, &eResp)
return eResp["error"]
}
return ""
}
package deviceauth
import (
"encoding/json"
"fmt"
"golang.org/x/oauth2"
"io"
"io/ioutil"
"mime"
"net/http"
"net/url"
"scaleway-dedibox-api/httputil"
"strconv"
"strings"
"time"
)
func cloneURLValues(v url.Values) url.Values {
v2 := make(url.Values, len(v))
for k, vv := range v {
v2[k] = append([]string(nil), vv...)
}
return v2
}
// newTokenRequest returns a fallback *http.Request to retrieve a fallback token
// from tokenURL using the provided clientID, clientSecret, and POST
// body parameters.
func newTokenRequest(tokenURL, clientID, clientSecret string, v url.Values, authStyle AuthStyle) (*http.Request, error) {
if authStyle == AuthStyleInParams {
v = cloneURLValues(v)
if clientID != "" {
v.Set("client_id", clientID)
}
if clientSecret != "" {
v.Set("client_secret", clientSecret)
}
}
req, err := http.NewRequest("POST", tokenURL, strings.NewReader(v.Encode()))
if err != nil {
return nil, fmt.Errorf("deviceauth.request_token: could not build token request\n %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if authStyle == AuthStyleInHeader {
req.SetBasicAuth(url.QueryEscape(clientID), url.QueryEscape(clientSecret))
}
return req, nil
}
// tokenJSON is the struct representing the HTTP response from OAuth2
// providers returning a token in JSON form.
type tokenJSON struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
RefreshToken string `json:"refresh_token"`
ExpiresIn durationDto `json:"expires_in"` // at least PayPal returns string, while most return number
}
func (config *DeviceAuthConfig) doTokenRoundTrip(request *http.Request) (*oauth2.Token, error) {
now := time.Now()
response, err := config.Client.Do(request)
if err != nil {
return nil, fmt.Errorf("deviceauth.request_token: unable to fetch token\n %w", err)
}
body := io.LimitReader(response.Body, maximumBodySize)
if !httputil.IsHttpStatusSuccess(response.StatusCode) {
fullBody, err := ioutil.ReadAll(body)
if err != nil {
return nil, fmt.Errorf("deviceauth.request_token: unable to fetch token\n %w", err)
}
return nil, &oauth2.RetrieveError{
Response: response,
Body: fullBody,
}
}
var token *oauth2.Token
content, _, _ := mime.ParseMediaType(response.Header.Get("Content-Type"))
switch content {
case "application/x-www-form-urlencoded", "text/plain":
fullBody, err := ioutil.ReadAll(body)
if err != nil {
return nil, fmt.Errorf("deviceauth.request_token: could not read token response\n %w", err)
}
vals, err := url.ParseQuery(string(fullBody))
if err != nil {
return nil, fmt.Errorf("deviceauth.request_token: could not parse token response\n %w", err)
}
token = &oauth2.Token{
AccessToken: vals.Get("access_token"),
TokenType: vals.Get("token_type"),
RefreshToken: vals.Get("refresh_token"),
}
e := vals.Get("expires_in")
expires, _ := strconv.Atoi(e)
if expires != 0 {
token.Expiry = now.Add(time.Duration(expires) * time.Second)
}
default:
var tj tokenJSON
if err = json.NewDecoder(body).Decode(&tj); err != nil {
return nil, fmt.Errorf("deviceauth.request_token: could not deserialize token response\n %w", err)
}
token = &oauth2.Token{
AccessToken: tj.AccessToken,
TokenType: tj.TokenType,
RefreshToken: tj.RefreshToken,
Expiry: tj.ExpiresIn.expiry(now),
}
}
if token.AccessToken == "" {
return nil, fmt.Errorf("deviceauth.request_token: server response missing access_token")
}
return token, nil
}
func (config *DeviceAuthConfig) retrieveToken(params url.Values, authStyle AuthStyle) (*oauth2.Token, error) {
req, err := newTokenRequest(config.Endpoint.TokenURL, config.ClientID, config.ClientSecret, params, authStyle)
if err != nil {
return nil, fmt.Errorf("deviceauth.request_token: could not retrieve token\n %w", err)
}
token, err := config.doTokenRoundTrip(req)
// Don’t overwrite `RefreshToken` with an empty value if this was a token refreshing request.
if token != nil && token.RefreshToken == "" {
token.RefreshToken = params.Get("refresh_token")
}
if err != nil {
return nil, fmt.Errorf("deviceauth.request_token: could not retrieve token\n %w", err)
}
return token, nil
}
package deviceauth
import (
"fmt"
"golang.org/x/oauth2"
)
type deviceAuthTokenSource struct {
deviceAuthConfig *DeviceAuthConfig
}
func (s *deviceAuthTokenSource) Token() (*oauth2.Token, error) {
device, err := s.deviceAuthConfig.AuthDevice(AccessTypeOffline)
if err != nil {
return nil, fmt.Errorf("deviceauth.tokensource_deviceauth: could not authenticate device\n %w", err)
}
fmt.Printf(
"\n Please go to the following URL and enter this code '%s' to verify this oauth app:\n\n %s\n\n\n",
device.UserCode,
device.VerificationURI,
)
token, err := s.deviceAuthConfig.Poll(device, AccessTypeOffline)
if err != nil {
return nil, fmt.Errorf("deviceauth.tokensource_deviceauth: did not receive a device token\n %w", err)
}
return token, nil
}
func DeviceAuthTokenSource(config *DeviceAuthConfig) oauth2.TokenSource {
return &deviceAuthTokenSource{
deviceAuthConfig: config,
}
}
package deviceauth
import (
"fmt"
"github.com/sirupsen/logrus"
"golang.org/x/oauth2"
"scaleway-dedibox-api/util"
"sync"
)
var persistingTokenSourceLog = logrus.WithField("file", "deviceauth.tokensource_persisting")
// reuseTokenSource is a TokenSource that holds a single token in storage
// and validates its expiry before each call to retrieve it with
// Token. If it's expired, it will be auto-refreshed using the
// fallback TokenSource.
type persistingTokenSource struct {
fallback oauth2.TokenSource // called when token is expired.
mutex sync.Mutex // guards token
token *oauth2.Token
persister util.Persister[oauth2.Token]
}
// Token returns the current token if it's still valid, else will
// refresh the current token and return the new one.
func (source *persistingTokenSource) Token() (*oauth2.Token, error) {
source.mutex.Lock()
defer source.mutex.Unlock()
if source.token.Valid() {
return source.token, nil
}
token, err := source.persister.Restore()
if err != nil {
persistingTokenSourceLog.Debugln("requesting new token")
token, err = source.fallback.Token()
} else {
persistingTokenSourceLog.Debugln("token restored")
}
if err != nil {
return nil, fmt.Errorf("deviceauth.tokensource_persisting: could not request new token\n %w", err)
}
source.token = token
persistingTokenSourceLog.Debugln("persisting token")
err = source.persister.Persist(token)
if err != nil {
return nil, fmt.Errorf("deviceauth.tokensource_persisting: could not persist new token\n %w", err)
}
return token, nil
}
func PersistingTokenSource(fallback oauth2.TokenSource, persister util.Persister[oauth2.Token]) (oauth2.TokenSource, error) {
// Don't wrap a reuseTokenSource in itself. That would work,
// but cause an unnecessary number of mutex operations.
// Just build the equivalent one.
if wrapped, ok := fallback.(*persistingTokenSource); ok {
fallback = wrapped.fallback
}
return &persistingTokenSource{
fallback: fallback,
persister: persister,
}, nil
}
go.mod 0 → 100644
module scaleway-dedibox-api
go 1.18
require (
github.com/sirupsen/logrus v1.8.1
golang.org/x/oauth2 v0.0.0-20220524215830-622c5d57e401
github.com/golang/protobuf v1.4.2 // indirect
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e // indirect
google.golang.org/appengine v1.6.6 // indirect
google.golang.org/protobuf v1.25.0 // indirect
)
go.sum 0 → 100644
This diff is collapsed.
package httputil
import (
"context"
"fmt"
"golang.org/x/net/context/ctxhttp"
"net/http"
)
type Client struct {
client http.Client
ctx context.Context
requestMiddleware *RequestMiddleware
responseMiddleware *ResponseMiddleware
}
func NewClient(ctx context.Context, client *http.Client) *Client {
if client == nil {
client = &http.Client{}
}
return &Client{
ctx: ctx,
client: *client,
}
}
func (c *Client) WithRequestMiddleware(middleware RequestMiddleware) *Client {
if c.requestMiddleware != nil {
middleware = RequestMiddlewares(*c.requestMiddleware, middleware)
}
return &Client{
client: c.client,
requestMiddleware: &middleware,
responseMiddleware: c.responseMiddleware,
}
}
func (c *Client) WithResponseMiddleware(middleware ResponseMiddleware) *Client {
if c.responseMiddleware != nil {
middleware = ResponseMiddlewares(*c.responseMiddleware, middleware)
}
return &Client{
client: c.client,
requestMiddleware: c.requestMiddleware,
responseMiddleware: &middleware,
}
}
func (c *Client) Do(req *http.Request) (*http.Response, error) {
if c.requestMiddleware != nil {
if err := (*c.requestMiddleware)(req); err != nil {
return nil, fmt.Errorf("httputil.client: could not execute request middleware\n %w", err)
}
}
var resp *http.Response
var err error
if c.ctx == nil {
resp, err = c.client.Do(req)
} else {
resp, err = ctxhttp.Do(c.ctx, &c.client, req)
}
if err != nil {
return nil, fmt.Errorf("httputil.client: could not execute request\n %w", err)
}
if resp == nil {
return nil, fmt.Errorf("httputil.client: got a nil response during an http request")
}
if c.responseMiddleware != nil {
if err := (*c.responseMiddleware)(req, resp); err != nil {
return nil, fmt.Errorf("httputil.client: could not execute response middleware\n %w", err)
}
}
return resp, nil
}
func IsHttpStatusSuccess(status int) bool {
return status >= 200 && status < 300
}
package httputil
import (
"net/http"
)
type RequestMiddleware func(req *http.Request) error
type ResponseMiddleware func(req *http.Request, resp *http.Response) error
func RequestMiddlewares(middlewares ...RequestMiddleware) RequestMiddleware {
return func(req *http.Request) error {
for _, middleware := range middlewares {
if err := middleware(req); err != nil {
return err
}
}
return nil
}
}
func ResponseMiddlewares(middlewares ...ResponseMiddleware) ResponseMiddleware {
return func(req *http.Request, resp *http.Response) error {
for _, middleware := range middlewares {
if err := middleware(req, resp); err != nil {
return err
}
}
return nil
}
}
package httputil
import (
"fmt"
"github.com/sirupsen/logrus"
"golang.org/x/oauth2"
"net/http"
"time"
)
var tokenSourceMiddlewareLog = logrus.WithField("file", "httputil.middleware_tokensource")
func TokenSourceMiddleware(tokenSource oauth2.TokenSource) RequestMiddleware {
return func(req *http.Request) error {
token, err := tokenSource.Token()
if err != nil {
return err
}
if token.Type() != "Bearer" {
return fmt.Errorf("httputil.middleware_tokensource: cannot use token of type %s for authorization", token.Type())
}
tokenSourceMiddlewareLog.Debugln("authorizing with token having expiry of", token.Expiry.Format(time.RFC3339))
req.Header.Set(
"Authorization",
fmt.Sprintf("Bearer %s", token.AccessToken),
)
return nil
}
}
package httputil
import (
"fmt"
"github.com/sirupsen/logrus"
"net/http"
"net/http/httputil"
"strings"
)
func TracingMiddleware(traceAll bool) ResponseMiddleware {
return func(req *http.Request, resp *http.Response) error {
if traceAll || !IsHttpStatusSuccess(resp.StatusCode) {
originalAuth := req.Header.Get("Authorization")
if originalAuth != "" {
if strings.HasPrefix(originalAuth, "Bearer ") {
req.Header.Set("Authorization", "Bearer [redacted]")
} else {
req.Header.Set("Authorization", "[redacted]")
}
}
reqTrace, err := httputil.DumpRequestOut(req, false)
if err != nil {
return fmt.Errorf("httputil.middleware_tracing: could not trace http request %w", err)
}
// only dump body for errors
respTrace, err := httputil.DumpResponse(resp, traceAll || !IsHttpStatusSuccess(resp.StatusCode))
if err != nil {
return fmt.Errorf("httputil.middleware_tracing: could not trace http response %w", err)
}
logrus.Infoln(
strings.Join(
[]string{
"--- HTTP TRACE ---",
string(reqTrace),
strings.TrimSpace(string(respTrace)),
},
"\n",
),
)
}
return nil
}
}
package util
import (
"encoding/json"
"fmt"
"os"
)
type FilePersister[T any] struct {
path string
}
func NewFilePersister[T any](path string) Persister[T] {
return Persister[T](FilePersister[T]{path})
}
func (persister FilePersister[T]) Restore() (*T, error) {
file, err := os.OpenFile(persister.path, os.O_RDONLY, 0600)
if err != nil {
return nil, fmt.Errorf("filepersister: could not restore file %s\n %w", persister.path,
fmt.Errorf("could not open file\n %w", err))
}
var data T
err = json.NewDecoder(file).Decode(&data)
if err != nil {
return nil, fmt.Errorf("filepersister: could not restore file %s\n %w", persister.path,
fmt.Errorf("could not deserialize data\n %w", err))
}
return &data, nil
}
func (persister FilePersister[T]) Persist(data *T) error {
file, err := os.OpenFile(persister.path, os.O_RDWR|os.O_CREATE, 0600)
if err != nil {
return fmt.Errorf("filepersister: could not persist file %s\n %w", persister.path,
fmt.Errorf("could not open file\n %w", err))
}
err = json.NewEncoder(file).Encode(data)
if err != nil {
return fmt.Errorf("filepersister: could not persist file %s\n %w", persister.path,
fmt.Errorf("could not serialize data\n %w", err))
}
return nil
}
package util
type Persister[T any] interface {
Restore() (*T, error)
Persist(data *T) error
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment