Hi guys,
I’m trying to implement the OIDC Code flow for Facebook.
source: https://developers.facebook.com/docs/facebook-login/guides/advanced/oidc-token
This is how it works so far:
Code
The code looks like this (largely inspired form this blog post: https://chrisguitarguy.com/2022/12/07/oauth-pkce-with-go/)
package controllers
var (
state string
codeVerifier string
nonce string
)
var fbOAuthConfig = &oauth2.Config{
ClientID: "xxxxxxxx",
ClientSecret: "xxxxxxxx",
Scopes: []string{"openid"},
RedirectURL: "", // received by the authEndpointHandler below
Endpoint: oauth2.Endpoint{
AuthURL: "https://www.facebook.com/v17.0/dialog/oauth",
TokenURL: "https://graph.facebook.com/v17.0/oauth/access_token",
},
}
// 1) after the user clicks the Facebook btn on the front, he is redirected to this endpoint
func authEndpointHandler(w http.ResponseWriter, req *http.Request){
// assign the redirect URL to FB OAuth Config
FBOAuthConfig.RedirectURL = req.URL.Query().Get("redirect_url")
// generate authorization URL
...
// save nonce, state and codeVerifier
nonce = authURL.Nonce
state = authURL.State
codeVerifier = authURL.CodeVerifier
// redirect to the authorization URL
http.Redirect(w, req, authURL.URL, http.StatusFound)
}
// 2) once the user give approval, Facebook sends a request to this handler
func responseEndpointHandler(w http.ResponseWriter, req *http.Request){
// get the state
requestState := query.Get("state")
// from the doc: ConstantTimeCompare returns 1 if the two slices, x and y, have equal contents and 0 otherwise.
if subtle.ConstantTimeCompare([]byte(requestState), []byte(state)) == 0 {
textResponse(w, http.StatusBadRequest, "Invalid State")
return
}
// get the code
code := query.Get("code")
if code == "" {
textResponse(w, http.StatusBadRequest, "Missing Code")
return
}
// 3) exchange the code received by Facebook with an id_token
// from the doc: Exchange converts an authorization code into a token.
tokenResp, err := fbOAuthConfig.Exchange(
req.Context(),
code, oauth2.SetAuthURLParam("code_verifier", codeVerifier),
oauth2.SetAuthURLParam("redirect_uri", "https://front.app.com/signin/success)",
)
if err != nil {
textResponse(w, http.StatusInternalServerError, err.Error())
return
}
// extract the id token from the response and validate it
idToken:= tokenResp.Extra("id_token")
Verify(idToken)
...
// redirect to the required URL
http.Redirect(w, req, fbOAuthConfig.RedirectURL, http.StatusFound)
}
Problem
When I make a test, I works. But there’s something obvious I’m missing …
My backend API will probably receive multiple request at the same time, and today, I store the nounce
, state
and code_verifier
as package variables so if the authEndpointHandler
receives a request before responseEndpointHandler
finishes, then the nounce
, state
and code_verifier
values would be overiden by the incoming request.
Idea
I thought about creating a map like below in which the key would be the state.
var requestStates = map[string]struct {
CodeVerifier string
Nonce string
}{}
Then I would just have to:
- ensure not to generate twice the same state in a short period of time.
- remove each item once both handlers are finished to avoid collision between keys.
With this idea, the responseEndpointHandler
func would look something like this:
package controller
// ...
const REDIRECT_URI = "https://localhost:3000/auth/facebook/access-token"
var FBOAuthConfig = &oauth2.Config{
ClientID: "xxxxx",
ClientSecret: "xxxxx",
Scopes: []string{"openid", "email", "public_profile"},
RedirectURL: "", // retrieved as a query string parameter
Endpoint: oauth2.Endpoint{
AuthURL: "https://www.facebook.com/v17.0/dialog/oauth",
TokenURL: "https://graph.facebook.com/v17.0/oauth/access_token",
},
}
var states = map[string]struct {
CodeVerifier string
Nonce string
BrowserRedirectURL string
}{}
// 1) STEP 1: handle GET /auth/facebook/signin?redirect_url=https://localhost/signin/success
func (ctrl *controller) RedirectToAuthURL(w http.ResponseWriter, req *http.Request) {
FBOAuthConfig.RedirectURL = req.URL.Query().Get("redirect_url")
authURL, err := ctrl.getAuthorizationURL(FBOAuthConfig)
if err != nil {
helpers.ErrorJSON(w, http.StatusBadRequest, "bad request")
return
}
// save the nonce and codeVerifier and make it match to the created state
// 1 request = 1 state = 1 nonce + 1 code verifier + 1 BrowserRedirectURL
states[authURL.State] = struct {
CodeVerifier string
Nonce string
BrowserRedirectURL string
}{
authURL.CodeVerifier,
authURL.Nonce,
req.URL.Query().Get("redirect_url"),
}
http.Redirect(w, req, authURL.URL, http.StatusFound)
}
// 2) STEP 2: handle GET /auth/facebook/access-token?code=XXXXXXXXXXXX&state=XXXXXXXXXXXX
func (ctrl *controller) HandleResponse(w http.ResponseWriter, req *http.Request) {
requestState := req.URL.Query().Get("state")
if requestState == "" {
helpers.ErrorJSON(w, http.StatusBadRequest, "request state can't be empty")
return
}
// from doc: ConstantTimeCompare returns 1 if the two slices, x and y, have equal contents and 0 otherwise.
// Verify state and prevent timing attacks on state
// if subtle.ConstantTimeCompare([]byte(requestState), []byte(state)) == 0 {
// textResponse(w, http.StatusBadRequest, "Invalid State")
// return
// }
// check if state exists
state, ok := states[requestState]
if !ok {
helpers.ErrorJSON(w, http.StatusBadRequest, "invalid sequence, state doesn't exist")
return
}
// get the code
code := query.Get("code")
if code == "" {
helpers.ErrorJSON(w, http.StatusBadRequest, "missing code")
return
}
// STEP 3) Exchange converts an authorization code into a token.
tokenResp, err := FBOAuthConfig.Exchange(
req.Context(),
code,
oauth2.SetAuthURLParam("code_verifier", state.CodeVerifier),
oauth2.SetAuthURLParam("redirect_uri", REDIRECT_URI),
)
if err != nil {
helpers.ErrorJSON(w, http.StatusBadRequest, "exchange code error: "+ err)
return
}
// verify ID Token
claims, err := verifyIDToken(tokenResp.Extra("id_token").(string))
if err != nil {
helpers.ErrorJSON(w, http.StatusBadRequest, "invalid id token: "+ err)
return
}
// verify nonce
if claims["nonce"] != state.Nonce{
helpers.ErrorJSON(w, http.StatusBadRequest, "invalid id token: invalid nonce")
}
// set a cookie etc.
// ...
// END) redirect to front end success URL
http.Redirect(w, req, state.RedirectURL, http.StatusFound)
}
func (ctrl *controller) getAuthorizationURL(config *oauth2.Config) (*oauthdom.AuthURL, resterrors.RestErr) {
// create code verifier
codeVerifier, err := randomBytesInHex(32) // 64 character string here
if err != nil {
return nil, restErr
}
// create code challenge
sha2 := sha256.New()
io.WriteString(sha2, codeVerifier)
codeChallenge := base64.RawURLEncoding.EncodeToString(sha2.Sum(nil))
// create state
state, err := randomBytesInHex(24)
if err != nil {
return nil, err
}
// create nonce
nonce, err := randomBytesInHex(12)
if err != nil {
return nil, err
}
// create auth URL
// from doc: oauth2.SetAuthURLParam append the appropriate query string values to the authorization url.
authUrl := config.AuthCodeURL(
state,
oauth2.SetAuthURLParam("redirect_uri", REDIRECT_URI),
oauth2.SetAuthURLParam("scope", "openid, email, public_profile"),
oauth2.SetAuthURLParam("redirect_type", "code"),
oauth2.SetAuthURLParam("code_challenge", codeChallenge), // should be stored in sessions or HTTP only cookies, in memory, etc. WITH THE URL
oauth2.SetAuthURLParam("code_challenge_method", "S256"), // should be stored in sessions or HTTP only cookies, in memory, etc. WITH THE URL
oauth2.SetAuthURLParam("nonce", nonce),
)
return &oauthdom.AuthURL{
URL: authUrl,
State: state,
CodeVerifier: codeVerifier,
Nonce: nonce,
}, nil
}
// Used to Generate a code verifier and a state
func randomBytesInHex(count int) (string, error) {
buf := make([]byte, count)
_, err := io.ReadFull(rand.Reader, buf)
if err != nil {
return "", fmt.Errorf("Could not generate %d random bytes: %v", count, err)
}
return hex.EncodeToString(buf), nil
}
I really don’t know if I’m completely off so any comment/advice is welcome