Danylo's website

OAuth2 intro

Word count: ~1862

OAuth2 or RFC6749.

It is a popular auth framework, where resource owner can grant access to some resource and a client use the granted permissions to do work on behalf of the user. Or pre-configured program can request access on behalf of itself. The protocol is only defined in terms of HTTP.

But, I’m only going to tell you about client side OAuth, that is, what should be sent to receive access token. To my surprise it’s simple, though I’m not sure about writing auth related code myself.

The spec has three entities:

Several grant types:

Before all, you should get client_id and maybe client_secret, but the RFC does not specify where to get them. For example, Codeberg can be OAuth provider.

Authorization code

I have Go backend. To block some nasty bots, I decided to add authorization. OAuth gives an access token to access restricted APIs, but the mere fact of issuing a token means the user have valid account in one of the provider services. Additionally, I’ll show how to easily add OpenID Connect to get more information about users.

To start authorization flow you have to form a URL with query params, you know, the key value pairs after ?.

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /v1/oauth/{provider}/login", login)
}

func (o *OAuth) Login(w http.ResponseWriter, r *http.Request) {
	provider := r.PathValue("provider")

    var config Provider
    var ok bool

	config, ok = o.providers[provider]
	if !ok {
		RespondError(w, r, http.StatusBadRequest, "oauth provider not found")
		return
	}

	verifier := base64Encode(cryptoRngBytes(32))
	id := cryptoRngBytes(32)
	o.verifiers[string(id)] = verifier

	m := url.Values{
		"response_type":         []string{"code"},
		"redirect_uri":          []string{config.RedirectURL},
		"code_challenge_method": []string{"S256"}, // SHA256
		"code_challenge":        []string{base64Encode(sha256([]byte(verifier)))},
		"client_id":             []string{config.ClientID},
		"scope":                 []string{strings.Join(config.Scopes, " ")},
		"state":                 []string{id},
	}

	var buf strings.Builder
	_, _ = buf.WriteString(config.Endpoint.AuthURL)
	_ = buf.WriteByte('?')
	_, _ = buf.WriteString(m.Encode())

	http.Redirect(w, r, buf.String(), http.StatusFound)
}

func cryptoRngBytes(size uint) []byte
func sha256(data []byte) []byte
func base64Encode(data []byte) string {
	return base64.RawURLEncoding.EncodeToString(data[:])
}

type Endpoint struct {
	AuthURL  string
	TokenURL string
	InHeader bool
}

type Provider struct {
	ClientID     string
	ClientSecret string
	Endpoint     Endpoint
	RedirectURL  string
	Scopes       []string
}

Not bad, not bad! According to the spec, there’s only two required params, but to be more secure it’s recommended to implement PKCE.

// required
"response_type":         []string{"code"},
"client_id":             []string{config.ClientID},
// optional
"redirect_uri":          []string{config.RedirectURL},
"code_challenge_method": []string{"S256"}, // PKCE
"code_challenge":        []string{base64Encode(sha256([]byte(verifier)))}, // PKCE
"scope":                 []string{strings.Join(config.Scopes, " ")},
"state":                 []string{id},

Even when the authorization server does not support PKCE, all unrecognized params are just ignored, that means always sending PKCE is OK. However, you will have to use older method of protection, by sending cryptographically random string in state parameter and later verifying it.

Once user agent redirected user to authorization server, the server waits for a redirect with response parameters in URL query.

Authorization code exchange

It’s time to parse redirect params and exchange authorization token for an access token!

func main() {
    // ...
	mux.HandleFunc("GET /v1/oauth/{provider}/callback", callback)
}

func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) {
	provider := r.PathValue("provider")
	cfg, ok := o.providers[provider]
	if !ok {
		RespondError(w, r, http.StatusBadRequest, "oauth provider not found")
		return
	}

	ctx := r.Context()
	query := r.URL.Query()

	code := query.Get("code")
	if code == "" {
		RespondError(w, r, http.StatusBadRequest, "empty code")
		return
	}

	state := query.Get("state")

	verifier, ok := o.verifiers[state]
	if !ok {
		RespondError(w, r, http.StatusBadRequest, "bad state")
		return
	}

	delete(o.verifiers, state)

	_, err := cfg.Exchange(ctx, code, verifier)
	if err != nil {
		RespondError(w, r, http.StatusInternalServerError, "could not exchange auth token")
		return
	}

	http.SetCookie(w, &http.Cookie{
		Name:     "auth-token",
		Value:    "some crypto random string",
		Path:     "/",
		Domain:   "",
		Expires:  time.Now().Add(7 * 24 * time.Hour),
		Secure:   true,
		HttpOnly: true,
		SameSite: http.SameSiteStrictMode,
	})

	http.Redirect(w, r, "...", http.StatusFound)
}

func (p Provider) Exchange(ctx context.Context, code string, verifier Verifier) (Token, error) {
	m := make(url.Values, 6)
	m.Set("grant_type", "authorization_code")
	m.Set("code", code)
	m.Set("redirect_uri", p.RedirectURL)
	m.Set("code_verifier", string(verifier))
	if !p.Endpoint.InHeader {
		m.Set("client_id", p.ClientID)
		m.Set("client_secret", p.ClientSecret)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.Endpoint.TokenURL, strings.NewReader(m.Encode()))
	if err != nil {
		return Token{}, fmt.Errorf("new token url request: %w", err)
	}

	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

	if p.Endpoint.InHeader {
		req.SetBasicAuth(p.ClientID, p.ClientSecret)
	}

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return Token{}, fmt.Errorf("do token url request: %w", err)
	}

	defer func() {
		_ = resp.Body.Close()
	}()

	bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 1*1024*1024))
	if err != nil {
		return Token{}, fmt.Errorf("read token url response: %w", err)
	}

	if resp.StatusCode != 200 {
		return Token{}, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
	}

	mt, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type"))
	switch mt {
	case "application/x-www-form-urlencoded":
		return NewTokenFromURLEncoded(string(bodyBytes))
	case "application/json":
		var token Token
		err = json.Unmarshal(bodyBytes, &token)
		if err != nil {
			return Token{}, fmt.Errorf("unmarshal body into token: %w", err)
		}

		return token, nil
	default:
		return Token{}, fmt.Errorf("unexpected response content-type: %s", mt)
	}
}

type Token struct {
	AccessToken      string `json:"access_token"`
	TokenType        string `json:"token_type"`
	RefreshToken     string `json:"refresh_token"`
	ExpiresIn        uint   `json:"expires_in"`
	ErrorCode        string `json:"error"`
	ErrorDescription string `json:"error_description"`
	ErrorURI         string `json:"error_uri"`

	IDToken string `json:"id_token"`
}

In the params you might find: code, state, error, error_*. code here means “authorization code”. The code expires really quickly, you have around a minute to exchange it for access token, in addition it has to be exchanged only once. Some of you may have already noticed that, until now, all tokens and data were transmitted via the URL query parameters, but this is not the most secure way to pass confidential data. However, the authorization server keeps the authorization code even after first use, so it can revoke the token if someone tries to exchange it again. Neat!

Having received code and state, you must check state, since the server generated it, the exact same record must be found and deleted, otherwise this might be an attack attempt. Now create POST request for authorization code exchange.

A little digression. Using redirect anyone could send some data to try to imitate authorization server callback response, but now we are going to send HTTP request to a known URL that we trust. Thus, it’s fine to send private data over this back channel.

m := make(url.Values, 6)
m.Set("grant_type", "authorization_code")
m.Set("code", code)
m.Set("redirect_uri", p.RedirectURL)
m.Set("code_verifier", string(verifier))
if !p.Endpoint.InHeader {
	m.Set("client_id", p.ClientID)
	m.Set("client_secret", p.ClientSecret)
}

Make POST request with content type set to application/x-www-form-urlencoded. Only PKCE here deserves attention. When user agent redirects to authorization server, code_challenge is attached, that in essence is a SHA256 of randomly generated byte string, in contrast on exchange code_challenge is sent, that is the original value, before SHA256 transformation, because back channel is safe place.

The full snippet above can send client_id, client_secret as Authorization: Basic ... header. Some services expect these params only in the header! The same applies to response’s content type. The RFC specifies application/json, although some services respond with application/x-www-form-urlencoded.

On success, the client receives tokens.

type Token struct {
	AccessToken  string `json:"access_token"`
	RefreshToken string `json:"refresh_token"`
	IDToken      string `json:"id_token"`

	// TokenType string `json:"token_type"`
	// ExpiresIn uint   `json:"expires_in"`
	// ...
}

Well, mission accomplished. The trusted server’s response is a proof of user account existence. But wait, there’s id_token that specified in OpenID. To get it, you don’t need to rewrite all your code, id_token is added to response when you specify openid in your scopes with optional: profile, email, address, phone.

Decoding id_token JWT gives you familiar JSON with requested information about the user.

{
    "sub": "248289761001",
    "name": "Jane Doe",
    "given_name": "Jane",
    "family_name": "Doe",
    "preferred_username": "j.doe",
    "email": "janedoe@example.com",
    "picture": "http://example.com/janedoe/me.jpg"
}
Recursive descent parser in Go