Firebase Authentication, Cloud Endpoints and gRPC (2of2)
- 6 minutes read - 1082 wordsEarlier this week, I wrote about using Firebase Authentcation, Cloud Endpoints and gRPC (1of2). Since then, I learned some more and added a gRPC interceptor to implement basic authorization for the service.
ESPv2 --allow-unauthenticated
The Cloud Enpoints (ESPv2) proxy must be run as --allow-unauthenticated
on Cloud Run to ensure that requests make it to the proxy where the request is authenticated and only authenticated requests make it on to the backend service. Thanks Google’s Teju Nareddy!
NOTE The backend service should be run with
--no-allow-authenticated
and the proxy’s service account (perhaps the default Compute Engine account) must beroles/run.invoker
per Google’s documentation or:# Get this Project's (`${PROJECT}`) number PROJECT_NUM=$(\ gcloud projects describe ${PROJECT} \ --format="value(projectNumber)") # Use this Project's number to identify # the Compute Engine service account EMAIL=${PROJECT_NUM}-compute@developer.gserviceaccount.com # Grant the service account `roles/run.invoker` gcloud run services add-iam-policy-binding ${SRV_NAME} \ --member=serviceAccount:${EMAIL} \ --role=roles/run.invoker \ --platform=managed \ --region=${REGION} \ --project=${PROJECT}
gRPC backend service
The backend service (methods) will be invoked by the Cloud Endpoints proxy and with gRPC over HTTP/2, the contents of the headers frame is surfaced through metadata. Here’s a snapshot of the metadata received by the Interceptor:
Key | Value |
---|---|
:authority |
${SRV_NAME}-[[hash]].a.run.app |
authorization |
Bearer ${JWT} |
content-length |
XX |
content-type |
application/grpc |
forwarded |
for=${IPv4};proto=https, |
for=${IPv6};proto=https |
|
scheme |
http |
traceparent |
... |
user-agent |
grpc-go/1.31.0 |
x-endpoint-api-userinfo |
${USER_INFO} |
x-client-data |
${HASH} |
x-cloud-trace-context |
… |
x-forward-authorization |
Bearer ${JWT} |
x-forwarded-for |
${IPv4}, |
${IPv6} |
|
x-forwarded-proto |
https |
x-request-id |
${UUID} |
NOTE gRPC metadata returns slices of strings. I’ve removed the
["..."]
from the above to aid clarity.
NOTE An important item of metadata is
X-Endpoint-API-UserInfo
which we’ll use in the following code.
It’s straightforward to interact with the metadata:
import (
"context"
"google.golang.org/grpc/metadata"
)
func SomeMethod(
ctx context.Context,
r *pb.SomeMethodRequest,
) (*pb.SomeMethodResponse, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
log.Fatal("unable to retrieve gRPC metadata")
}
// Get returns []string
value := md.Get(SomeKey)
...
}
But, in this case, we want to apply authorization (not authentication, that’s already done) to users of our service.
Authorization (AuthZ)
Sometimes, for convenience and to highlight the different authentication is referred to as AuthN and authorization is referred to as AuthZ. Firebase Authentication (AuthN) has enabled us to outsource all the messiness of authentication for users of myriad OAuth providers to Google Firebase which is great.
But, Firebase does not include an authorization (AuthZ) service.
NOTE Cloud Endpoints also supports Auth0. Auth0 provides authentication and authorization services. It’s possible, as my service grows that I’ll consider swapping both AuthN and AuthZ to Auth0.
I’m considering using Firestore as the backend for authorization but have not yet implemented this. Meantime, I wanted to write a very simple – the simplest possible? – authorization service:
// Users is a type that represents the system's list of users
type Users []User
// User is a type that represents one of the system's users
type User struct {
Email string
}
var (
SystemUsers = Users{
{
Email: "my@email.com",
},
}
)
// Contains is a method that determines whether a User is in Users
func (uu Users) Contains(u User) bool {
for _, x := range uu {
if x == u {
return true
}
}
return false
}
gRPC Interceptor
See Writing gRPC Interceptors in Go
Rather than apply authorization piecemeal to each gRPC server method, I’ve written a gRPC interceptor. The Interceptor intercepts every gRPC request that’s received by the server, checks whether the authenticated user included in its metadata and represented by X-Endpoint-API-UserInfo
is authorized and, if they are, then it invokes the gRPC method and returns the method’s results. If the user is not authenticated, then the interceptor returns an error without invoking the method. Clean!
func serverInteceptor(
ctx context.Context,
rqst interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
...
// Get metadata
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, fmt.Errorf("...")
}
// Metadata keys are lowercase
// Avoid md.Get()'s use of strings.ToLower(), use lowercase
if encUserInfos := md.Get("x-endpoint-api-userinfo");
len(encUserInfos) != 0 {
// Should be none or one
if len(encUserInfos) > 1 {
...
}
// Decode the header
userinfo, _ := NewUserInfo(encUserInfos[0])
// Create a User
user := NewUser(userinfo)
if user.Valid() {
// Authorize the User
if SystemUsers.Authorized(user) {
// Call the intercepted handler
// Returning its results
return handler(ctx, rqst)
}
}
// Unauthenticated or Unauthorized
return nil,
status.Errorf(codes.PermissionDenied, "permission denied")
}
I won’t labor the User
struct but UserInfo
decodes the incoming metadata header:
import (
"encoding/base64"
"encoding/json"
"fmt"
)
// UserInfo is a type that represents `X-Endpoint-API-UserToken`
// The Firebase field matches the service config auth provider
type UserInfo struct {
Name string `json:"name"`
Issuer string `json:"iss"`
Audience string `json:"aud"`
AuthNTime Time `json:"auth_time"`
UserID string `json:"user_id"`
Subject string `json:"sub"`
IssuedAt Time `json:"iat"`
ExpirationTime Time `json:"exp"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Firebase Provider `json:"firebase"`
}
// Provider is a type that represents a service config auth provider
type Provider struct {
Identities map[string][]string `json:"identities"`
Provider string `json:"sign_in_provider"`
}
func NewUserInfo(enc string) (*UserInfo, error) {
log := log.WithName("NewUserInfo")
// Value is base64url encoded
// Decode it
b, _ := base64.RawURLEncoding.DecodeString(enc)
userinfo := &UserInfo{}
if err := json.Unmarshal(b, userinfo); err != nil {
...
}
return userinfo, nil
}
NOTE
Time
isn’t Golang’stime.Time
but YellowDuck’sTime
; Golang’s default JSON unmarshaler doesn’t unmarshal UNIX epoch times correctly.
Test
Now we can deploy and test the Interceptor. Cloud Endpoints will only permit authenticated users so we want an authenticated user (i.e. someone with valid e.g. Google, Microsoft, GitHub credentials) but who isn’t authorized to use the service.
In this case, anyone other than my@email.com
is denied:
grpcurl \
-v \
-H "Authorization: Bearer ${FIREBASE_TOKEN}" \
-d "{...}" \
-proto some.proto \
${ESP_HOST}:${PORT} \
SomeService/SomeMethod
Resolved method descriptor:
rpc SomeMethod ( .v1alpha1.SomeMethodRequest )
returns ( .v1alpha1.SomeMethodResponse );
Request metadata to send:
authorization: Bearer [[JWT]]
Response headers received:
alt-svc: h3=":443"; ...
content-length: 0
content-type: application/grpc
date: Thu, 18 Jun 2021 00:00:00
server: Google Frontend
x-cloud-trace-context: ...
x-envoy-decorator-operation: ingress SomeMethod
Response trailers received:
(empty)
Sent 1 request and received 0 responses
ERROR:
Code: PermissionDenied
Message: permission denied
But, authenticating with Firebase using my@memail.com
, and passing the JWT:
grpcurl \
-v \
-H "Authorization: Bearer ${FIREBASE_TOKEN}" \
-d "{...}" \
-proto some.proto \
${ESP_HOST}:${PORT} \
SomeService/SomeMethod
Resolved method descriptor:
rpc SomeMethod ( .v1alpha1.SomeMethodRequest )
returns ( .v1alpha1.SomeMethodResponse );
Request metadata to send:
authorization: Bearer [[JWT]]
Response headers received:
alt-svc: h3=":443"; ...
content-length: 0
content-type: application/grpc
date: Thu, 18 Jun 2021 00:00:00
server: Google Frontend
x-cloud-trace-context: ...
x-envoy-decorator-operation: ingress SomeMethod
Response contents:
{
...
}
Response trailers received:
(empty)
Sent 1 request and received 1 response