// enfonica_credentials.go — Enfonica service account authentication (reference implementation) // // Exchanges an Enfonica service account key for an OAuth2 bearer access token // using the JWT Bearer Grant (RFC 7523), and caches the token in memory. // Copy this single file into your project and change the package name to suit. // // Requires: Go 1.20+. Standard library only. // // Creating credentials (pick one): // // creds, err := enfonicaauth.NewCredentialsFromEnv() // path in ENFONICA_APPLICATION_CREDENTIALS // creds, err := enfonicaauth.NewCredentialsFromFile("key.json") // creds, err := enfonicaauth.NewCredentialsFromJSON(keyJSONBytes) // e.g. from a secret manager // // Using them on each API request: // // header, err := creds.AuthorizationHeader(ctx) // "Bearer " // req.Header.Set("Authorization", header) // // token, err := creds.AccessToken(ctx) // just the token // // Notes: // - Create ONE *Credentials and reuse it for the life of your application. // Tokens are cached in memory and refreshed automatically after 30 minutes // (the server issues 60-minute tokens), so a new value per request means a // token request per API call. // - *Credentials is safe for concurrent use. // - A 401 from the API is a real error (bad key, revoked key, missing // permission). Do not retry it by fetching a new token. // - Tokens are never written to disk. package enfonicaauth import ( "context" "crypto" "crypto/rand" "crypto/rsa" "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "regexp" "strings" "sync" "time" ) const ( scope = "https://api.enfonica.com/auth/platform" grantType = "urn:ietf:params:oauth:grant-type:jwt-bearer" envVarName = "ENFONICA_APPLICATION_CREDENTIALS" jwtLifetime = time.Hour tokenRefreshTime = 30 * time.Minute httpTimeout = 30 * time.Second ) // Credentials produces cached Enfonica access tokens from a service account key. type Credentials struct { ProjectID string ClientEmail string TokenURI string // always read from the key file, never hardcoded privateKeyID string privateKey *rsa.PrivateKey httpClient *http.Client mu sync.Mutex token string fetchedAt time.Time } type serviceAccountKey struct { Type string `json:"type"` ProjectID string `json:"project_id"` PrivateKeyID string `json:"private_key_id"` PrivateKey string `json:"private_key"` ClientEmail string `json:"client_email"` TokenURI string `json:"token_uri"` } // NewCredentialsFromEnv loads the key file named by the // ENFONICA_APPLICATION_CREDENTIALS environment variable. func NewCredentialsFromEnv() (*Credentials, error) { path := os.Getenv(envVarName) if path == "" { return nil, fmt.Errorf("%s is not set: set it to the path of a service account key file, "+ "or use NewCredentialsFromFile / NewCredentialsFromJSON", envVarName) } return NewCredentialsFromFile(path) } // NewCredentialsFromFile loads a service account key file from disk. func NewCredentialsFromFile(path string) (*Credentials, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("could not read key file %q: %w", path, err) } return NewCredentialsFromJSON(data) } // NewCredentialsFromJSON loads a service account key from its JSON contents. func NewCredentialsFromJSON(data []byte) (*Credentials, error) { var key serviceAccountKey if err := json.Unmarshal(data, &key); err != nil { return nil, fmt.Errorf("invalid key file: not valid JSON: %w", err) } if key.Type != "service_account" { return nil, fmt.Errorf("invalid key file: expected type \"service_account\", got %q", key.Type) } var missing []string for name, value := range map[string]string{ "project_id": key.ProjectID, "private_key_id": key.PrivateKeyID, "private_key": key.PrivateKey, "client_email": key.ClientEmail, "token_uri": key.TokenURI, } { if value == "" { missing = append(missing, name) } } if len(missing) > 0 { return nil, fmt.Errorf("invalid key file: missing field(s): %s", strings.Join(missing, ", ")) } privateKey, err := loadPrivateKey(key.PrivateKey) if err != nil { return nil, err } return &Credentials{ ProjectID: key.ProjectID, ClientEmail: key.ClientEmail, TokenURI: key.TokenURI, privateKeyID: key.PrivateKeyID, privateKey: privateKey, httpClient: &http.Client{Timeout: httpTimeout}, }, nil } // AccessToken returns a valid access token, fetching a new one if none is // cached or the cached one is 30+ minutes old. func (c *Credentials) AccessToken(ctx context.Context) (string, error) { c.mu.Lock() defer c.mu.Unlock() if c.token == "" || time.Since(c.fetchedAt) >= tokenRefreshTime { token, err := c.fetchToken(ctx) if err != nil { return "", err } c.token = token c.fetchedAt = time.Now() } return c.token, nil } // AuthorizationHeader returns the value for the HTTP Authorization header: "Bearer ". func (c *Credentials) AuthorizationHeader(ctx context.Context) (string, error) { token, err := c.AccessToken(ctx) if err != nil { return "", err } return "Bearer " + token, nil } func (c *Credentials) fetchToken(ctx context.Context) (string, error) { assertion, err := c.signedAssertion() if err != nil { return "", err } form := url.Values{"grant_type": {grantType}, "assertion": {assertion}} req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.TokenURI, strings.NewReader(form.Encode())) if err != nil { return "", fmt.Errorf("building token request: %w", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return "", fmt.Errorf("token request to %s failed: %w", c.TokenURI, err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("reading token response: %w", err) } if resp.StatusCode < 200 || resp.StatusCode > 299 { return "", fmt.Errorf("token request failed: HTTP %d from %s: %s", resp.StatusCode, c.TokenURI, body) } var parsed struct { AccessToken string `json:"access_token"` } if err := json.Unmarshal(body, &parsed); err != nil { return "", fmt.Errorf("token response was malformed: %q", body) } if parsed.AccessToken == "" { return "", fmt.Errorf("token response had no access_token: %q", body) } return parsed.AccessToken, nil } // signedAssertion builds the RS256-signed JWT assertion (RFC 7523 §3). func (c *Credentials) signedAssertion() (string, error) { now := time.Now().Unix() header := map[string]string{"alg": "RS256", "typ": "JWT", "kid": c.privateKeyID} payload := map[string]any{ "iss": c.ClientEmail, "aud": c.TokenURI, "scope": scope, "iat": now, "exp": now + int64(jwtLifetime.Seconds()), } headerJSON, _ := json.Marshal(header) payloadJSON, _ := json.Marshal(payload) signingInput := b64url(headerJSON) + "." + b64url(payloadJSON) digest := sha256.Sum256([]byte(signingInput)) signature, err := rsa.SignPKCS1v15(rand.Reader, c.privateKey, crypto.SHA256, digest[:]) if err != nil { return "", fmt.Errorf("signing JWT assertion: %w", err) } return signingInput + "." + b64url(signature), nil } var pemArmor = regexp.MustCompile(`-----(BEGIN|END) [A-Z ]+-----|\s+`) // loadPrivateKey decodes the PEM body directly so keys wrapped at any line // width (or not at all) load correctly. func loadPrivateKey(pem string) (*rsa.PrivateKey, error) { der, err := base64.StdEncoding.DecodeString(pemArmor.ReplaceAllString(pem, "")) if err != nil { return nil, fmt.Errorf("invalid key file: private_key is not valid base64 PEM: %w", err) } key, err := x509.ParsePKCS8PrivateKey(der) if err != nil { return nil, fmt.Errorf("invalid key file: private_key is not a valid PKCS#8 key: %w", err) } rsaKey, ok := key.(*rsa.PrivateKey) if !ok { return nil, errors.New("invalid key file: private_key is not an RSA key") } return rsaKey, nil } func b64url(data []byte) string { return base64.RawURLEncoding.EncodeToString(data) }