Renewing Firebase Authentication ID tokens with gRPC
- 6 minutes read - 1129 wordsI’ve written before about a project in which I’m using Firebase Authentication in combination with Google Cloud Endpoints and a gRPC service running on Cloud Run:
- Firebase Authentication, Cloud Endpoints and gRPC (1of2)
- Firebase Authentication, Cloud Endpoints and gRPC (2of2)
This works well with one caveat, the ID tokens (JWTs) minted by Firebase Authentication have a 3600 second (one hour) lifetime.
The user flow in my app is that whenever the user invokes the app’s CLI:
- Initially (and whenever the ID token has expired), the user will be prompted to visit the app’s web page which uses Firebase Authentication to generate an ID token (JWT);
- The user will be prompted to copy the ID token and its expiration into the app’s CLI where it is persisted
- The app’s CLI retrieves persisted credentials and adds these to the gRPC request’s metadata (per Calling an authenticated method from gRPC);
- Cloud Endpoints extracts the value of
authorization
(sic.) from the metadata and authenticates the request - The app’s server uses a gRPC interceptor to make a request to the app’s authorization server and, if approved, invokes the handler.
The web app for Firebase Authentication is straightforward. Firebase Auth is well-documented and clear. The JavaScript client is able to retrieve the user’s credentials:
firebase.auth().onAuthStateChanged((user) => {
if (user) {
...
}
}
It wasn’t documented as clearly as I would have liked but, the Firebase Auth User
not only includes email
, displayName
but, what I’d overlooked refreshToken
.
In my defence, I think this is because, it’s necessary to either getIdToken()
or getIdTokenResult()
in order to interact with the JWT (I expected e.g. getRefreshToken()
or equivalent and there isn’t one).
Regardless, I’d missed the obvious refreshToken
and I was unsure how to refresh the ID token. My initial thought was to simply either the user back to the web app but, this would have been necessary every 3600 seconds which would have become tedious. The process requires the user interacting with a browser page becasue the browser is supplying the user’s e.g. Google, Microsoft, Facebook credentials and so this couldn’t be handled by the CLI with a simple HTTP GET
, the user has to be involved in the flow.
I actually discovered the Firebase Authentication Exchange a refresh token for an ID and refresh token method before I found the refreshToken
value but, combined you can see where this is headed.
The user flow is augmented and, when the ID token has expired and the user is redirected to the Firebase Authentication page of the app, the user is now required to copy the following values:
- The ID token (JWT)
- The ID token’s expiration
- The Refresh token
Whenever it’s invoked, the CLI checks for the existence of these credentials, if they don’t exist, they’re created. If they exist but they’re expired, the CLI (!) invokes the https://securetoken.googleapis.com/v1/token
method, with the app’s (!) Firebase API key and the user’s refreshToken
and this returns updated values.
NOTE The endpoint is the general-purpose Google Token Service endpoint and not a specific Fireabse URL
The refresh method uses the app’s gRPC-based Authentication service. Apart from consistency (all the app’s services are gRPC), a benefit of this approach is that only the server knows the Firebase API key; it is not exposed to the CLI client.
So, a simple Golang client for Google Token Service:
package google
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"github.com/go-logr/logr"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
const (
method string = "https://securetoken.googleapis.com/v1/token"
)
const (
grantType string = "refresh_token"
)
// TokenRequest is a type that represents the request body posted to Google Token Service
type TokenRequest struct {
GrantType string `json:"grant_type" description:"The type of token being sent"`
RefreshToken string `json:"refresh_token" description:"Refresh token to exchange for an access token"`
}
// NewTokenRequest is a function that takes a refresh token and returns a new TokenRequest
func NewTokenRequest(refreshToken string) *TokenRequest {
return &TokenRequest{
GrantType: grantType,
RefreshToken: refreshToken,
}
}
// Values is a method that converts a TokenRequest into URL-encoded values
func (t *TokenRequest) Values() url.Values {
data := url.Values{}
data.Set("grant_type", t.GrantType)
data.Set("refresh_token", t.RefreshToken)
return data
}
// TokenResponse is a type that represents the response body received from Google Token Service
type TokenResponse struct {
// Probably 3600 (seconds)
ExpiresIn string `json:"expires_in" description:"The number of second in which the ID token expires"`
TokenType string `json:"token_type" description:"The type of the access token; always Bearer"`
RefreshToken string `json:"refresh_token" description:"The refresh token provided in the request or a new refresh token"`
IDToken string `json:"id_token" description:"The ID token"`
UserID string `json:"user_id" description:"A unique identifier of the User"`
ProjectID string `json:"project_id" description:"The Google Project ID"`
}
// NewTokenResponse is a function that converts []byte representing a JSON string into TokenResponse
func NewTokenResponse(j []byte) (tokenResponse *TokenResponse, err error) {
if err := json.Unmarshal(j, tokenResponse); err != nil {
return &TokenResponse{}, err
}
return tokenResponse, nil
}
// TokenClient is a type that represents a client for Google's Token Service
type TokenClient struct {
client http.Client
log logr.Logger
url string
}
// NewTokenClient is a function that creates a new Google Token Service client
func NewTokenClient(key string, log logr.Logger) (*TokenClient, error) {
if key == "" {
msg := "Firebase API Key is expected"
log.Info(msg)
return &TokenClient{}, status.Errorf(codes.InvalidArgument, msg)
}
url := fmt.Sprintf("%s?key=%s", method, key)
return &TokenClient{
client: *http.DefaultClient,
log: log,
url: url,
}, nil
}
// Token is a method that refreshes the user's ID (and refresh) tokens
func (c *TokenClient) Token(refresh_token string) (*TokenResponse, error) {
data := NewTokenRequest(refresh).Values()
resp, err := c.client.PostForm(c.url, data)
if err != nil {
return &TokenResponse{}, fmt.Errorf("unable to POST to Google Token Service")
}
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return &TokenResponse{}, fmt.Errorf("unable to read response")
}
tokenResponse, err := NewTokenResponse(body)
if err != nil {
return &TokenResponse{}, fmt.Errorf("Unable to unmarshal response into TokenResponse")
}
return tokenResponse, nil
}
This yields a straightforward implementation in the CLI:
if creds.Empty() {
// Prompt the user to open the app's Firebase Authentication page
// User provides:
// + ID token
// + Expiration
// + Refresh Token
// Save these credentials
}
if creds.Expired() {
client := authn.NewClient(...)
rqst := &pb.RefreshRequest{
RefreshToken: creds.RefreshToken,
}
resp,_ := client.Refresh(ctx, rqst)
creds := NewCredentials(resp)
// Save these credentials
}
NOTE One omission in the above is the need to time the request in order to reduce the expiration time by the elapsed time; the credentials have limited (3600s) lifespan.
Then, lastly, the credentials can be bundled into the CLI’s requests through Cloud Endpoints to be auth’d:
// Get the ID token as a `Bearer ${VALUE}`
bearer, _ := creds.Bearer()
// Update the context appending the authorization
ctx = metadata.AppendToOutgoingContext(
ctx,
"authorization", bearer,
)
// Grab a new CLI client
// The gRPC server uses an Interceptor that validates the Cloud Endpoints headers
cli := NewCLI(ctx, client, *customerID)
That’s all!