Authentication¶
Every request to the Enfonica API carries an access token in its Authorization header. This is the same whether you call the API over REST or gRPC:
Authorization: Bearer ACCESS_TOKEN
The API accepts no other kind of credential, such as an API key in the URL.
You can get an access token in two ways:
- Service account key (recommended). Your app exchanges a service account key for an access token that lasts up to one hour, and gets a new one before it expires. Use this for any app that you write.
- Bearer token from Enfonica support. A long-lived token for tools that can send only a fixed
Authorizationheader, such as Postman or a no-code platform.
Authenticate with a service account key¶
To call the Enfonica REST API from your app, use the code sample for your language in Code samples. Each sample is one file that you copy into your project. It reads your service account key, gets an access token, and keeps it until it needs a new one. The samples need no Enfonica client library.
Before you begin¶
- Create a service account in your project, and give it the least access that your app needs.
- Add a key to the service account, and download the JSON key file. Store the key file as you would a password, and keep it out of your source code.
-
Set the
ENFONICA_APPLICATION_CREDENTIALSenvironment variable to the path of the key file:export ENFONICA_APPLICATION_CREDENTIALS=/path/to/key.jsonThe samples and the Enfonica client libraries read the key from this path. Each sample can also load a key from a path or from JSON text that you pass in, for example from a secret manager.
Code samples¶
Each sample is tested against the Enfonica API. Download the file for your language, or copy it from the tab below.
| Language | Sample | Requires |
|---|---|---|
| Python | enfonica_credentials.py | Python 3.9 or later, and the cryptography package |
| Node.js (TypeScript) | enfonica-credentials.ts | Node.js 18 or later. No packages. |
| Go | enfonica_credentials.go | Go 1.20 or later. Standard library only. |
| C# (.NET) | EnfonicaCredentials.cs | .NET 6 or later. No NuGet packages. |
| Java | EnfonicaCredentials.java | Java 11 or later, and jackson-databind |
| PHP | EnfonicaCredentials.php | PHP 8.1 or later, with the openssl and curl extensions |
| Ruby | enfonica_credentials.rb | Ruby 3, and the jwt gem |
The comment at the top of each file shows how to create the credentials and add the header to a request. Create one instance when your app starts, and reuse it for every request. A new instance for each request means a token request for each API call.
"""
enfonica_credentials.py — 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.
Requires: Python 3.9+ and the `cryptography` package (pip install cryptography).
HTTP uses the standard library only.
Creating credentials (pick one):
creds = EnfonicaCredentials.from_env() # path in ENFONICA_APPLICATION_CREDENTIALS
creds = EnfonicaCredentials.from_service_account_file("key.json")
creds = EnfonicaCredentials.from_service_account_info(json_str_or_dict) # e.g. from a secret manager
Using them on each API request:
headers = {"Authorization": creds.authorization_header()} # "Bearer <token>"
token = creds.get_access_token() # just the token
Notes:
- Create ONE instance 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 instance per request means a
token request per API call.
- Instances are thread-safe.
- 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.
"""
from __future__ import annotations
import base64
import json
import os
import re
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Union
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
SCOPE = "https://api.enfonica.com/auth/platform"
GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"
ENV_VAR = "ENFONICA_APPLICATION_CREDENTIALS"
JWT_LIFETIME_SECONDS = 3600
TOKEN_REFRESH_SECONDS = 30 * 60
HTTP_TIMEOUT_SECONDS = 30
_REQUIRED_FIELDS = ("type", "project_id", "private_key_id", "private_key", "client_email", "token_uri")
class EnfonicaCredentialsError(Exception):
"""Raised when a key file is missing or invalid, or a token request fails."""
class EnfonicaCredentials:
"""Service account credentials that produce cached Enfonica access tokens."""
@classmethod
def from_env(cls) -> "EnfonicaCredentials":
"""Load the key file named by the ENFONICA_APPLICATION_CREDENTIALS environment variable."""
path = os.environ.get(ENV_VAR)
if not path:
raise EnfonicaCredentialsError(
f"{ENV_VAR} is not set. Set it to the path of a service account key file, "
"or use from_service_account_file() / from_service_account_info()."
)
return cls.from_service_account_file(path)
@classmethod
def from_service_account_file(cls, path: str) -> "EnfonicaCredentials":
"""Load a service account key file from disk."""
try:
with open(path, encoding="utf-8") as f:
return cls.from_service_account_info(f.read())
except OSError as e:
raise EnfonicaCredentialsError(f"Could not read key file '{path}': {e}") from e
@classmethod
def from_service_account_info(cls, info: Union[str, dict]) -> "EnfonicaCredentials":
"""Load a service account key from its JSON text or an already-parsed dict."""
if isinstance(info, str):
try:
info = json.loads(info)
except json.JSONDecodeError as e:
raise EnfonicaCredentialsError(f"Invalid key file: not valid JSON: {e}") from e
return cls(info)
def __init__(self, info: dict[str, Any]) -> None:
if not isinstance(info, dict):
raise EnfonicaCredentialsError(f"Invalid key file: expected a JSON object, got {type(info).__name__}")
if info.get("type") != "service_account":
raise EnfonicaCredentialsError(
f"Invalid key file: expected type 'service_account', got {info.get('type')!r}"
)
missing = [f for f in _REQUIRED_FIELDS if not isinstance(info.get(f), str) or not info[f]]
if missing:
raise EnfonicaCredentialsError(f"Invalid key file: missing field(s): {', '.join(missing)}")
self.project_id: str = info["project_id"]
self.client_email: str = info["client_email"]
self.token_uri: str = info["token_uri"] # always read from the key file, never hardcoded
self._private_key_id: str = info["private_key_id"]
self._private_key = _load_private_key(info["private_key"])
self._lock = threading.Lock()
self._token: str | None = None
self._fetched_at = 0.0
def get_access_token(self) -> str:
"""Return a valid access token, fetching a new one if none is cached or it is 30+ minutes old."""
with self._lock:
if self._token is None or time.monotonic() - self._fetched_at >= TOKEN_REFRESH_SECONDS:
self._token = self._fetch_token()
self._fetched_at = time.monotonic()
return self._token
def authorization_header(self) -> str:
"""Return the value for the HTTP Authorization header: 'Bearer <token>'."""
return f"Bearer {self.get_access_token()}"
def _fetch_token(self) -> str:
body = urllib.parse.urlencode({"grant_type": GRANT_TYPE, "assertion": self._signed_assertion()}).encode()
request = urllib.request.Request(
self.token_uri,
data=body,
method="POST",
headers={"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"},
)
try:
with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT_SECONDS) as response:
text = response.read().decode("utf-8")
except urllib.error.HTTPError as e:
detail = e.read().decode("utf-8", errors="replace")
raise EnfonicaCredentialsError(
f"Token request failed: HTTP {e.code} from {self.token_uri}: {detail}"
) from e
except urllib.error.URLError as e:
raise EnfonicaCredentialsError(f"Token request to {self.token_uri} failed: {e.reason}") from e
try:
token = json.loads(text)["access_token"]
except (json.JSONDecodeError, KeyError, TypeError) as e:
raise EnfonicaCredentialsError(f"Token response was malformed: {text!r}") from e
if not isinstance(token, str) or not token:
raise EnfonicaCredentialsError(f"Token response had an empty access_token: {text!r}")
return token
def _signed_assertion(self) -> str:
"""Build the RS256-signed JWT assertion (RFC 7523 §3)."""
now = int(time.time())
header = {"alg": "RS256", "typ": "JWT", "kid": self._private_key_id}
payload = {
"iss": self.client_email,
"aud": self.token_uri,
"scope": SCOPE,
"iat": now,
"exp": now + JWT_LIFETIME_SECONDS,
}
signing_input = f"{_b64url_json(header)}.{_b64url_json(payload)}".encode("ascii")
signature = self._private_key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256())
return f"{signing_input.decode('ascii')}.{_b64url(signature)}"
def _load_private_key(pem: str):
# Decode the PEM body ourselves so keys wrapped at any line width (or not
# at all) load correctly; some PEM parsers insist on 64-character lines.
body = re.sub(r"-----(BEGIN|END) [A-Z ]+-----|\s+", "", pem)
try:
return serialization.load_der_private_key(base64.b64decode(body), password=None)
except ValueError as e:
raise EnfonicaCredentialsError(f"Invalid key file: private_key is not a valid PKCS#8 RSA key: {e}") from e
def _b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def _b64url_json(obj: dict) -> str:
return _b64url(json.dumps(obj, separators=(",", ":")).encode("utf-8"))
/**
* enfonica-credentials.ts — 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.
*
* Requires: Node.js 18+ (uses the built-in crypto module and fetch). No npm packages.
* Written as plain TypeScript with no enums or parameter properties, so Node 22.18+
* can run it directly; delete the type annotations to use it as JavaScript.
*
* Creating credentials (pick one):
*
* const creds = EnfonicaCredentials.fromEnv(); // path in ENFONICA_APPLICATION_CREDENTIALS
* const creds = EnfonicaCredentials.fromServiceAccountFile('key.json');
* const creds = EnfonicaCredentials.fromServiceAccountInfo(jsonStringOrObject); // e.g. from a secret manager
*
* Using them on each API request:
*
* headers: { Authorization: await creds.getAuthorizationHeader() } // "Bearer <token>"
* const token = await creds.getAccessToken(); // just the token
*
* Notes:
* - Create ONE instance 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 instance per request means a
* token request per API call.
* - Concurrent calls share a single in-flight token request.
* - 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.
*/
import { createPrivateKey, createSign, type KeyObject } from 'node:crypto';
import { readFileSync } from 'node:fs';
const SCOPE = 'https://api.enfonica.com/auth/platform';
const GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer';
const ENV_VAR = 'ENFONICA_APPLICATION_CREDENTIALS';
const JWT_LIFETIME_SECONDS = 3600;
const TOKEN_REFRESH_MS = 30 * 60 * 1000;
const HTTP_TIMEOUT_MS = 30_000;
const REQUIRED_FIELDS = ['type', 'project_id', 'private_key_id', 'private_key', 'client_email', 'token_uri'] as const;
export interface ServiceAccountKey {
type: string;
project_id: string;
private_key_id: string;
private_key: string;
client_email: string;
token_uri: string;
}
/** Thrown when a key file is missing or invalid, or a token request fails. */
export class EnfonicaCredentialsError extends Error {
constructor(message: string) {
super(message);
this.name = 'EnfonicaCredentialsError';
}
}
/** Service account credentials that produce cached Enfonica access tokens. */
export class EnfonicaCredentials {
readonly projectId: string;
readonly clientEmail: string;
/** Always read from the key file, never hardcoded. */
readonly tokenUri: string;
private readonly privateKeyId: string;
private readonly privateKey: KeyObject;
private token: string | null = null;
private fetchedAt = 0;
private inFlight: Promise<string> | null = null;
/** Load the key file named by the ENFONICA_APPLICATION_CREDENTIALS environment variable. */
static fromEnv(): EnfonicaCredentials {
const path = process.env[ENV_VAR];
if (!path) {
throw new EnfonicaCredentialsError(
`${ENV_VAR} is not set. Set it to the path of a service account key file, ` +
'or use fromServiceAccountFile() / fromServiceAccountInfo().',
);
}
return EnfonicaCredentials.fromServiceAccountFile(path);
}
/** Load a service account key file from disk. */
static fromServiceAccountFile(path: string): EnfonicaCredentials {
let text: string;
try {
text = readFileSync(path, 'utf8');
} catch (e) {
throw new EnfonicaCredentialsError(`Could not read key file '${path}': ${(e as Error).message}`);
}
return EnfonicaCredentials.fromServiceAccountInfo(text);
}
/** Load a service account key from its JSON text or an already-parsed object. */
static fromServiceAccountInfo(info: string | ServiceAccountKey): EnfonicaCredentials {
let parsed: unknown = info;
if (typeof info === 'string') {
try {
parsed = JSON.parse(info);
} catch (e) {
throw new EnfonicaCredentialsError(`Invalid key file: not valid JSON: ${(e as Error).message}`);
}
}
return new EnfonicaCredentials(parsed);
}
constructor(info: unknown) {
if (typeof info !== 'object' || info === null || Array.isArray(info)) {
throw new EnfonicaCredentialsError('Invalid key file: expected a JSON object');
}
const key = info as Record<string, unknown>;
if (key.type !== 'service_account') {
throw new EnfonicaCredentialsError(`Invalid key file: expected type 'service_account', got '${String(key.type)}'`);
}
const missing = REQUIRED_FIELDS.filter((f) => typeof key[f] !== 'string' || key[f] === '');
if (missing.length > 0) {
throw new EnfonicaCredentialsError(`Invalid key file: missing field(s): ${missing.join(', ')}`);
}
this.projectId = key.project_id as string;
this.clientEmail = key.client_email as string;
this.tokenUri = key.token_uri as string;
this.privateKeyId = key.private_key_id as string;
this.privateKey = loadPrivateKey(key.private_key as string);
}
/** Return a valid access token, fetching a new one if none is cached or it is 30+ minutes old. */
async getAccessToken(): Promise<string> {
if (this.token !== null && Date.now() - this.fetchedAt < TOKEN_REFRESH_MS) {
return this.token;
}
if (this.inFlight === null) {
this.inFlight = this.fetchToken()
.then((token) => {
this.token = token;
this.fetchedAt = Date.now();
return token;
})
.finally(() => {
this.inFlight = null;
});
}
return this.inFlight;
}
/** Return the value for the HTTP Authorization header: "Bearer <token>". */
async getAuthorizationHeader(): Promise<string> {
return `Bearer ${await this.getAccessToken()}`;
}
private async fetchToken(): Promise<string> {
let response: Response;
try {
response = await fetch(this.tokenUri, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
body: new URLSearchParams({ grant_type: GRANT_TYPE, assertion: this.signedAssertion() }),
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
});
} catch (e) {
throw new EnfonicaCredentialsError(`Token request to ${this.tokenUri} failed: ${(e as Error).message}`);
}
const text = await response.text();
if (!response.ok) {
throw new EnfonicaCredentialsError(`Token request failed: HTTP ${response.status} from ${this.tokenUri}: ${text}`);
}
let token: unknown;
try {
token = JSON.parse(text).access_token;
} catch {
throw new EnfonicaCredentialsError(`Token response was malformed: ${text}`);
}
if (typeof token !== 'string' || token === '') {
throw new EnfonicaCredentialsError(`Token response had no access_token: ${text}`);
}
return token;
}
/** Build the RS256-signed JWT assertion (RFC 7523 §3). */
private signedAssertion(): string {
const now = Math.floor(Date.now() / 1000);
const header = { alg: 'RS256', typ: 'JWT', kid: this.privateKeyId };
const payload = {
iss: this.clientEmail,
aud: this.tokenUri,
scope: SCOPE,
iat: now,
exp: now + JWT_LIFETIME_SECONDS,
};
const signingInput = `${b64urlJson(header)}.${b64urlJson(payload)}`;
const signature = createSign('RSA-SHA256').update(signingInput).sign(this.privateKey);
return `${signingInput}.${signature.toString('base64url')}`;
}
}
function loadPrivateKey(pem: string): KeyObject {
// Decode the PEM body ourselves so keys wrapped at any line width (or not
// at all) load correctly; some PEM parsers insist on 64-character lines.
const body = pem.replace(/-----(BEGIN|END) [A-Z ]+-----|\s+/g, '');
try {
return createPrivateKey({ key: Buffer.from(body, 'base64'), format: 'der', type: 'pkcs8' });
} catch (e) {
throw new EnfonicaCredentialsError(`Invalid key file: private_key is not a valid PKCS#8 RSA key: ${(e as Error).message}`);
}
}
function b64urlJson(obj: object): string {
return Buffer.from(JSON.stringify(obj), 'utf8').toString('base64url');
}
// 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 <token>"
// 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 <token>".
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)
}
// EnfonicaCredentials.cs — 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 namespace to suit.
//
// Requires: .NET 6+. No NuGet packages.
//
// Creating credentials (pick one):
//
// var creds = EnfonicaCredentials.FromEnvironment(); // path in ENFONICA_APPLICATION_CREDENTIALS
// var creds = EnfonicaCredentials.FromServiceAccountFile("key.json");
// var creds = EnfonicaCredentials.FromServiceAccountInfo(keyJson); // e.g. from a secret manager
//
// Using them on each API request:
//
// request.Headers.Authorization = await creds.GetAuthorizationHeaderAsync(); // Bearer <token>
// string token = await creds.GetAccessTokenAsync(); // just the token
//
// Notes:
// - Create ONE instance and reuse it for the life of your application (e.g.
// register it as a singleton). Tokens are cached in memory and refreshed
// automatically after 30 minutes (the server issues 60-minute tokens), so
// a new instance per request means a token request per API call.
// - Instances are thread-safe.
// - 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.
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
namespace Enfonica.Auth;
/// <summary>Thrown when a key file is missing or invalid, or a token request fails.</summary>
public sealed class EnfonicaCredentialsException : Exception
{
public EnfonicaCredentialsException(string message, Exception? inner = null) : base(message, inner) { }
}
/// <summary>Service account credentials that produce cached Enfonica access tokens.</summary>
public sealed class EnfonicaCredentials : IDisposable
{
private const string Scope = "https://api.enfonica.com/auth/platform";
private const string GrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer";
private const string EnvVar = "ENFONICA_APPLICATION_CREDENTIALS";
private static readonly TimeSpan JwtLifetime = TimeSpan.FromHours(1);
private static readonly TimeSpan TokenRefresh = TimeSpan.FromMinutes(30);
private static readonly string[] RequiredFields =
{ "type", "project_id", "private_key_id", "private_key", "client_email", "token_uri" };
private readonly string _privateKeyId;
private readonly RSA _privateKey;
private readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(30) };
private readonly SemaphoreSlim _lock = new(1, 1);
private string? _token;
private DateTime _fetchedAtUtc;
public string ProjectId { get; }
public string ClientEmail { get; }
/// <summary>Always read from the key file, never hardcoded.</summary>
public string TokenUri { get; }
/// <summary>Load the key file named by the ENFONICA_APPLICATION_CREDENTIALS environment variable.</summary>
public static EnfonicaCredentials FromEnvironment()
{
var path = Environment.GetEnvironmentVariable(EnvVar);
if (string.IsNullOrEmpty(path))
throw new EnfonicaCredentialsException(
$"{EnvVar} is not set. Set it to the path of a service account key file, " +
"or use FromServiceAccountFile() / FromServiceAccountInfo().");
return FromServiceAccountFile(path);
}
/// <summary>Load a service account key file from disk.</summary>
public static EnfonicaCredentials FromServiceAccountFile(string path)
{
string json;
try
{
json = File.ReadAllText(path);
}
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
{
throw new EnfonicaCredentialsException($"Could not read key file '{path}': {e.Message}", e);
}
return FromServiceAccountInfo(json);
}
/// <summary>Load a service account key from its JSON contents.</summary>
public static EnfonicaCredentials FromServiceAccountInfo(string json)
{
Dictionary<string, JsonElement>? fields;
try
{
fields = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(json);
}
catch (JsonException e)
{
throw new EnfonicaCredentialsException($"Invalid key file: not valid JSON: {e.Message}", e);
}
if (fields is null)
throw new EnfonicaCredentialsException("Invalid key file: expected a JSON object");
string Field(string name) =>
fields.TryGetValue(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? "" : "";
if (Field("type") != "service_account")
throw new EnfonicaCredentialsException(
$"Invalid key file: expected type 'service_account', got '{Field("type")}'");
var missing = Array.FindAll(RequiredFields, f => Field(f) == "");
if (missing.Length > 0)
throw new EnfonicaCredentialsException($"Invalid key file: missing field(s): {string.Join(", ", missing)}");
return new EnfonicaCredentials(
Field("project_id"), Field("client_email"), Field("token_uri"),
Field("private_key_id"), LoadPrivateKey(Field("private_key")));
}
private EnfonicaCredentials(string projectId, string clientEmail, string tokenUri, string privateKeyId, RSA privateKey)
{
ProjectId = projectId;
ClientEmail = clientEmail;
TokenUri = tokenUri;
_privateKeyId = privateKeyId;
_privateKey = privateKey;
}
/// <summary>Return a valid access token, fetching a new one if none is cached or it is 30+ minutes old.</summary>
public async Task<string> GetAccessTokenAsync(CancellationToken cancellationToken = default)
{
await _lock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_token is null || DateTime.UtcNow - _fetchedAtUtc >= TokenRefresh)
{
_token = await FetchTokenAsync(cancellationToken).ConfigureAwait(false);
_fetchedAtUtc = DateTime.UtcNow;
}
return _token;
}
finally
{
_lock.Release();
}
}
/// <summary>Return an Authorization header value: <c>Bearer <token></c>.</summary>
public async Task<AuthenticationHeaderValue> GetAuthorizationHeaderAsync(CancellationToken cancellationToken = default) =>
new("Bearer", await GetAccessTokenAsync(cancellationToken).ConfigureAwait(false));
public void Dispose()
{
_httpClient.Dispose();
_privateKey.Dispose();
_lock.Dispose();
}
private async Task<string> FetchTokenAsync(CancellationToken cancellationToken)
{
using var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = GrantType,
["assertion"] = SignedAssertion(),
});
using var request = new HttpRequestMessage(HttpMethod.Post, TokenUri) { Content = content };
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
HttpResponseMessage response;
try
{
response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
catch (HttpRequestException e)
{
throw new EnfonicaCredentialsException($"Token request to {TokenUri} failed: {e.Message}", e);
}
using (response)
{
var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
throw new EnfonicaCredentialsException(
$"Token request failed: HTTP {(int)response.StatusCode} from {TokenUri}: {body}");
try
{
using var doc = JsonDocument.Parse(body);
if (doc.RootElement.TryGetProperty("access_token", out var token)
&& token.ValueKind == JsonValueKind.String
&& !string.IsNullOrEmpty(token.GetString()))
return token.GetString()!;
}
catch (JsonException)
{
// Fall through to the malformed-response error below.
}
throw new EnfonicaCredentialsException($"Token response was malformed: {body}");
}
}
/// <summary>Build the RS256-signed JWT assertion (RFC 7523 §3).</summary>
private string SignedAssertion()
{
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var header = new Dictionary<string, object> { ["alg"] = "RS256", ["typ"] = "JWT", ["kid"] = _privateKeyId };
var payload = new Dictionary<string, object>
{
["iss"] = ClientEmail,
["aud"] = TokenUri,
["scope"] = Scope,
["iat"] = now,
["exp"] = now + (long)JwtLifetime.TotalSeconds,
};
var signingInput = $"{Base64Url(JsonSerializer.SerializeToUtf8Bytes(header))}.{Base64Url(JsonSerializer.SerializeToUtf8Bytes(payload))}";
var signature = _privateKey.SignData(
Encoding.ASCII.GetBytes(signingInput), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
return $"{signingInput}.{Base64Url(signature)}";
}
// Decode the PEM body directly so keys wrapped at any line width (or not
// at all) load correctly.
private static RSA LoadPrivateKey(string pem)
{
var body = Regex.Replace(pem, @"-----(BEGIN|END) [A-Z ]+-----|\s+", "");
var rsa = RSA.Create();
try
{
rsa.ImportPkcs8PrivateKey(Convert.FromBase64String(body), out _);
return rsa;
}
catch (Exception e) when (e is FormatException or CryptographicException)
{
rsa.Dispose();
throw new EnfonicaCredentialsException(
$"Invalid key file: private_key is not a valid PKCS#8 RSA key: {e.Message}", e);
}
}
private static string Base64Url(byte[] data) =>
Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
// EnfonicaCredentials.java — 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 to suit.
//
// Requires: Java 11+ and Jackson for JSON (com.fasterxml.jackson.core:jackson-databind).
// HTTP, RSA and SHA-256 use the JDK only.
//
// Creating credentials (pick one):
//
// EnfonicaCredentials creds = EnfonicaCredentials.fromEnv(); // path in ENFONICA_APPLICATION_CREDENTIALS
// EnfonicaCredentials creds = EnfonicaCredentials.fromServiceAccountFile(Path.of("key.json"));
// EnfonicaCredentials creds = EnfonicaCredentials.fromServiceAccountInfo(keyJson); // e.g. from a secret manager
//
// Using them on each API request:
//
// requestBuilder.header("Authorization", creds.getAuthorizationHeader()); // "Bearer <token>"
// String token = creds.getAccessToken(); // just the token
//
// Notes:
// - Create ONE instance and reuse it for the life of your application (e.g.
// a singleton bean). Tokens are cached in memory and refreshed
// automatically after 30 minutes (the server issues 60-minute tokens), so
// a new instance per request means a token request per API call.
// - Instances are thread-safe.
// - 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 com.enfonica.auth;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/** Service account credentials that produce cached Enfonica access tokens. */
public final class EnfonicaCredentials {
/** Thrown when a key file is missing or invalid, or a token request fails. */
public static final class EnfonicaCredentialsException extends RuntimeException {
private static final long serialVersionUID = 1L;
public EnfonicaCredentialsException(String message) { super(message); }
public EnfonicaCredentialsException(String message, Throwable cause) { super(message, cause); }
}
private static final String SCOPE = "https://api.enfonica.com/auth/platform";
private static final String GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer";
private static final String ENV_VAR = "ENFONICA_APPLICATION_CREDENTIALS";
private static final long JWT_LIFETIME_SECONDS = 3600;
private static final long TOKEN_REFRESH_NANOS = Duration.ofMinutes(30).toNanos();
private static final Duration HTTP_TIMEOUT = Duration.ofSeconds(30);
private static final String[] REQUIRED_FIELDS =
{"type", "project_id", "private_key_id", "private_key", "client_email", "token_uri"};
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final Base64.Encoder B64URL = Base64.getUrlEncoder().withoutPadding();
private final String projectId;
private final String clientEmail;
private final String tokenUri;
private final String privateKeyId;
private final PrivateKey privateKey;
private final HttpClient httpClient = HttpClient.newBuilder().connectTimeout(HTTP_TIMEOUT).build();
private String token;
private long fetchedAtNanos;
/** Load the key file named by the ENFONICA_APPLICATION_CREDENTIALS environment variable. */
public static EnfonicaCredentials fromEnv() {
String path = System.getenv(ENV_VAR);
if (path == null || path.isEmpty()) {
throw new EnfonicaCredentialsException(ENV_VAR + " is not set. Set it to the path of a service "
+ "account key file, or use fromServiceAccountFile() / fromServiceAccountInfo().");
}
return fromServiceAccountFile(Path.of(path));
}
/** Load a service account key file from disk. */
public static EnfonicaCredentials fromServiceAccountFile(Path path) {
try {
return fromServiceAccountInfo(Files.readString(path));
} catch (IOException e) {
throw new EnfonicaCredentialsException("Could not read key file '" + path + "': " + e.getMessage(), e);
}
}
/** Load a service account key from its JSON contents. */
public static EnfonicaCredentials fromServiceAccountInfo(String json) {
JsonNode node;
try {
node = MAPPER.readTree(json);
} catch (JsonProcessingException e) {
throw new EnfonicaCredentialsException("Invalid key file: not valid JSON: " + e.getOriginalMessage(), e);
}
if (node == null || !node.isObject()) {
throw new EnfonicaCredentialsException("Invalid key file: expected a JSON object");
}
if (!"service_account".equals(node.path("type").asText(""))) {
throw new EnfonicaCredentialsException(
"Invalid key file: expected type 'service_account', got '" + node.path("type").asText("") + "'");
}
List<String> missing = new ArrayList<>();
for (String field : REQUIRED_FIELDS) {
if (!node.path(field).isTextual() || node.path(field).asText().isEmpty()) {
missing.add(field);
}
}
if (!missing.isEmpty()) {
throw new EnfonicaCredentialsException("Invalid key file: missing field(s): " + String.join(", ", missing));
}
return new EnfonicaCredentials(
node.get("project_id").asText(), node.get("client_email").asText(), node.get("token_uri").asText(),
node.get("private_key_id").asText(), loadPrivateKey(node.get("private_key").asText()));
}
private EnfonicaCredentials(String projectId, String clientEmail, String tokenUri,
String privateKeyId, PrivateKey privateKey) {
this.projectId = projectId;
this.clientEmail = clientEmail;
this.tokenUri = tokenUri;
this.privateKeyId = privateKeyId;
this.privateKey = privateKey;
}
public String getProjectId() { return projectId; }
public String getClientEmail() { return clientEmail; }
/** Always read from the key file, never hardcoded. */
public String getTokenUri() { return tokenUri; }
/** Return a valid access token, fetching a new one if none is cached or it is 30+ minutes old. */
public synchronized String getAccessToken() {
if (token == null || System.nanoTime() - fetchedAtNanos >= TOKEN_REFRESH_NANOS) {
token = fetchToken();
fetchedAtNanos = System.nanoTime();
}
return token;
}
/** Return the value for the HTTP Authorization header: "Bearer <token>". */
public String getAuthorizationHeader() {
return "Bearer " + getAccessToken();
}
private String fetchToken() {
String form = "grant_type=" + URLEncoder.encode(GRANT_TYPE, StandardCharsets.UTF_8)
+ "&assertion=" + URLEncoder.encode(signedAssertion(), StandardCharsets.UTF_8);
HttpRequest request = HttpRequest.newBuilder(URI.create(tokenUri))
.timeout(HTTP_TIMEOUT)
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(form))
.build();
HttpResponse<String> response;
try {
response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
} catch (IOException e) {
throw new EnfonicaCredentialsException("Token request to " + tokenUri + " failed: " + e.getMessage(), e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new EnfonicaCredentialsException("Token request to " + tokenUri + " was interrupted", e);
}
if (response.statusCode() < 200 || response.statusCode() > 299) {
throw new EnfonicaCredentialsException("Token request failed: HTTP " + response.statusCode()
+ " from " + tokenUri + ": " + response.body());
}
try {
JsonNode accessToken = MAPPER.readTree(response.body()).path("access_token");
if (accessToken.isTextual() && !accessToken.asText().isEmpty()) {
return accessToken.asText();
}
} catch (JsonProcessingException e) {
// Fall through to the malformed-response error below.
}
throw new EnfonicaCredentialsException("Token response was malformed: " + response.body());
}
/** Build the RS256-signed JWT assertion (RFC 7523 §3). */
private String signedAssertion() {
long now = System.currentTimeMillis() / 1000;
Map<String, Object> header = new LinkedHashMap<>();
header.put("alg", "RS256");
header.put("typ", "JWT");
header.put("kid", privateKeyId);
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("iss", clientEmail);
payload.put("aud", tokenUri);
payload.put("scope", SCOPE);
payload.put("iat", now);
payload.put("exp", now + JWT_LIFETIME_SECONDS);
try {
String signingInput = B64URL.encodeToString(MAPPER.writeValueAsBytes(header))
+ "." + B64URL.encodeToString(MAPPER.writeValueAsBytes(payload));
Signature signer = Signature.getInstance("SHA256withRSA");
signer.initSign(privateKey);
signer.update(signingInput.getBytes(StandardCharsets.US_ASCII));
return signingInput + "." + B64URL.encodeToString(signer.sign());
} catch (JsonProcessingException | GeneralSecurityException e) {
throw new EnfonicaCredentialsException("Could not sign JWT assertion: " + e.getMessage(), e);
}
}
// Decode the PEM body directly so keys wrapped at any line width (or not
// at all) load correctly.
private static PrivateKey loadPrivateKey(String pem) {
String body = pem.replaceAll("-----(BEGIN|END) [A-Z ]+-----|\\s+", "");
try {
byte[] der = Base64.getDecoder().decode(body);
return KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(der));
} catch (IllegalArgumentException | GeneralSecurityException e) {
throw new EnfonicaCredentialsException(
"Invalid key file: private_key is not a valid PKCS#8 RSA key: " + e.getMessage(), e);
}
}
}
<?php
/**
* EnfonicaCredentials.php — 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 add a namespace to suit.
*
* Requires: PHP 8.1+ with the openssl and curl extensions. No Composer packages.
*
* Creating credentials (pick one):
*
* $creds = EnfonicaCredentials::fromEnv(); // path in ENFONICA_APPLICATION_CREDENTIALS
* $creds = EnfonicaCredentials::fromServiceAccountFile('key.json');
* $creds = EnfonicaCredentials::fromServiceAccountInfo($jsonOrArray); // e.g. from a secret manager
*
* Using them on each API request:
*
* $headers = ['Authorization: ' . $creds->getAuthorizationHeader()]; // "Bearer <token>"
* $token = $creds->getAccessToken(); // just the token
*
* Notes:
* - Reuse ONE instance for the life of the process (e.g. register it as a
* shared service in your container). Tokens are cached in memory and
* refreshed automatically after 30 minutes (the server issues 60-minute
* tokens). Under PHP-FPM the cache lasts one request; in long-running
* workers it lasts the life of the worker.
* - 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.
*/
declare(strict_types=1);
/** Thrown when a key file is missing or invalid, or a token request fails. */
final class EnfonicaCredentialsException extends RuntimeException
{
}
/** Service account credentials that produce cached Enfonica access tokens. */
final class EnfonicaCredentials
{
private const SCOPE = 'https://api.enfonica.com/auth/platform';
private const GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer';
private const ENV_VAR = 'ENFONICA_APPLICATION_CREDENTIALS';
private const JWT_LIFETIME_SECONDS = 3600;
private const TOKEN_REFRESH_SECONDS = 30 * 60;
private const HTTP_TIMEOUT_SECONDS = 30;
private const REQUIRED_FIELDS = ['type', 'project_id', 'private_key_id', 'private_key', 'client_email', 'token_uri'];
public readonly string $projectId;
public readonly string $clientEmail;
/** Always read from the key file, never hardcoded. */
public readonly string $tokenUri;
private readonly string $privateKeyId;
private readonly OpenSSLAsymmetricKey $privateKey;
private ?string $token = null;
private float $fetchedAt = 0.0;
/** Load the key file named by the ENFONICA_APPLICATION_CREDENTIALS environment variable. */
public static function fromEnv(): self
{
$path = getenv(self::ENV_VAR);
if ($path === false || $path === '') {
throw new EnfonicaCredentialsException(self::ENV_VAR . ' is not set. Set it to the path of a service '
. 'account key file, or use fromServiceAccountFile() / fromServiceAccountInfo().');
}
return self::fromServiceAccountFile($path);
}
/** Load a service account key file from disk. */
public static function fromServiceAccountFile(string $path): self
{
$json = is_readable($path) ? file_get_contents($path) : false;
if ($json === false) {
throw new EnfonicaCredentialsException("Could not read key file '{$path}'");
}
return self::fromServiceAccountInfo($json);
}
/**
* Load a service account key from its JSON text or an already-decoded array.
*
* @param string|array<string, mixed> $info
*/
public static function fromServiceAccountInfo(string|array $info): self
{
if (is_string($info)) {
try {
$info = json_decode($info, true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new EnfonicaCredentialsException('Invalid key file: not valid JSON: ' . $e->getMessage(), 0, $e);
}
if (!is_array($info) || array_is_list($info)) {
throw new EnfonicaCredentialsException('Invalid key file: expected a JSON object');
}
}
return new self($info);
}
/** @param array<string, mixed> $info */
private function __construct(array $info)
{
if (($info['type'] ?? null) !== 'service_account') {
throw new EnfonicaCredentialsException("Invalid key file: expected type 'service_account', got '"
. (is_string($info['type'] ?? null) ? $info['type'] : '') . "'");
}
$missing = array_filter(
self::REQUIRED_FIELDS,
static fn (string $f): bool => !is_string($info[$f] ?? null) || $info[$f] === '',
);
if ($missing !== []) {
throw new EnfonicaCredentialsException('Invalid key file: missing field(s): ' . implode(', ', $missing));
}
$this->projectId = $info['project_id'];
$this->clientEmail = $info['client_email'];
$this->tokenUri = $info['token_uri'];
$this->privateKeyId = $info['private_key_id'];
$this->privateKey = self::loadPrivateKey($info['private_key']);
}
/** Return a valid access token, fetching a new one if none is cached or it is 30+ minutes old. */
public function getAccessToken(): string
{
$now = hrtime(true) / 1e9;
if ($this->token === null || $now - $this->fetchedAt >= self::TOKEN_REFRESH_SECONDS) {
$this->token = $this->fetchToken();
$this->fetchedAt = hrtime(true) / 1e9;
}
return $this->token;
}
/** Return the value for the HTTP Authorization header: "Bearer <token>". */
public function getAuthorizationHeader(): string
{
return 'Bearer ' . $this->getAccessToken();
}
private function fetchToken(): string
{
$curl = curl_init($this->tokenUri);
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query([
'grant_type' => self::GRANT_TYPE,
'assertion' => $this->signedAssertion(),
]),
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded', 'Accept: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => self::HTTP_TIMEOUT_SECONDS,
]);
$body = curl_exec($curl);
$error = curl_error($curl);
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
if ($body === false) {
throw new EnfonicaCredentialsException("Token request to {$this->tokenUri} failed: {$error}");
}
if ($status < 200 || $status > 299) {
throw new EnfonicaCredentialsException("Token request failed: HTTP {$status} from {$this->tokenUri}: {$body}");
}
$decoded = json_decode((string) $body, true);
$token = is_array($decoded) ? ($decoded['access_token'] ?? null) : null;
if (!is_string($token) || $token === '') {
throw new EnfonicaCredentialsException("Token response was malformed: {$body}");
}
return $token;
}
/** Build the RS256-signed JWT assertion (RFC 7523 §3). */
private function signedAssertion(): string
{
$now = time();
$header = ['alg' => 'RS256', 'typ' => 'JWT', 'kid' => $this->privateKeyId];
$payload = [
'iss' => $this->clientEmail,
'aud' => $this->tokenUri,
'scope' => self::SCOPE,
'iat' => $now,
'exp' => $now + self::JWT_LIFETIME_SECONDS,
];
$signingInput = self::base64Url(json_encode($header, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR))
. '.' . self::base64Url(json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
if (!openssl_sign($signingInput, $signature, $this->privateKey, OPENSSL_ALGO_SHA256)) {
throw new EnfonicaCredentialsException('Could not sign JWT assertion: ' . openssl_error_string());
}
return $signingInput . '.' . self::base64Url($signature);
}
// Re-wrap the PEM body at 64 characters per line before parsing: OpenSSL
// rejects keys wrapped at other widths even though the key material is valid.
private static function loadPrivateKey(string $pem): OpenSSLAsymmetricKey
{
$body = preg_replace('/-----(BEGIN|END) [A-Z ]+-----|\s+/', '', $pem);
$normalized = "-----BEGIN PRIVATE KEY-----\n" . chunk_split($body, 64, "\n") . "-----END PRIVATE KEY-----\n";
$key = openssl_pkey_get_private($normalized);
if ($key === false) {
throw new EnfonicaCredentialsException(
'Invalid key file: private_key is not a valid PKCS#8 RSA key: ' . openssl_error_string());
}
return $key;
}
private static function base64Url(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
}
# frozen_string_literal: true
# enfonica_credentials.rb
#
# Standalone service-account authentication for the Enfonica API.
#
# Implements the OAuth2 JWT Bearer Grant flow (RFC 7523): signs a short-lived
# JWT with your service account's RSA private key and exchanges it for a
# bearer access token at the Enfonica token server.
#
# Dependencies: the `jwt` gem (https://rubygems.org/gems/jwt) — everything
# else is Ruby standard library.
#
# gem install jwt
#
# Usage:
#
# require_relative "enfonica_credentials"
#
# # From an explicit key file path:
# credentials = Enfonica::Credentials.from_file("/path/to/keyfile.json")
#
# # Or from the ENFONICA_APPLICATION_CREDENTIALS environment variable:
# credentials = Enfonica::Credentials.from_env
#
# # Then, on every API request:
# request["Authorization"] = credentials.authorization_header
#
# Token lifetime / caching:
# - The token server issues tokens valid for 60 minutes.
# - Tokens are cached in memory and refreshed proactively after 30 minutes.
# - No token state is written to disk.
# - 401 responses from the API are hard errors, NOT refresh triggers.
# - Instances are thread-safe; share one instance across your application.
require "json"
require "net/http"
require "openssl"
require "uri"
require "jwt"
module Enfonica
# Raised when a key file is missing or invalid, or a token request fails.
class CredentialsError < StandardError; end
# Loads an Enfonica service account key and exchanges it for bearer access
# tokens, caching them in memory.
#
# Key file format:
#
# {
# "type": "service_account",
# "project_id": "my-project",
# "private_key_id": "cf53e0bde8cd6603a9860380a16be3e2985c3701",
# "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEv...\n-----END PRIVATE KEY-----\n",
# "client_email": "my-sa@my-project.iam.enfonica.com",
# "token_uri": "https://oauth2.api.enfonica.com/token"
# }
class Credentials
# The fixed OAuth2 scope used for all Enfonica APIs.
SCOPE = "https://api.enfonica.com/auth/platform"
GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"
# Lifetime of the signed JWT assertion.
JWT_LIFETIME_SECONDS = 3600
# Refresh the cached token once it is this old. The server issues tokens
# valid for 60 minutes; 30 minutes provides a comfortable safety margin.
TOKEN_REFRESH_SECONDS = 30 * 60
REQUIRED_FIELDS = %w[
type project_id private_key_id private_key client_email token_uri
].freeze
attr_reader :project_id, :client_email, :token_uri
# Load credentials from the path named by the
# ENFONICA_APPLICATION_CREDENTIALS environment variable.
def self.from_env
path = ENV["ENFONICA_APPLICATION_CREDENTIALS"]
if path.nil? || path.empty?
raise CredentialsError,
"No credentials provided. Either pass a key file path to " \
"Enfonica::Credentials.from_file, pass key data to " \
"Enfonica::Credentials.from_hash, or set the " \
"ENFONICA_APPLICATION_CREDENTIALS environment variable to the " \
"path of a service account key file."
end
from_file(path)
end
# Load credentials from a service account key file on disk.
def self.from_file(path)
json = begin
File.read(path)
rescue SystemCallError => e
raise CredentialsError, "Could not read key file '#{path}': #{e.message}"
end
from_json(json)
end
# Load credentials from a JSON string.
def self.from_json(json)
data = begin
JSON.parse(json)
rescue JSON::ParserError => e
raise CredentialsError, "Invalid key file: not valid JSON: #{e.message}"
end
from_hash(data)
end
# Load credentials from an already-parsed Hash with string keys.
def self.from_hash(data)
new(data)
end
def initialize(data)
unless data.is_a?(Hash)
raise CredentialsError,
"Invalid key file: expected a Hash, got #{data.class}"
end
unless data["type"] == "service_account"
raise CredentialsError,
"Invalid key file: expected type 'service_account', " \
"got '#{data["type"]}'"
end
missing = REQUIRED_FIELDS.reject { |f| data[f].is_a?(String) && !data[f].empty? }
unless missing.empty?
raise CredentialsError,
"Invalid key file: missing required field(s): #{missing.join(", ")}"
end
@project_id = data["project_id"]
@private_key_id = data["private_key_id"]
@client_email = data["client_email"]
@token_uri = data["token_uri"]
# JSON.parse has already converted the \n escape sequences stored in the
# key file into real newlines. The body is re-wrapped at 64 characters
# per line before parsing: some key files carry longer base64 lines,
# which strict PEM parsers reject even though the key material is valid.
begin
@private_key = OpenSSL::PKey::RSA.new(normalize_pem(data["private_key"]))
rescue OpenSSL::PKey::RSAError => e
raise CredentialsError,
"Invalid key file: private_key is not a valid RSA PEM: #{e.message}"
end
@mutex = Mutex.new
@token = nil
@fetched_at = nil
end
# Returns a valid bearer access token, fetching or refreshing as needed.
def access_token
@mutex.synchronize do
if @token.nil? || monotonic_now - @fetched_at >= TOKEN_REFRESH_SECONDS
@token = fetch_token
@fetched_at = monotonic_now
end
@token
end
end
# Convenience: the value for an HTTP +Authorization+ header.
def authorization_header
"Bearer #{access_token}"
end
private
# Build and sign the JWT assertion, then exchange it for a bearer token:
#
# POST <token_uri>
# Content-Type: application/x-www-form-urlencoded
# grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=<jwt>
def fetch_token
uri = URI(@token_uri)
request = Net::HTTP::Post.new(uri)
request.set_form_data(
"grant_type" => GRANT_TYPE,
"assertion" => signed_assertion
)
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
http.request(request)
end
unless response.is_a?(Net::HTTPSuccess)
raise CredentialsError,
"Enfonica token request failed: HTTP #{response.code} " \
"from #{@token_uri}: #{response.body}"
end
begin
JSON.parse(response.body).fetch("access_token")
rescue JSON::ParserError, KeyError => e
raise CredentialsError,
"Enfonica token response was malformed: #{e.message}. " \
"Body: #{response.body.inspect}"
end
end
# JWT payload per RFC 7523 §3, signed RS256 with the key file's private
# key; the +kid+ header carries the key file's private_key_id.
def signed_assertion
now = Time.now.to_i
payload = {
iss: @client_email,
aud: @token_uri,
scope: SCOPE,
iat: now,
exp: now + JWT_LIFETIME_SECONDS
}
JWT.encode(payload, @private_key, "RS256", { typ: "JWT", kid: @private_key_id })
end
def monotonic_now
Process.clock_gettime(Process::CLOCK_MONOTONIC)
end
# Rebuild the PEM with its base64 body wrapped at 64 characters per line,
# as RFC 7468 specifies. Tolerates input wrapped at other widths, on a
# single line, with CRLF endings, or with stray whitespace.
def normalize_pem(pem)
match = pem.match(/(-----BEGIN [A-Z ]+-----)(.*?)(-----END [A-Z ]+-----)/m)
return pem unless match
header, body, footer = match.captures
"#{header}\n#{body.gsub(/\s+/, "").scan(/.{1,64}/).join("\n")}\n#{footer}\n"
end
end
end
Make a request¶
Send the access token in the Authorization header of each request. This request lists the phone numbers in a project with the Phone Numbers REST API. Replace my-project with your project ID:
curl -H "Authorization: Bearer $ACCESS_TOKEN" \
https://numbering.api.enfonica.com/v1beta1/projects/my-project/phoneNumberInstances
In your app, set the header to the value that the sample's authorization header method returns, such as authorization_header() in Python. Over gRPC, send the same value in the authorization metadata. Each product has its own host, such as numbering.api.enfonica.com, which serves both REST and gRPC on port 443.
If a request returns 401 Unauthorized or 403 Forbidden, don't retry it with a new token. The key, the project, or the service account's role is wrong. For what each error means, see What do common errors mean?
How the token exchange works¶
To write the exchange in another language, follow these steps. The samples do the same.
- Read the key file. It's a JSON object with
typeset toservice_account, and the fieldsproject_id,private_key_id,private_key,client_email, andtoken_uri. - Build a JWT, and sign it with RS256 using
private_key, a PKCS #8 RSA key in PEM format.- Header:
algisRS256,typisJWT, andkidis the key'sprivate_key_id. - Claims:
issis the key'sclient_email,audis the key'stoken_uri,scopeishttps://api.enfonica.com/auth/platform,iatis the current time, andexpis one hour later, both in seconds since the Unix epoch. One hour is the longest that the token endpoint allows.
- Header:
- Send a
POSTrequest to the key'stoken_uri, with a form-encoded body (application/x-www-form-urlencoded) of two fields:grant_type:urn:ietf:params:oauth:grant-type:jwt-bearerassertion: the signed JWT
- Read
access_tokenfrom the JSON response. The token expires at theexptime in your JWT. Keep it in memory, and get a new one halfway toexp, which is after 30 minutes for a one-hour JWT.
If the token request fails, the response is 400 Bad Request with a JSON body that has error and error_description fields, such as invalid_grant and Invalid JWT signature.
This is the OAuth 2.0 JWT bearer grant, defined in RFC 7523. Always read token_uri from the key file, rather than writing it into your code. Send the access token to the API, not the signed JWT: the API rejects a JWT that you signed yourself.
Authenticate with a bearer token from Enfonica support¶
Some tools can send only a fixed Authorization header, so they can't exchange a key for a token. For these, Enfonica support can provision a bearer token that lasts two years. The token belongs to a service account, and has the same access as that service account. To request one, see Can I call the REST API from Postman or a no-code tool?
Send the token in the same Authorization: Bearer header as an access token. Store it as you would a password. For an app that you write, use a service account key instead, because you can create and delete keys yourself in the Console.
Client libraries¶
Enfonica also publishes client libraries for .NET and Node.js. They read the service account key from the ENFONICA_APPLICATION_CREDENTIALS environment variable, and get access tokens for you.