mirror of
https://github.com/cloudflare/cloudflared.git
synced 2026-06-23 04:10:20 +00:00
540 lines
17 KiB
Go
540 lines
17 KiB
Go
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
|
|
}
|