servertoken
Server Token authorization using Go. A server token is a way to identify a client of an API that is not accessing a resource directly as the user (as in Oauth2). Some shops call these server tokens API keys, I chose server token.
To validate the server token, Basic Authentication is used for endpoints that do not require a user's authentication. For Basic Auth endpoints, a pre-assigned "Server" token should be sent in the username field of the Basic Authentication scheme. The password should be left blank.
Code Walkthrough
Through the magic of closures, the servertoken.Handler function is able to reference the request details(r.Context and r.BasicAuth below). The API servertoken is taken from the username portion of the Basic Authorization HTTP request header. I chose to do API authorization with Basic Auth because it's easy to understand. Everyone does authorization differently (see some research below). Authorization using Oauth2 (and now Webauthn) are something I need to use for many of my APIs as well, but that's a whole other story.
// Handler middleware performs Server Token authorization
// The client must send their Server token as the username portion of
// HTTP Basic Authenication. This middleware will parse the token and
// determine if the token is valid for the request path and method
func Handler(log zerolog.Logger, db *sql.DB) (mw func(http.Handler) http.Handler) {
mw = func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
const op errors.Op = "servertoken/Handler"
// retrieve the context from the http.Request
ctx := r.Context()
token, _, ok := r.BasicAuth()
if !ok {
err := errors.E(op, "Unable to parse Basic Auth string")
// log error
log.Error().Err(err).
Str("path", r.URL.Path).
Str("method", r.Method).Msg("")
// use RE (Response Error) to send Response with appropriate http status code
err = errors.RE(http.StatusUnauthorized, errors.Permission, "BasicAuth Parse Error", err)
errors.HTTPError(w, err)
return
}
Once the token has been successfully parsed from the Basic Auth header, it is set inside the ServerToken type and the Authorize method is called sending the request context, logger, app db, path of the incoming request as well as the request method.
st := ServerToken(token)
ctx, err := st.Authorize(ctx, log, db, r.URL.Path, r.Method)
if err != nil {
err := errors.E(op, err)
// log error
log.Error().Err(err).
Str("servertoken", st.String()).
Str("path", r.URL.Path).
Str("method", r.Method).Msg("")
// use RE (Response Error) to send Response with appropriate http status code
err = errors.RE(http.StatusUnauthorized, errors.Permission, "Unauthorized", err)
errors.HTTPError(w, err)
return
}
Inside the servertoken.Authorize method, the servertoken is added to the context
func (s *ServerToken) Authorize(ctx context.Context, log zerolog.Logger, db *sql.DB, path string, method string) (context.Context, error) {
const op errors.Op = "servertoken/Authorize"
ctx = s.Add2Ctx(ctx)
using the Add2Ctx method
type contextKey string
var ctxKey = contextKey("ServerToken")
// Add2Ctx adds the calling serverToken to the context
func (s *ServerToken) Add2Ctx(ctx context.Context) context.Context {
ctx = context.WithValue(ctx, ctxKey, s.String())
return ctx
}
Next in the servertoken.Authorize method, I authorize the servertoken against my app database using the servertoken.authorizeDB method. You could do this in something like Redis for speed, but for keeping it simple for now, I used the same app db as everything else.
err := s.authorizeDB(ctx, log, db, path, method)
The Authorize method uses a stored function istokenvalid which returns a boolean in the database.
create function istokenvalid(p_server_token character varying, p_path character varying, p_method character varying)
returns boolean
language plpgsql
as
$$
DECLARE
v_exists int;
v_ok boolean;
BEGIN
BEGIN
SELECT 1 INTO STRICT v_exists
FROM auth.server_token a
WHERE a.server_token = p_server_token
and a.resource = p_path
and a.method = p_method
and (a.exp_timestamp is null or a.exp_timestamp > current_timestamp);
EXCEPTION
WHEN NO_DATA_FOUND THEN
v_ok = false;
WHEN TOO_MANY_ROWS THEN
v_ok = false;
END;
if v_exists = 1 then
v_ok = true;
end if;
return v_ok;
END;
$$;
The table structure is quite simple
create table auth.server_token
(
server_token varchar(1000) not null,
resource varchar(1000) not null,
method varchar(30) not null,
exp_timestamp timestamp
);
comment on table auth.server_token is 'Table is used to authorize server tokens.';
The stored function is called below to determine validity of the token.
// authorizeServerToken uses a db function to authorize a client's ServerToken
func (s *ServerToken) authorizeDB(ctx context.Context, log zerolog.Logger, db *sql.DB, path string, method string) error {
const op errors.Op = "servertoken/authorizeDB"
var ok *bool
err := db.QueryRowContext(ctx, `SELECT auth.istokenvalid(
p_server_token => $1,
p_path => $2,
p_method => $3)`, s, path, method).Scan(&ok)
switch {
case err == sql.ErrNoRows:
return errors.E(op, "Database Error - select returned no results")
case err != nil:
return errors.E(op, err)
}
if !*ok {
return errors.E(op, "Token is not authorized")
}
return nil
}
If valid, then back in servertoken.Authorize, the context with the servertoken is returned to the Handler.
return ctx, err
}
Back inside the Handler function, the context is added to the request context as part of the call to the next Handler in the chain.
h.ServeHTTP(w, r.WithContext(ctx)) // call original
})
}
return
}
Research for Server Authorization Tokens
Uber
Uber provides both a server token and a user token in the Uber admin console
- Uber uses an OAuth Bearer token for requests that require a user's login:
$ curl -H 'Authorization: Bearer <USER_ACCESS_TOKEN>' \
-H 'Accept-Language: en_US' \
-H 'Content-Type: application/json' \
'https://api.uber.com/v1.2/estimates/price?start_latitude=37.7752315&start_longitude=-122.418075&end_latitude=37.7752415&end_longitude=-122.518075'
- Uber uses an unusual "Token" Authorization scheme for server tokens that do not require a user login:
$ curl -H 'Authorization: Token <SERVER_TOKEN>' \
-H 'Accept-Language: en_US' \
-H 'Content-Type: application/json' \
'https://api.uber.com/v1.2/estimates/price?start_latitude=37.7752315&start_longitude=-122.418075&end_latitude=37.7752415&end_longitude=-122.518075'
Twilio
- Twilio uses HTTP Basic Authentication and provides a SID (some custom unique ID) and an auth token as part of the Twilio admin console. This account SID thing is also passed back in responses
$ curl -G https://api.twilio.com/2010-04-01/Accounts \
-u '[YOUR ACCOUNT SID]:[YOUR AUTH TOKEN]'
Stripe
- Stripe just has you pass an API key as the username of HTTP Basic Authentication and no password
$ curl https://api.stripe.com/v1/charges \
-u 'sk_test_4eC39HqLyjWDarjtT1zdp7dc:'
Mailchimp
- Mailchimp has you pass your API key in the password field of HTTP Basic Authentication (you can pass anything you want in the username section). Mailchimp also supports Oauth2, but has a somewhat unusual implementation of it (no Bearer token, an "Oauth token" instead), no refresh token, etc.
$ curl --request GET \
--url 'https://<dc>.api.mailchimp.com/3.0/' \
--user 'anystring:<your_apikey>'