package token import ( "encoding/json" "fmt" "net/http" "net/url" "os" "strings" "time" "github.com/go-jose/go-jose/v4" "github.com/pkg/errors" "github.com/rs/zerolog" "github.com/shirou/gopsutil/v4/process" ) const ( keyName = "token" tokenCookie = "CF_Authorization" appSessionCookie = "CF_AppSession" appDomainHeader = "CF-Access-Domain" appAUDHeader = "CF-Access-Aud" AccessLoginWorkerPath = "/cdn-cgi/access/login" AccessAuthorizedWorkerPath = "/cdn-cgi/access/authorized" ) var ( userAgent = "DEV" signatureAlgs = []jose.SignatureAlgorithm{jose.RS256} ) type AppInfo struct { AuthDomain string AppAUD string AppDomain string } // lockContent is the JSON structure written into lock files. type lockContent struct { PID int32 `json:"pid"` StartTime int64 `json:"start_time"` } type jwtPayload struct { Aud []string `json:"-"` Email string `json:"email"` Exp int `json:"exp"` Iat int `json:"iat"` Nbf int `json:"nbf"` Iss string `json:"iss"` Type string `json:"type"` Subt string `json:"sub"` } type transferServiceResponse struct { AppToken string `json:"app_token"` OrgToken string `json:"org_token"` } func (p *jwtPayload) UnmarshalJSON(data []byte) error { type Alias jwtPayload if err := json.Unmarshal(data, (*Alias)(p)); err != nil { return err } var audParser struct { Aud any `json:"aud"` } if err := json.Unmarshal(data, &audParser); err != nil { return err } switch aud := audParser.Aud.(type) { case string: p.Aud = []string{aud} case []any: for _, a := range aud { s, ok := a.(string) if !ok { return errors.New("aud array contains non-string elements") } p.Aud = append(p.Aud, s) } default: return errors.New("aud field is not a string or an array of strings") } return nil } func (p jwtPayload) isExpired() bool { return int(time.Now().Unix()) > p.Exp } const ( lockRetryInterval = 2 * time.Second lockTimeout = 10 * time.Minute startTimeTolerance = int64(1000) // milliseconds ) // acquireLockFile loops until it successfully creates a lock file for the // given token file path. The lock file is created at tokenPath + ".lock". // // On each iteration: // 1. Try to create the file atomically with O_CREATE|O_EXCL. // If that succeeds, write our PID + start time and return nil. // 2. If the file already exists, read it and check whether the owning // process is still alive (PID exists and start time matches). // 3. If the owner is alive, sleep for lockRetryInterval and retry. // 4. If the owner is dead (stale lock), remove the file and immediately // retry the O_EXCL create. No sleep (the atomic create is the // tiebreaker if multiple processes race to reclaim). func acquireLockFile(tokenPath string, log *zerolog.Logger) error { lockPath := tokenPath + ".lock" deadline := time.Now().Add(lockTimeout) lastURL := "" for { if time.Now().After(deadline) { return fmt.Errorf("timed out waiting for lock file %s", lockPath) } err := tryCreateLockFile(lockPath) if err == nil { log.Debug().Str("path", lockPath).Msg("lock file acquired") return nil } if !os.IsExist(err) { return errors.Wrapf(err, "failed to create lock file %s", lockPath) } // lock file exists, so check if the owner is still alive stale, content, checkErr := isLockFileStale(lockPath) if checkErr != nil { // file may be mid-write by another racer, or was removed // between our O_EXCL attempt and this read log.Debug().Err(checkErr).Str("path", lockPath). Msg("could not read lock file, retrying") time.Sleep(lockRetryInterval) continue } if !stale { // try to display the auth URL so the user can open a browser // manually if the original window is not visible if authURL := readAuthURL(tokenPath); authURL != "" && authURL != lastURL { fmt.Fprintf(os.Stderr, "\nAnother cloudflared process (pid %d) "+ "is already waiting for authentication.\n\n"+ "If a browser window did not open, please visit "+ "the following URL:\n\n%s\n\n", content.PID, authURL) lastURL = authURL } log.Debug().Str("path", lockPath). Msg("lock file is held by another process, retrying") time.Sleep(lockRetryInterval) continue } // stale, so remove and immediately retry log.Debug().Str("path", lockPath).Int32("stale_pid", content.PID). Msg("reclaiming stale lock file") if removeErr := os.Remove(lockPath); removeErr != nil && !os.IsNotExist(removeErr) { log.Debug().Err(removeErr).Str("path", lockPath). Msg("could not remove stale lock file, retrying") time.Sleep(lockRetryInterval) continue } } } // readAuthURL reads the auth URL companion file for the given token path. // Returns the URL string, or empty string if the file doesn't exist or // can't be read. func readAuthURL(tokenPath string) string { data, err := os.ReadFile(tokenPath + ".url") // nolint: gosec if err != nil { return "" } return strings.TrimSpace(string(data)) } // tryCreateLockFile atomically creates the lock file using O_CREATE|O_EXCL // and writes the current process's PID and start time into it as JSON. // The file is created with 0600 permissions (owner read/write only). func tryCreateLockFile(path string) (retErr error) { f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) // nolint: gosec if err != nil { return err } defer func() { if retErr != nil { _ = f.Close() _ = os.Remove(path) return } retErr = f.Close() }() content, err := newSelfLockContent() if err != nil { return err } return json.NewEncoder(f).Encode(content) } // newSelfLockContent returns a lockContent describing the current process. func newSelfLockContent() (lockContent, error) { pid := int32(os.Getpid()) // nolint: gosec p, err := process.NewProcess(pid) if err != nil { return lockContent{}, fmt.Errorf("failed to look up own process: %w", err) } ct, err := p.CreateTime() if err != nil { return lockContent{}, fmt.Errorf("failed to get own start time: %w", err) } return lockContent{PID: pid, StartTime: ct}, nil } // isLockFileStale reads the lock file and checks whether the owning process // is dead or has a mismatched start time. Returns (true, content, nil) if // stale, (false, content, nil) if actively held, or an error if the file // cannot be read. func isLockFileStale(path string) (bool, lockContent, error) { data, err := os.ReadFile(path) // nolint: gosec if err != nil { return false, lockContent{}, err } var content lockContent if err := json.Unmarshal(data, &content); err != nil { // corrupt or empty file (treat as stale) return true, lockContent{}, nil } p, err := process.NewProcess(content.PID) if err != nil { return true, content, nil // process does not exist } // CreateTime reads /proc/{pid}/stat on Linux (world-readable, always works). // On Windows and macOS it can fail for processes owned by a different user, // but cloudflared instances sharing a lock file are always running as the // same user (the lock directory is derived from ~ via go-homedir). ct, err := p.CreateTime() if err != nil { return true, content, nil // cannot query process (treat as stale) } diff := ct - content.StartTime if diff < 0 { diff = -diff } if diff > startTimeTolerance { return true, content, nil // PID was recycled (different process) } // If the lock file is older than lockTimeout, the auth flow is // definitely complete and the process is no longer doing auth work. info, err := os.Stat(path) if err == nil && time.Since(info.ModTime()) > lockTimeout { return true, content, nil } return false, content, nil // process is alive and actively authenticating } func Init(version string) { userAgent = fmt.Sprintf("cloudflared/%s", version) } // FetchTokenWithRedirect will either load a stored token or generate a new one // it appends the full url as the redirect URL to the access cli request if opening the browser func FetchTokenWithRedirect(appURL *url.URL, appInfo *AppInfo, autoClose bool, isFedramp bool, log *zerolog.Logger) (string, error) { return getToken(appURL, appInfo, false, autoClose, isFedramp, log) } // FetchToken will either load a stored token or generate a new one // it appends the host of the appURL as the redirect URL to the access cli request if opening the browser func FetchToken(appURL *url.URL, appInfo *AppInfo, autoClose bool, isFedramp bool, log *zerolog.Logger) (string, error) { return getToken(appURL, appInfo, true, autoClose, isFedramp, log) } // getToken will either load a stored token or generate a new one func getToken(appURL *url.URL, appInfo *AppInfo, useHostOnly bool, autoClose bool, isFedramp bool, log *zerolog.Logger) (string, error) { if token, err := GetAppTokenIfExists(appInfo); token != "" && err == nil { return token, nil } appTokenPath, err := GenerateAppTokenFilePathFromURL(appInfo.AppDomain, appInfo.AppAUD, keyName) if err != nil { return "", errors.Wrap(err, "failed to generate app token file path") } if err = acquireLockFile(appTokenPath, log); err != nil { return "", errors.Wrap(err, "failed to acquire app token lock") } // check to see if another process has gotten a token while we waited for the lock if token, err := GetAppTokenIfExists(appInfo); token != "" && err == nil { return token, nil } // If an app token couldn't be found on disk, check for an org token and attempt to exchange it for an app token. var orgTokenPath string orgToken, err := GetOrgTokenIfExists(appInfo.AuthDomain) if err != nil { orgTokenPath, err = generateOrgTokenFilePathFromURL(appInfo.AuthDomain) if err != nil { return "", errors.Wrap(err, "failed to generate org token file path") } if err = acquireLockFile(orgTokenPath, log); err != nil { return "", errors.Wrap(err, "failed to acquire org token lock") } // check if an org token has been created since the lock was acquired orgToken, err = GetOrgTokenIfExists(appInfo.AuthDomain) } if err == nil { if appToken, err := exchangeOrgToken(appURL, orgToken); err != nil { log.Debug().Msgf("failed to exchange org token for app token: %s", err) } else { // generate app path if err := os.WriteFile(appTokenPath, []byte(appToken), 0600); err != nil { // nolint: gosec return "", errors.Wrap(err, "failed to write app token to disk") } return appToken, nil } } return getTokensFromEdge(appURL, appInfo.AppAUD, appTokenPath, orgTokenPath, useHostOnly, autoClose, isFedramp, log) } // getTokensFromEdge will attempt to use the transfer service to retrieve an app and org token, save them to disk, // and return the app token. func getTokensFromEdge(appURL *url.URL, appAUD, appTokenPath, orgTokenPath string, useHostOnly bool, autoClose bool, isFedramp bool, log *zerolog.Logger) (string, error) { // If no org token exists or if it couldn't be exchanged for an app token, then run the transfer service flow. // this weird parameter is the resource name (token) and the key/value // we want to send to the transfer service. the key is token and the value // is blank (basically just the id generated in the transfer service) resourceData, err := RunTransfer(appURL, appAUD, keyName, keyName, "", true, useHostOnly, autoClose, isFedramp, log, appTokenPath+".url") if err != nil { return "", errors.Wrap(err, "failed to run transfer service") } var resp transferServiceResponse if err = json.Unmarshal(resourceData, &resp); err != nil { return "", errors.Wrap(err, "failed to marshal transfer service response") } // If we were able to get the auth domain and generate an org token path, lets write it to disk. if orgTokenPath != "" { if err := os.WriteFile(orgTokenPath, []byte(resp.OrgToken), 0600); err != nil { return "", errors.Wrap(err, "failed to write org token to disk") } } if err := os.WriteFile(appTokenPath, []byte(resp.AppToken), 0600); err != nil { return "", errors.Wrap(err, "failed to write app token to disk") } return resp.AppToken, nil } // GetAppInfo makes a request to the appURL and stops at the first redirect. The 302 location header will contain the // auth domain func GetAppInfo(reqURL *url.URL) (*AppInfo, error) { client := &http.Client{ // do not follow redirects CheckRedirect: func(req *http.Request, via []*http.Request) error { // stop after hitting login endpoint since it will contain app path if strings.Contains(via[len(via)-1].URL.Path, AccessLoginWorkerPath) { return http.ErrUseLastResponse } return nil }, Timeout: time.Second * 7, } appInfoReq, err := http.NewRequest("HEAD", reqURL.String(), nil) if err != nil { return nil, errors.Wrap(err, "failed to create app info request") } appInfoReq.Header.Add("User-Agent", userAgent) resp, err := client.Do(appInfoReq) // nolint: gosec if err != nil { return nil, errors.Wrap(err, "failed to get app info") } _ = resp.Body.Close() var aud string location := resp.Request.URL if strings.Contains(location.Path, AccessLoginWorkerPath) { aud = resp.Request.URL.Query().Get("kid") if aud == "" { return nil, errors.New("Empty app aud") } } else if audHeader := resp.Header.Get(appAUDHeader); audHeader != "" { // 403/401 from the edge will have aud in a header aud = audHeader } else { return nil, fmt.Errorf("failed to find Access application at %s", reqURL.String()) } domain := resp.Header.Get(appDomainHeader) if domain == "" { return nil, errors.New("Empty app domain") } return &AppInfo{location.Hostname(), aud, domain}, nil } func handleRedirects(req *http.Request, via []*http.Request, orgToken string) error { // attach org token to login request if strings.Contains(req.URL.Path, AccessLoginWorkerPath) { req.AddCookie(&http.Cookie{Name: tokenCookie, Value: orgToken}) //nolint: gosec } // attach app session cookie to authorized request if strings.Contains(req.URL.Path, AccessAuthorizedWorkerPath) { // We need to check and see if the CF_APP_SESSION cookie was set for _, prevReq := range via { if prevReq != nil && prevReq.Response != nil { for _, c := range prevReq.Response.Cookies() { if c.Name == appSessionCookie { req.AddCookie(&http.Cookie{Name: appSessionCookie, Value: c.Value}) //nolint: gosec return nil } } } } } // stop after hitting authorized endpoint since it will contain the app token if len(via) > 0 && strings.Contains(via[len(via)-1].URL.Path, AccessAuthorizedWorkerPath) { return http.ErrUseLastResponse } return nil } // exchangeOrgToken attaches an org token to a request to the appURL and returns an app token. This uses the Access SSO // flow to automatically generate and return an app token without the login page. func exchangeOrgToken(appURL *url.URL, orgToken string) (string, error) { client := &http.Client{ CheckRedirect: func(req *http.Request, via []*http.Request) error { return handleRedirects(req, via, orgToken) }, Timeout: time.Second * 7, } appTokenRequest, err := http.NewRequest("HEAD", appURL.String(), nil) if err != nil { return "", errors.Wrap(err, "failed to create app token request") } appTokenRequest.Header.Add("User-Agent", userAgent) resp, err := client.Do(appTokenRequest) // nolint: gosec if err != nil { return "", errors.Wrap(err, "failed to get app token") } _ = resp.Body.Close() var appToken string for _, c := range resp.Cookies() { //if Org token revoked on exchange, getTokensFromEdge instead validAppToken := c.Name == tokenCookie && time.Now().Before(c.Expires) if validAppToken { appToken = c.Value break } } if len(appToken) > 0 { return appToken, nil } return "", fmt.Errorf("response from %s did not contain app token", resp.Request.URL.String()) } func GetOrgTokenIfExists(authDomain string) (string, error) { path, err := generateOrgTokenFilePathFromURL(authDomain) if err != nil { return "", err } token, err := getTokenIfExists(path) if err != nil { return "", err } var payload jwtPayload err = json.Unmarshal(token.UnsafePayloadWithoutVerification(), &payload) if err != nil { return "", err } if payload.isExpired() { err := os.Remove(path) return "", err } return token.CompactSerialize() } func GetAppTokenIfExists(appInfo *AppInfo) (string, error) { path, err := GenerateAppTokenFilePathFromURL(appInfo.AppDomain, appInfo.AppAUD, keyName) if err != nil { return "", err } token, err := getTokenIfExists(path) if err != nil { return "", err } var payload jwtPayload err = json.Unmarshal(token.UnsafePayloadWithoutVerification(), &payload) if err != nil { return "", err } if payload.isExpired() { err := os.Remove(path) return "", err } return token.CompactSerialize() } // GetTokenIfExists will return the token from local storage if it exists and not expired func getTokenIfExists(path string) (*jose.JSONWebSignature, error) { content, err := os.ReadFile(path) // nolint: gosec if err != nil { return nil, err } token, err := jose.ParseSigned(string(content), signatureAlgs) if err != nil { return nil, err } return token, nil } // RemoveTokenIfExists removes the a token from local storage if it exists func RemoveTokenIfExists(appInfo *AppInfo) error { path, err := GenerateAppTokenFilePathFromURL(appInfo.AppDomain, appInfo.AppAUD, keyName) if err != nil { return err } if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return err } return nil }