blob: b4220f0735a3d43c13f0361ac7cf27b694e6b05f [file] [log] [blame]
// Copyright 2019 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package authctx allows to run subprocesses in an environment with ambient
// auth.
//
// Supports setting up an auth context for LUCI tools, gsutil and gcloud, Git,
// Docker and Firebase.
//
// Git auth depends on presence of Git wrapper and git-credential-luci in PATH.
// Docker auth depends on presence of docker-credential-luci in PATH.
package authctx
import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"net"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/common/logging"
"go.chromium.org/luci/common/system/environ"
"go.chromium.org/luci/lucictx"
"go.chromium.org/luci/auth"
"go.chromium.org/luci/auth/integration/devshell"
"go.chromium.org/luci/auth/integration/firebase"
"go.chromium.org/luci/auth/integration/gcemeta"
"go.chromium.org/luci/auth/integration/gsutil"
"go.chromium.org/luci/auth/integration/localauth"
)
// Context knows how to prepare an environment with ambient authentication for
// various tools: LUCI, gsutil, Docker, Git, Firebase.
//
// 'Launch' launches a bunch of local HTTP servers and writes a bunch of
// configuration files that point to these servers. 'Export' then exposes
// location of these configuration files to subprocesses, so they can discover
// local HTTP servers and use them to mint tokens.
type Context struct {
// ID is used in logs, filenames and in LUCI_CONTEXT (if we launch a new one).
//
// Usually a logical account name associated with this context, e.g. "task" or
// "system".
ID string
// Options define how to build the root authenticator.
//
// This authenticator (perhaps indirectly through LUCI_CONTEXT created in
// 'Launch') will be used by all other auth helpers to grab access tokens.
//
// If Options.Method is LUCIContextMethod, indicating there's some existing
// LUCI_CONTEXT with "local_auth" section we should use, and service account
// impersonation is not requested (Options.ActAsServiceAccount == "") the
// existing LUCI_CONTEXT is reused. Otherwise launches a new local_auth server
// (that uses given auth options to mint tokens) and puts its location into
// the new LUCI_CONTEXT. Either way, subprocesses launched with an environment
// modified by 'Export' will see a functional LUCI_CONTEXT.
//
// When reusing an existing LUCI_CONTEXT, subprocesses inherit all OAuth
// scopes permissible there.
Options auth.Options
// EnableGitAuth enables authentication for Git subprocesses.
//
// Assumes 'git' binary is actually gitwrapper and that 'git-credential-luci'
// binary is in PATH.
//
// Requires "https://www.googleapis.com/auth/gerritcodereview" OAuth scope.
EnableGitAuth bool
// EnableDockerAuth enables authentication for Docker.
//
// Assumes 'docker-credential-luci' is in PATH.
//
// Requires Google Storage OAuth scopes. See GCR docs for more info.
EnableDockerAuth bool
// EnableDevShell enables DevShell server and gsutil auth shim.
//
// They are used to make gsutil and gcloud use LUCI authentication.
//
// On Windows only gsutil auth shim is enabled, since enabling DevShell there
// triggers bugs in gsutil. See https://crbug.com/788058#c14.
//
// Requires Google Storage OAuth scopes. See GS docs for more info.
//
// TODO(vadimsh): Delete this method if EnableGCEEmulation works everywhere.
EnableDevShell bool
// EnableGCEEmulation enables emulation of GCE instance environment.
//
// Overrides EnableDevShell if used. Will likely completely replace
// EnableDevShell in the near future.
//
// It does multiple things by setting environment variables and writing config
// files:
// * Creates new empty CLOUDSDK_CONFIG directory, to make sure we don't
// reuse existing gcloud cache.
// * Creates new BOTO_CONFIG, telling gsutil to use new empty state dir.
// * Launches a local server that imitates GCE metadata server.
// * Tells gcloud, gsutil and various Go and Python libraries to use this
// server by setting env vars like GCE_METADATA_HOST (and a bunch more).
//
// This tricks gcloud, gsutil and various Go and Python libraries that use
// Application Default Credentials into believing they run on GCE so that
// they request OAuth2 tokens via GCE metadata server (which is implemented by
// us).
//
// This is not a foolproof way: nothing prevents clients from ignoring env
// vars and hitting metadata.google.internal directly. But most clients
// respect env vars we set.
EnableGCEEmulation bool
// EnableFirebaseAuth enables Firebase auth shim.
//
// It is used to make Firebase use LUCI authentication.
//
// Requires "https://www.googleapis.com/auth/firebase" OAuth scope.
EnableFirebaseAuth bool
// KnownGerritHosts is list of Gerrit hosts to force git authentication for.
//
// By default public hosts are accessed anonymously, and the anonymous access
// has very low quota. Context needs to know all such hostnames in advance to
// be able to force authenticated access to them.
KnownGerritHosts []string
localAuth *lucictx.LocalAuth // non-nil when running localauth.Server
tmpDir string // non empty if we created a new temp dir
authenticator *auth.Authenticator // used by in-process helpers
anonymous bool // true if not associated with any account
email string // an account email or "" for anonymous
luciSrv *localauth.Server // non-nil if we launched a LUCI_CONTEXT subcontext
gitHome string // custom HOME for git or "" if not using git auth
dockerConfig string // location for Docker configuration files
dockerTmpDir string // location for Docker temporary files
gsutilSrv *gsutil.Server // gsutil auth shim server
// Note: these fields are used in both EnableGCEEmulation and EnableDevShell
// modes.
gsutilState string // path to a context-managed state directory
gsutilBoto string // path to a generated .boto file
devShellSrv *devshell.Server // DevShell server instance
devShellAddr *net.TCPAddr // address local DevShell instance is listening on
gcemetaSrv *gcemeta.Server // fake GCE metadata server
gcemetaAddr string // "host:port" address of the fake metadata server
gcloudConfDir string // directory with gcloud configs
firebaseSrv *firebase.Server // firebase auth shim server
firebaseTokenURL string // URL to get firebase auth token from
}
// Launch launches this auth context. It must be called before any other method.
//
// It launches various local server and prepares various configs, by putting
// them into tempDir which may be "" to use some new ioutil.TempDir.
//
// The given context.Context is used for logging and to pick up the initial
// ambient authentication (per auth.NewAuthenticator contract, see its docs).
//
// To run a subprocess within this new auth context use 'Export' to modify
// an environ for a new process.
func (ac *Context) Launch(ctx context.Context, tempDir string) (err error) {
// EnableGCEEmulation provides a superset of EnableDevShell features. No need
// to have both enabled at the same time (they also conflict with each other).
if ac.EnableGCEEmulation {
ac.EnableDevShell = false
}
defer func() {
if err != nil {
ac.Close(ctx)
}
}()
if tempDir == "" {
ac.tmpDir, err = ioutil.TempDir("", "luci")
if err != nil {
return errors.Annotate(err, "failed to create a temp directory").Err()
}
tempDir = ac.tmpDir
}
// Expand AuthSelectMethod into the actual method. We'll be checking it later.
// We do this expansion now to consistently use new 'opts' through out.
opts := ac.Options
if opts.Method == auth.AutoSelectMethod {
opts.Method = auth.SelectBestMethod(ctx, opts)
}
// Construct the authenticator to be used directly by the helpers hosted in
// the current process (devshell, gsutil, firebase) and by the new
// localauth.Server (if we are going to launch it). Out-of-process helpers
// (git, docker) will use LUCI_CONTEXT protocol.
ac.authenticator = auth.NewAuthenticator(ctx, auth.SilentLogin, opts)
// Figure out what email is associated with this account (if any).
ac.email, err = ac.authenticator.GetEmail()
switch {
case err == auth.ErrLoginRequired:
// This context is not associated with any account. This happens when
// running Swarming tasks without service account specified or running
// locally without doing 'luci-auth login' first.
ac.anonymous = true
case err != nil:
return errors.Annotate(err, "failed to get email of %q account", ac.ID).Err()
}
// Check whether we are allowed to inherit the existing LUCI_CONTEXT. We do it
// if 'opts' indicate to use LUCI_CONTEXT and do NOT use impersonation. When
// impersonating, we must launch a new auth server to actually perform it
// there.
//
// If we can't reuse the existing LUCI_CONTEXT, launch a new one (deriving
// a new context.Context with it).
//
// If there's no auth credentials at all, do not launch any LUCI_CONTEXT (it
// is impossible without credentials). Subprocesses will discover lack of
// ambient credentials on their own and fail (or proceed) appropriately.
canInherit := opts.Method == auth.LUCIContextMethod && opts.ActAsServiceAccount == ""
if !canInherit && !ac.anonymous {
if ac.luciSrv, ac.localAuth, err = launchSrv(ctx, opts, ac.authenticator, ac.ID); err != nil {
return errors.Annotate(err, "failed to launch local auth server for %q account", ac.ID).Err()
}
}
// Now setup various credential helpers (they all mutate 'ac' and return
// annotated errors).
if ac.EnableGitAuth {
if err := ac.setupGitAuth(tempDir); err != nil {
return err
}
}
if ac.EnableDockerAuth {
if err := ac.setupDockerAuth(tempDir); err != nil {
return err
}
}
if ac.EnableDevShell && !ac.anonymous {
if err := ac.setupDevShellAuth(ctx, tempDir); err != nil {
return err
}
}
if ac.EnableGCEEmulation {
if err := ac.setupGCEEmulationAuth(ctx, tempDir); err != nil {
return err
}
}
if ac.EnableFirebaseAuth && !ac.anonymous {
if err := ac.setupFirebaseAuth(ctx); err != nil {
return err
}
}
return nil
}
// Close stops this context, cleaning up after it.
//
// The given context.Context is used for deadlines and for logging.
//
// The auth context is not usable after this call. Logs errors inside (there's
// nothing caller can do about them anyway).
func (ac *Context) Close(ctx context.Context) {
// Stop all the servers in parallel.
wg := sync.WaitGroup{}
stop := func(what string, srv interface{ Stop(context.Context) error }) {
wg.Add(1)
go func() {
defer wg.Done()
if err := srv.Stop(ctx); err != nil {
logging.Errorf(ctx, "Failed to stop %s server for %q account: %s", what, ac.ID, err)
}
}()
}
// Note: can't move != nil check into stop(...) because 'srv' becomes
// a "typed nil interface", which is not nil itself.
if ac.luciSrv != nil {
stop("local auth", ac.luciSrv)
}
if ac.gsutilSrv != nil {
stop("gsutil shim", ac.gsutilSrv)
}
if ac.devShellSrv != nil {
stop("devshell", ac.devShellSrv)
}
if ac.gcemetaSrv != nil {
stop("fake GCE metadata server", ac.gcemetaSrv)
}
if ac.firebaseSrv != nil {
stop("firebase shim", ac.firebaseSrv)
}
wg.Wait()
// Cleanup the rest of the garbage.
cleanup := func(what, where string) {
if where != "" {
if err := os.RemoveAll(where); err != nil {
logging.Errorf(ctx, "Failed to clean up %s for %q account at [%s]: %s", what, ac.ID, where, err)
}
}
}
cleanup("git HOME", ac.gitHome)
cleanup("gsutil state", ac.gsutilState)
cleanup("gcloud config dir", ac.gcloudConfDir)
cleanup("docker configs", ac.dockerConfig)
cleanup("docker temp dir", ac.dockerTmpDir)
cleanup("created temp dir", ac.tmpDir)
// And finally reset the state as if nothing happened.
ac.localAuth = nil
ac.tmpDir = ""
ac.authenticator = nil
ac.anonymous = false
ac.email = ""
ac.luciSrv = nil
ac.gitHome = ""
ac.dockerConfig = ""
ac.dockerTmpDir = ""
ac.gsutilSrv = nil
ac.gsutilState = ""
ac.gsutilBoto = ""
ac.devShellSrv = nil
ac.devShellAddr = nil
ac.gcemetaSrv = nil
ac.gcemetaAddr = ""
ac.gcloudConfDir = ""
ac.firebaseSrv = nil
ac.firebaseTokenURL = ""
}
// Authenticator returns an authenticator used by this context.
//
// It is the one constructed from Options. It is safe to use it directly.
func (ac *Context) Authenticator() *auth.Authenticator {
return ac.authenticator
}
// Export exports details of this context into the environment, so it can
// be inherited by subprocesses that support it.
//
// It does two inter-dependent things:
// 1. Updates LUCI_CONTEXT in 'ctx' so that LUCI tools can use the local
// token server.
// 2. Mutates 'env' so that various third party tools can also use local
// tokens.
//
// To successfully launch a subprocess, LUCI_CONTEXT in returned context.Context
// *must* be exported into 'env' (e.g. via lucictx.Export(...) followed by
// SetInEnviron).
func (ac *Context) Export(ctx context.Context, env environ.Env) context.Context {
// Mutate LUCI_CONTEXT to use localauth.Server{...} launched by us (if any).
if ac.localAuth != nil {
ctx = lucictx.SetLocalAuth(ctx, ac.localAuth)
}
if ac.EnableGitAuth {
env.Set("GIT_TERMINAL_PROMPT", "0") // no interactive prompts
env.Set("GIT_CONFIG_NOSYSTEM", "1") // no $(prefix)/etc/gitconfig
env.Set("INFRA_GIT_WRAPPER_HOME", ac.gitHome) // tell gitwrapper about the new HOME
}
if ac.EnableDockerAuth {
env.Set("DOCKER_CONFIG", ac.dockerConfig)
env.Set("DOCKER_TMPDIR", ac.dockerTmpDir)
}
if ac.EnableDevShell && !ac.anonymous {
if ac.devShellAddr != nil {
env.Set(devshell.EnvKey, fmt.Sprintf("%d", ac.devShellAddr.Port))
} else {
// See https://crbug.com/788058#c14.
logging.Warningf(ctx, "Disabling devshell auth for account %q", ac.ID)
}
}
if ac.EnableGCEEmulation {
env.Set("CLOUDSDK_CONFIG", ac.gcloudConfDir)
if !ac.anonymous {
// Used by google.auth.compute_engine Python library to grab tokens.
env.Set("GCE_METADATA_ROOT", ac.gcemetaAddr)
// Used by google.auth.compute_engine Python library to "ping" metadata srv.
env.Set("GCE_METADATA_IP", ac.gcemetaAddr)
// Used by cloud.google.com/go/compute/metadata Go library.
env.Set("GCE_METADATA_HOST", ac.gcemetaAddr)
}
}
// Prepare .boto configs if faking Cloud in some way. Do it even if running
// anonymously, since in this case we want to switch gsutil to run in
// anonymous mode as well (by forbidding it to use default ~/.boto that may
// have some credential in it).
if ac.EnableDevShell || ac.EnableGCEEmulation {
// Note: gsutilBoto may be empty here if running anonymously in DevShell
// mode. This is fine, it tells gsutil not to use default ~/.boto.
env.Set("BOTO_CONFIG", ac.gsutilBoto)
env.Remove("BOTO_PATH")
}
if ac.EnableFirebaseAuth && !ac.anonymous {
// This env var is supposed to contain a refresh token. Its presence
// switches Firebase into "CI mode" where it doesn't try to grab credentials
// from disk or via gcloud. The actual value doesn't matter, since we
// replace the endpoint that consumes this token below.
env.Set("FIREBASE_TOKEN", "ignored-non-empty-value")
// Instruct Firebase to use the local server for "refreshing" the token.
// Usually this is "https://www.googleapis.com" and it takes a refresh token
// and returns an access token. We replace it with a local version that
// just returns task account access tokens.
env.Set("FIREBASE_TOKEN_URL", ac.firebaseTokenURL)
}
return ctx
}
// Report logs the service account email used by this auth context.
func (ac *Context) Report(ctx context.Context) {
account := ac.email
if ac.anonymous {
account = "anonymous"
}
logging.Infof(ctx,
"%q account is %s (git_auth: %v, devshell: %v, emulate_gce:%v, docker:%v, firebase: %v)",
ac.ID, account, ac.EnableGitAuth, ac.EnableDevShell, ac.EnableGCEEmulation,
ac.EnableDockerAuth, ac.EnableFirebaseAuth)
}
////////////////////////////////////////////////////////////////////////////////
// launchSrv launches new localauth.Server that serves LUCI_CONTEXT protocol.
//
// Returns the server itself (so it can be stopped) and also LocalAuth section
// that can be put into LUCI_CONTEXT to make subprocesses use the server.
func launchSrv(ctx context.Context, opts auth.Options, athn *auth.Authenticator, accID string) (srv *localauth.Server, la *lucictx.LocalAuth, err error) {
// Two cases here:
// 1) We are using options that specify service account private key or
// IAM-based authenticator (with IAM refresh token just initialized
// above). In this case we can mint tokens for any requested combination
// of scopes and can use NewFlexibleGenerator.
// 2) We are using options that specify some externally configured
// authenticator (like GCE metadata server, or a refresh token). In this
// case we have to use this specific authenticator for generating tokens.
var gen localauth.TokenGenerator
if auth.AllowsArbitraryScopes(ctx, opts) {
logging.Debugf(ctx, "Using flexible token generator: %s (acting as %q)", opts.Method, opts.ActAsServiceAccount)
gen, err = localauth.NewFlexibleGenerator(ctx, opts)
} else {
// An authenticator preconfigured with given list of scopes.
logging.Debugf(ctx, "Using rigid token generator: %s (scopes %s)", opts.Method, opts.Scopes)
gen, err = localauth.NewRigidGenerator(ctx, athn)
}
if err != nil {
return
}
// We currently always setup a context with one account (which is also
// default). It means if we override some existing LUCI_CONTEXT, all
// non-default accounts there are "forgotten".
srv = &localauth.Server{
TokenGenerators: map[string]localauth.TokenGenerator{
accID: gen,
},
DefaultAccountID: accID,
}
la, err = srv.Start(ctx)
return
}
func (ac *Context) setupGitAuth(tempDir string) error {
ac.gitHome = filepath.Join(tempDir, "git-home-"+ac.ID)
if err := os.Mkdir(ac.gitHome, 0700); err != nil {
return errors.Annotate(err, "failed to create git HOME for %q account at %s", ac.ID, ac.gitHome).Err()
}
if err := ac.writeGitConfig(); err != nil {
return errors.Annotate(err, "failed to setup .gitconfig for %q account", ac.ID).Err()
}
return nil
}
func (ac *Context) writeGitConfig() error {
var cfg gitConfig
if !ac.anonymous {
cfg = gitConfig{
IsWindows: runtime.GOOS == "windows",
UserEmail: ac.email,
UserName: strings.Split(ac.email, "@")[0],
UseCredentialHelper: true,
KnownGerritHosts: ac.KnownGerritHosts,
}
} else {
cfg = gitConfig{
IsWindows: runtime.GOOS == "windows",
UserEmail: "anonymous@example.com", // otherwise git doesn't work
UserName: "anonymous",
UseCredentialHelper: false, // fetch will be anonymous, push will fail
KnownGerritHosts: nil, // don't force non-anonymous fetch for public hosts
}
}
return cfg.Write(filepath.Join(ac.gitHome, ".gitconfig"))
}
func (ac *Context) setupDockerAuth(tempDir string) error {
ac.dockerConfig = filepath.Join(tempDir, "docker-cfg-"+ac.ID)
if err := os.Mkdir(ac.dockerConfig, 0700); err != nil {
return errors.Annotate(err, "failed to create Docker configuration directory for %q account at %s", ac.ID, ac.dockerConfig).Err()
}
if err := ac.writeDockerConfig(); err != nil {
return errors.Annotate(err, "failed to create config.json for %q account", ac.ID).Err()
}
ac.dockerTmpDir = filepath.Join(tempDir, "docker-tmp-"+ac.ID)
if err := os.Mkdir(ac.dockerTmpDir, 0700); err != nil {
return errors.Annotate(err, "failed to create Docker temporary directory for %q account at %s", ac.ID, ac.dockerTmpDir).Err()
}
return nil
}
func (ac *Context) writeDockerConfig() error {
f, err := os.Create(filepath.Join(ac.dockerConfig, "config.json"))
if err != nil {
return err
}
defer f.Close()
config := map[string]map[string]string{
"credHelpers": {
"us.gcr.io": "luci",
"staging-k8s.gcr.io": "luci",
"asia.gcr.io": "luci",
"gcr.io": "luci",
"marketplace.gcr.io": "luci",
"eu.gcr.io": "luci",
},
}
if err := json.NewEncoder(f).Encode(&config); err != nil {
return errors.Annotate(err, "cannot encode configuration").Err()
}
return f.Close()
}
func (ac *Context) setupDevShellAuth(ctx context.Context, tempDir string) error {
source, err := ac.authenticator.TokenSource()
if err != nil {
return errors.Annotate(err, "failed to get token source for %q account", ac.ID).Err()
}
// The directory for .boto and gsutil credentials cache (including access
// tokens).
ac.gsutilState = filepath.Join(tempDir, "gsutil-"+ac.ID)
if err := os.Mkdir(ac.gsutilState, 0700); err != nil {
return errors.Annotate(err, "failed to create gsutil state dir for %q account at %s", ac.ID, ac.gsutilState).Err()
}
// Launch gsutil auth shim server. It will put a specially constructed .boto
// into gsutilState dir (and return path to it).
ac.gsutilSrv = &gsutil.Server{
Source: source,
StateDir: ac.gsutilState,
}
if ac.gsutilBoto, err = ac.gsutilSrv.Start(ctx); err != nil {
return errors.Annotate(err, "failed to start gsutil auth shim server for %q account", ac.ID).Err()
}
// Presence of DevShell env var breaks gsutil on Windows. Luckily, we rarely
// need to use gcloud in Windows, and gsutil (which we do use on Windows
// extensively) is covered by gsutil auth shim server setup above.
if runtime.GOOS != "windows" {
ac.devShellSrv = &devshell.Server{
Source: source,
Email: ac.email,
}
if ac.devShellAddr, err = ac.devShellSrv.Start(ctx); err != nil {
return errors.Annotate(err, "failed to start the DevShell server").Err()
}
}
return nil
}
func (ac *Context) setupGCEEmulationAuth(ctx context.Context, tempDir string) error {
// Launch the fake GCE metadata server.
botoGCEAccount := ""
if !ac.anonymous {
source, err := ac.authenticator.TokenSource()
if err != nil {
return errors.Annotate(err, "failed to get token source for %q account", ac.ID).Err()
}
ac.gcemetaSrv = &gcemeta.Server{
Source: source,
Email: ac.email,
Scopes: ac.Options.Scopes,
}
if ac.gcemetaAddr, err = ac.gcemetaSrv.Start(ctx); err != nil {
return errors.Annotate(err, "failed to start fake GCE metadata server for %q account", ac.ID).Err()
}
botoGCEAccount = "default" // switch .boto to use GCE auth
}
// Prepare clean gcloud config, otherwise gcloud will reuse cached "is on GCE"
// value from ~/.config/gcloud/gce and will not bother contacting the fake GCE
// metadata server on non-GCE machines. Additionally in anonymous mode we
// want to avoid using any cached credentials (also stored in the default
// ~/.config/gcloud/...).
ac.gcloudConfDir = filepath.Join(tempDir, "gcloud-"+ac.ID)
if err := os.Mkdir(ac.gcloudConfDir, 0700); err != nil {
return errors.Annotate(err, "failed to create gcloud config dir for %q account at %s", ac.ID, ac.gcloudConfDir).Err()
}
// The directory for .boto and gsutil credentials cache. We need to replace it
// to tell gsutil NOT to use whatever tokens it had cached in the default
// ~/.gsutil/... state dir.
var err error
ac.gsutilState = filepath.Join(tempDir, "gsutil-"+ac.ID)
ac.gsutilBoto, err = gsutil.PrepareStateDir(&gsutil.Boto{
StateDir: ac.gsutilState,
GCEServiceAccount: botoGCEAccount, // may be "" in anonymous mode
})
return errors.Annotate(err, "failed to setup .boto for %q account", ac.ID).Err()
}
func (ac *Context) setupFirebaseAuth(ctx context.Context) error {
source, err := ac.authenticator.TokenSource()
if err != nil {
return errors.Annotate(err, "failed to get token source for %q account", ac.ID).Err()
}
// Launch firebase auth shim server. It will provide an URL from which we'll
// fetch an auth token.
ac.firebaseSrv = &firebase.Server{
Source: source,
}
if ac.firebaseTokenURL, err = ac.firebaseSrv.Start(ctx); err != nil {
return errors.Annotate(err, "failed to start firebase auth shim server for %q account", ac.ID).Err()
}
return nil
}