Skip to content

Commit 455d0a6

Browse files
authored
Support for creating custom tokens without service account credentials (#155)
* Re-org of the JWT handling code * More tests and refactoring * Implemented IAM-based JWT signing * Added and cleaned up tests * Improved error handling * Implementing sign protocol at go/firebase-admin-sign * Updated comments * Updated changelog * Updated documentation; Added required header to metadata svc call * Added comment * Added snippets; Renamed ServiceAccount to ServiceAccountID
1 parent 3d35edb commit 455d0a6

17 files changed

+1051
-534
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Unreleased
22

3+
- [added] Implemented the ability to create custom tokens without
4+
service account credentials.
5+
- [added] Added the `ServiceAccount` field to the `firebase.Config` struct.
36
- [added] The Admin SDK can now read the Firebase/GCP project ID from
47
both `GCLOUD_PROJECT` and `GOOGLE_CLOUD_PROJECT` environment
58
variables.

auth/auth.go

Lines changed: 101 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
package auth
1717

1818
import (
19-
"crypto/rsa"
2019
"encoding/json"
2120
"errors"
2221
"fmt"
@@ -58,34 +57,15 @@ type Token struct {
5857
Claims map[string]interface{} `json:"-"`
5958
}
6059

61-
func (t *Token) decodeFrom(s string) error {
62-
// Decode into a regular map to access custom claims.
63-
claims := make(map[string]interface{})
64-
if err := decode(s, &claims); err != nil {
65-
return err
66-
}
67-
// Now decode into Token to access the standard claims.
68-
if err := decode(s, t); err != nil {
69-
return err
70-
}
71-
72-
// Delete standard claims from the custom claims maps.
73-
for _, r := range []string{"iss", "aud", "exp", "iat", "sub", "uid"} {
74-
delete(claims, r)
75-
}
76-
t.Claims = claims
77-
return nil
78-
}
79-
8060
// Client is the interface for the Firebase auth service.
8161
//
8262
// Client facilitates generating custom JWT tokens for Firebase clients, and verifying ID tokens issued
8363
// by Firebase backend services.
8464
type Client struct {
8565
is *identitytoolkit.Service
86-
ks keySource
66+
keySource keySource
8767
projectID string
88-
snr signer
68+
signer cryptoSigner
8969
version string
9070
}
9171

@@ -98,40 +78,45 @@ type signer interface {
9878
//
9979
// This function can only be invoked from within the SDK. Client applications should access the
10080
// Auth service through firebase.App.
101-
func NewClient(ctx context.Context, c *internal.AuthConfig) (*Client, error) {
81+
func NewClient(ctx context.Context, conf *internal.AuthConfig) (*Client, error) {
10282
var (
103-
err error
104-
email string
105-
pk *rsa.PrivateKey
83+
signer cryptoSigner
84+
err error
10685
)
107-
if c.Creds != nil && len(c.Creds.JSON) > 0 {
108-
var svcAcct struct {
109-
ClientEmail string `json:"client_email"`
110-
PrivateKey string `json:"private_key"`
111-
}
112-
if err := json.Unmarshal(c.Creds.JSON, &svcAcct); err != nil {
86+
// Initialize a signer by following the go/firebase-admin-sign protocol.
87+
if conf.Creds != nil && len(conf.Creds.JSON) > 0 {
88+
// If the SDK was initialized with a service account, use it to sign bytes.
89+
var sa serviceAccount
90+
if err = json.Unmarshal(conf.Creds.JSON, &sa); err != nil {
11391
return nil, err
11492
}
115-
if svcAcct.PrivateKey != "" {
116-
pk, err = parsePrivateKey(svcAcct.PrivateKey)
93+
if sa.PrivateKey != "" && sa.ClientEmail != "" {
94+
var err error
95+
signer, err = newServiceAccountSigner(sa)
11796
if err != nil {
11897
return nil, err
11998
}
12099
}
121-
email = svcAcct.ClientEmail
122100
}
123-
124-
var snr signer
125-
if email != "" && pk != nil {
126-
snr = serviceAcctSigner{email: email, pk: pk}
127-
} else {
128-
snr, err = newSigner(ctx)
129-
if err != nil {
130-
return nil, err
101+
if signer == nil {
102+
if conf.ServiceAccountID != "" {
103+
// If the SDK was initialized with a service account email, use it with the IAM service
104+
// to sign bytes.
105+
signer, err = newIAMSigner(ctx, conf)
106+
if err != nil {
107+
return nil, err
108+
}
109+
} else {
110+
// Use GAE signing capabilities if available. Otherwise, obtain a service account email
111+
// from the local Metadata service, and fallback to the IAM service.
112+
signer, err = newCryptoSigner(ctx, conf)
113+
if err != nil {
114+
return nil, err
115+
}
131116
}
132117
}
133118

134-
hc, _, err := transport.NewHTTPClient(ctx, c.Opts...)
119+
hc, _, err := transport.NewHTTPClient(ctx, conf.Opts...)
135120
if err != nil {
136121
return nil, err
137122
}
@@ -143,25 +128,40 @@ func NewClient(ctx context.Context, c *internal.AuthConfig) (*Client, error) {
143128

144129
return &Client{
145130
is: is,
146-
ks: newHTTPKeySource(idTokenCertURL, hc),
147-
projectID: c.ProjectID,
148-
snr: snr,
149-
version: "Go/Admin/" + c.Version,
131+
keySource: newHTTPKeySource(idTokenCertURL, hc),
132+
projectID: conf.ProjectID,
133+
signer: signer,
134+
version: "Go/Admin/" + conf.Version,
150135
}, nil
151136
}
152137

153-
// CustomToken creates a signed custom authentication token with the specified user ID. The resulting
154-
// JWT can be used in a Firebase client SDK to trigger an authentication flow. See
138+
// CustomToken creates a signed custom authentication token with the specified user ID.
139+
//
140+
// The resulting JWT can be used in a Firebase client SDK to trigger an authentication flow. See
155141
// https://firebase.google.com/docs/auth/admin/create-custom-tokens#sign_in_using_custom_tokens_on_clients
156142
// for more details on how to use custom tokens for client authentication.
143+
//
144+
// CustomToken follows the protocol outlined below to sign the generated tokens:
145+
// - If the SDK was initialized with service account credentials, uses the private key present in
146+
// the credentials to sign tokens locally.
147+
// - If a service account email was specified during initialization (via firebase.Config struct),
148+
// calls the IAM service with that email to sign tokens remotely. See
149+
// https://cloud.google.com/iam/reference/rest/v1/projects.serviceAccounts/signBlob.
150+
// - If the code is deployed in the Google App Engine standard environment, uses the App Identity
151+
// service to sign tokens. See https://cloud.google.com/appengine/docs/standard/go/reference#SignBytes.
152+
// - If the code is deployed in a different GCP-managed environment (e.g. Google Compute Engine),
153+
// uses the local Metadata server to auto discover a service account email. This is used in
154+
// conjunction with the IAM service to sign tokens remotely.
155+
//
156+
// CustomToken returns an error the SDK fails to discover a viable mechanism for signing tokens.
157157
func (c *Client) CustomToken(ctx context.Context, uid string) (string, error) {
158158
return c.CustomTokenWithClaims(ctx, uid, nil)
159159
}
160160

161161
// CustomTokenWithClaims is similar to CustomToken, but in addition to the user ID, it also encodes
162162
// all the key-value pairs in the provided map as claims in the resulting JWT.
163163
func (c *Client) CustomTokenWithClaims(ctx context.Context, uid string, devClaims map[string]interface{}) (string, error) {
164-
iss, err := c.snr.Email(ctx)
164+
iss, err := c.signer.Email(ctx)
165165
if err != nil {
166166
return "", err
167167
}
@@ -183,29 +183,19 @@ func (c *Client) CustomTokenWithClaims(ctx context.Context, uid string, devClaim
183183
}
184184

185185
now := clk.Now().Unix()
186-
header := jwtHeader{Algorithm: "RS256", Type: "JWT"}
187-
payload := &customToken{
188-
Iss: iss,
189-
Sub: iss,
190-
Aud: firebaseAudience,
191-
UID: uid,
192-
Iat: now,
193-
Exp: now + tokenExpSeconds,
194-
Claims: devClaims,
186+
info := &jwtInfo{
187+
header: jwtHeader{Algorithm: "RS256", Type: "JWT"},
188+
payload: &customToken{
189+
Iss: iss,
190+
Sub: iss,
191+
Aud: firebaseAudience,
192+
UID: uid,
193+
Iat: now,
194+
Exp: now + tokenExpSeconds,
195+
Claims: devClaims,
196+
},
195197
}
196-
return encodeToken(ctx, c.snr, header, payload)
197-
}
198-
199-
// RevokeRefreshTokens revokes all refresh tokens issued to a user.
200-
//
201-
// RevokeRefreshTokens updates the user's TokensValidAfterMillis to the current UTC second.
202-
// It is important that the server on which this is called has its clock set correctly and synchronized.
203-
//
204-
// While this revokes all sessions for a specified user and disables any new ID tokens for existing sessions
205-
// from getting minted, existing ID tokens may remain active until their natural expiration (one hour).
206-
// To verify that ID tokens are revoked, use `verifyIdTokenAndCheckRevoked(ctx, idToken)`.
207-
func (c *Client) RevokeRefreshTokens(ctx context.Context, uid string) error {
208-
return c.updateUser(ctx, uid, (&UserToUpdate{}).revokeRefreshTokens())
198+
return info.Token(ctx, c.signer)
209199
}
210200

211201
// VerifyIDToken verifies the signature and payload of the provided ID token.
@@ -224,11 +214,30 @@ func (c *Client) VerifyIDToken(ctx context.Context, idToken string) (*Token, err
224214
return nil, fmt.Errorf("id token must be a non-empty string")
225215
}
226216

227-
h := &jwtHeader{}
228-
p := &Token{}
229-
if err := decodeToken(ctx, idToken, c.ks, h, p); err != nil {
217+
if err := verifyToken(ctx, idToken, c.keySource); err != nil {
218+
return nil, err
219+
}
220+
segments := strings.Split(idToken, ".")
221+
222+
var (
223+
header jwtHeader
224+
payload Token
225+
claims map[string]interface{}
226+
)
227+
if err := decode(segments[0], &header); err != nil {
228+
return nil, err
229+
}
230+
if err := decode(segments[1], &payload); err != nil {
231+
return nil, err
232+
}
233+
if err := decode(segments[1], &claims); err != nil {
230234
return nil, err
231235
}
236+
// Delete standard claims from the custom claims maps.
237+
for _, r := range []string{"iss", "aud", "exp", "iat", "sub", "uid"} {
238+
delete(claims, r)
239+
}
240+
payload.Claims = claims
232241

233242
projectIDMsg := "make sure the ID token comes from the same Firebase project as the credential used to" +
234243
" authenticate this SDK"
@@ -237,36 +246,36 @@ func (c *Client) VerifyIDToken(ctx context.Context, idToken string) (*Token, err
237246
issuer := issuerPrefix + c.projectID
238247

239248
var err error
240-
if h.KeyID == "" {
241-
if p.Audience == firebaseAudience {
249+
if header.KeyID == "" {
250+
if payload.Audience == firebaseAudience {
242251
err = fmt.Errorf("expected an ID token but got a custom token")
243252
} else {
244253
err = fmt.Errorf("ID token has no 'kid' header")
245254
}
246-
} else if h.Algorithm != "RS256" {
255+
} else if header.Algorithm != "RS256" {
247256
err = fmt.Errorf("ID token has invalid algorithm; expected 'RS256' but got %q; %s",
248-
h.Algorithm, verifyTokenMsg)
249-
} else if p.Audience != c.projectID {
257+
header.Algorithm, verifyTokenMsg)
258+
} else if payload.Audience != c.projectID {
250259
err = fmt.Errorf("ID token has invalid 'aud' (audience) claim; expected %q but got %q; %s; %s",
251-
c.projectID, p.Audience, projectIDMsg, verifyTokenMsg)
252-
} else if p.Issuer != issuer {
260+
c.projectID, payload.Audience, projectIDMsg, verifyTokenMsg)
261+
} else if payload.Issuer != issuer {
253262
err = fmt.Errorf("ID token has invalid 'iss' (issuer) claim; expected %q but got %q; %s; %s",
254-
issuer, p.Issuer, projectIDMsg, verifyTokenMsg)
255-
} else if p.IssuedAt > clk.Now().Unix() {
256-
err = fmt.Errorf("ID token issued at future timestamp: %d", p.IssuedAt)
257-
} else if p.Expires < clk.Now().Unix() {
258-
err = fmt.Errorf("ID token has expired at: %d", p.Expires)
259-
} else if p.Subject == "" {
263+
issuer, payload.Issuer, projectIDMsg, verifyTokenMsg)
264+
} else if payload.IssuedAt > clk.Now().Unix() {
265+
err = fmt.Errorf("ID token issued at future timestamp: %d", payload.IssuedAt)
266+
} else if payload.Expires < clk.Now().Unix() {
267+
err = fmt.Errorf("ID token has expired at: %d", payload.Expires)
268+
} else if payload.Subject == "" {
260269
err = fmt.Errorf("ID token has empty 'sub' (subject) claim; %s", verifyTokenMsg)
261-
} else if len(p.Subject) > 128 {
270+
} else if len(payload.Subject) > 128 {
262271
err = fmt.Errorf("ID token has a 'sub' (subject) claim longer than 128 characters; %s", verifyTokenMsg)
263272
}
264273

265274
if err != nil {
266275
return nil, err
267276
}
268-
p.UID = p.Subject
269-
return p, nil
277+
payload.UID = payload.Subject
278+
return &payload, nil
270279
}
271280

272281
// VerifyIDTokenAndCheckRevoked verifies the provided ID token and checks it has not been revoked.

auth/auth_appengine.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,23 +17,23 @@
1717
package auth
1818

1919
import (
20+
"firebase.google.com/go/internal"
2021
"golang.org/x/net/context"
2122

2223
"google.golang.org/appengine"
2324
)
2425

25-
type aeSigner struct {
26-
}
26+
type aeSigner struct{}
2727

28-
func newSigner(ctx context.Context) (signer, error) {
28+
func newCryptoSigner(ctx context.Context, conf *internal.AuthConfig) (cryptoSigner, error) {
2929
return aeSigner{}, nil
3030
}
3131

3232
func (s aeSigner) Email(ctx context.Context) (string, error) {
3333
return appengine.ServiceAccount(ctx)
3434
}
3535

36-
func (s aeSigner) Sign(ctx context.Context, ss []byte) ([]byte, error) {
37-
_, sig, err := appengine.SignBytes(ctx, ss)
36+
func (s aeSigner) Sign(ctx context.Context, b []byte) ([]byte, error) {
37+
_, sig, err := appengine.SignBytes(ctx, b)
3838
return sig, err
3939
}

auth/auth_std.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@
1616

1717
package auth // import "firebase.google.com/go/auth"
1818

19-
import "golang.org/x/net/context"
19+
import (
20+
"firebase.google.com/go/internal"
21+
"golang.org/x/net/context"
22+
)
2023

21-
func newSigner(ctx context.Context) (signer, error) {
22-
return serviceAcctSigner{}, nil
24+
func newCryptoSigner(ctx context.Context, conf *internal.AuthConfig) (cryptoSigner, error) {
25+
return newIAMSigner(ctx, conf)
2326
}

0 commit comments

Comments
 (0)