Skip to content

Instantly share code, notes, and snippets.

@stahnma
Created January 22, 2026 19:17
Show Gist options
  • Select an option

  • Save stahnma/aab60979eabbe8df734e7f7b1653c44a to your computer and use it in GitHub Desktop.

Select an option

Save stahnma/aab60979eabbe8df734e7f7b1653c44a to your computer and use it in GitHub Desktop.
package main
import (
"bufio"
"bytes"
"compress/gzip"
"context"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
)
type tokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
// getEnv returns env var if set, otherwise defaultValue.
func getEnv(key, defaultValue string) string {
if v, ok := os.LookupEnv(key); ok {
return v
}
return defaultValue
}
func main() {
log.SetFlags(0)
// Flags with env fallbacks
domainFlag := flag.String("domain", getEnv("AUTH0_DOMAIN", "flox"), "Auth0 domain (e.g. your-tenant.us.auth0.com or custom domain)")
clientIDFlag := flag.String("client-id", getEnv("AUTH0_CLIENT_ID", ""), "Auth0 M2M client ID")
clientSecretFlag := flag.String("client-secret", getEnv("AUTH0_CLIENT_SECRET", ""), "Auth0 M2M client secret")
timeoutFlag := flag.Duration("timeout", 15*time.Second, "HTTP timeout")
asOfDateFlag := flag.String("as-of-date", "", "Get user count as of this date (format: YYYY-MM-DD or RFC3339). Users created on or before this date will be counted.")
flag.Parse()
domain := *domainFlag
clientID := *clientIDFlag
clientSecret := *clientSecretFlag
if domain == "" {
log.Fatal("missing domain (set -domain or AUTH0_DOMAIN)")
}
if clientID == "" {
log.Fatal("missing client id (set -client-id or AUTH0_CLIENT_ID)")
}
if clientSecret == "" {
log.Fatal("missing client secret (set -client-secret or AUTH0_CLIENT_SECRET)")
}
ctx, cancel := context.WithTimeout(context.Background(), *timeoutFlag)
defer cancel()
httpClient := &http.Client{
Timeout: *timeoutFlag,
}
accessToken, err := getManagementToken(ctx, httpClient, domain, clientID, clientSecret)
if err != nil {
log.Fatalf("getting management token: %v", err)
}
var asOfDate *time.Time
if *asOfDateFlag != "" {
parsedDate, err := parseDate(*asOfDateFlag)
if err != nil {
log.Fatalf("parsing -as-of-date: %v", err)
}
asOfDate = &parsedDate
}
if err := fetchUsers(ctx, httpClient, domain, accessToken, asOfDate); err != nil {
log.Fatalf("fetching users: %v", err)
}
}
func getManagementToken(ctx context.Context, client *http.Client, domain, clientID, clientSecret string) (string, error) {
tokenURL := fmt.Sprintf("https://%s/oauth/token", domain)
body := map[string]string{
"client_id": clientID,
"client_secret": clientSecret,
"audience": fmt.Sprintf("https://%s/api/v2/", domain),
"grant_type": "client_credentials",
}
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(body); err != nil {
return "", fmt.Errorf("encoding token request body: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, &buf)
if err != nil {
return "", fmt.Errorf("creating token request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("calling token endpoint: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
slurp, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("token endpoint returned %s: %s", resp.Status, string(slurp))
}
var tr tokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
return "", fmt.Errorf("decoding token response: %w", err)
}
if tr.AccessToken == "" {
return "", fmt.Errorf("empty access_token in token response")
}
return tr.AccessToken, nil
}
// parseDate attempts to parse a date string in multiple formats
func parseDate(dateStr string) (time.Time, error) {
// Try RFC3339 format first (e.g., 2024-01-31T23:59:59Z)
if t, err := time.Parse(time.RFC3339, dateStr); err == nil {
return t, nil
}
// Try date-only format (YYYY-MM-DD) - set to end of day
if t, err := time.Parse("2006-01-02", dateStr); err == nil {
// Set to end of day in UTC
return time.Date(t.Year(), t.Month(), t.Day(), 23, 59, 59, 999999999, time.UTC), nil
}
return time.Time{}, fmt.Errorf("unable to parse date %q (supported formats: YYYY-MM-DD or RFC3339)", dateStr)
}
func fetchUsers(ctx context.Context, client *http.Client, domain, accessToken string, asOfDate *time.Time) error {
// Auth0 has a limitation: when using query filters, the total count is limited to 1,000
// even with include_totals=true and per_page=0. So we need to paginate through results
// to get an accurate count when a date filter is provided.
if asOfDate != nil {
return fetchUsersWithDateFilter(ctx, client, domain, accessToken, asOfDate)
}
// No date filter - use the simple approach with include_totals
usersURL := fmt.Sprintf("https://%s/api/v2/users?include_totals=true&per_page=0", domain)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, usersURL, nil)
if err != nil {
return fmt.Errorf("creating users request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("calling users endpoint: %w", err)
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("reading users response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("users endpoint returned %s: %s", resp.Status, string(bodyBytes))
}
// Parse JSON response to extract total
var response struct {
Total int `json:"total"`
}
if err := json.Unmarshal(bodyBytes, &response); err != nil {
return fmt.Errorf("parsing users response: %w", err)
}
// Output only the total
fmt.Println(response.Total)
return nil
}
func fetchUsersWithDateFilter(ctx context.Context, client *http.Client, domain, accessToken string, asOfDate *time.Time) error {
// Auth0 has a hard limit of 1,000 records for pagination, even without query filters.
// The only way to get accurate counts for large user bases is to use the Export Job API.
// This creates an async job that exports all users, which we then filter and count.
// Calculate the cutoff time: end of the specified date
cutoffTime := time.Date(asOfDate.Year(), asOfDate.Month(), asOfDate.Day(), 23, 59, 59, 999999999, time.UTC)
// Create export job
jobID, err := createExportJob(ctx, client, domain, accessToken)
if err != nil {
return fmt.Errorf("creating export job: %w", err)
}
// Wait for job to complete and get download URL
downloadURL, err := waitForExportJob(ctx, client, domain, accessToken, jobID)
if err != nil {
return fmt.Errorf("waiting for export job: %w", err)
}
// Download and process the export
count, err := downloadAndCountUsers(ctx, client, downloadURL, cutoffTime)
if err != nil {
return fmt.Errorf("downloading and counting users: %w", err)
}
fmt.Println(count)
return nil
}
func createExportJob(ctx context.Context, client *http.Client, domain, accessToken string) (string, error) {
jobURL := fmt.Sprintf("https://%s/api/v2/jobs/users-exports", domain)
body := map[string]interface{}{
"format": "json",
"fields": []map[string]interface{}{
{"name": "created_at"},
},
}
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(body); err != nil {
return "", fmt.Errorf("encoding job request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, jobURL, &buf)
if err != nil {
return "", fmt.Errorf("creating job request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("calling job endpoint: %w", err)
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("reading job response: %w", err)
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return "", fmt.Errorf("job endpoint returned %s: %s", resp.Status, string(bodyBytes))
}
var jobResponse struct {
ID string `json:"id"`
}
if err := json.Unmarshal(bodyBytes, &jobResponse); err != nil {
return "", fmt.Errorf("parsing job response: %w", err)
}
return jobResponse.ID, nil
}
func waitForExportJob(ctx context.Context, client *http.Client, domain, accessToken, jobID string) (string, error) {
jobURL := fmt.Sprintf("https://%s/api/v2/jobs/%s", domain, jobID)
for {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, jobURL, nil)
if err != nil {
return "", fmt.Errorf("creating job status request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("calling job status endpoint: %w", err)
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("reading job status response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("job status endpoint returned %s: %s", resp.Status, string(bodyBytes))
}
var jobStatus struct {
Status string `json:"status"`
Location string `json:"location"`
Type string `json:"type"`
Connection string `json:"connection"`
}
if err := json.Unmarshal(bodyBytes, &jobStatus); err != nil {
return "", fmt.Errorf("parsing job status response: %w", err)
}
switch jobStatus.Status {
case "completed":
if jobStatus.Location == "" {
return "", fmt.Errorf("job completed but no download URL provided")
}
return jobStatus.Location, nil
case "failed":
return "", fmt.Errorf("export job failed")
case "pending", "processing":
// Wait a bit and check again
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(2 * time.Second):
continue
}
default:
return "", fmt.Errorf("unknown job status: %s", jobStatus.Status)
}
}
}
func downloadAndCountUsers(ctx context.Context, client *http.Client, downloadURL string, cutoffTime time.Time) (int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return 0, fmt.Errorf("creating download request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return 0, fmt.Errorf("downloading export: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return 0, fmt.Errorf("download returned %s: %s", resp.Status, string(bodyBytes))
}
// Auth0 exports are typically gzipped, so we need to decompress
var reader io.Reader = resp.Body
// Check if content is gzipped (check Content-Encoding header or peek at first bytes)
contentEncoding := resp.Header.Get("Content-Encoding")
if contentEncoding == "gzip" {
gzReader, err := gzip.NewReader(resp.Body)
if err != nil {
return 0, fmt.Errorf("creating gzip reader: %w", err)
}
defer gzReader.Close()
reader = gzReader
} else {
// Also check magic bytes in case header is missing
peekBytes := make([]byte, 2)
n, err := resp.Body.Read(peekBytes)
if err != nil && err != io.EOF {
return 0, fmt.Errorf("peeking at content: %w", err)
}
// Check for gzip magic number: 0x1f 0x8b
if n >= 2 && peekBytes[0] == 0x1f && peekBytes[1] == 0x8b {
// It's gzipped, create a new reader with the peeked bytes
gzReader, err := gzip.NewReader(io.MultiReader(bytes.NewReader(peekBytes), resp.Body))
if err != nil {
return 0, fmt.Errorf("creating gzip reader: %w", err)
}
defer gzReader.Close()
reader = gzReader
} else {
// Not gzipped, use the peeked bytes + rest of body
reader = io.MultiReader(bytes.NewReader(peekBytes), resp.Body)
}
}
// Auth0 exports are newline-delimited JSON (NDJSON), one JSON object per line
// Parse line by line
scanner := bufio.NewScanner(reader)
count := 0
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var user map[string]interface{}
if err := json.Unmarshal(line, &user); err != nil {
// Skip invalid JSON lines
continue
}
createdAtStr, ok := user["created_at"].(string)
if !ok {
continue
}
createdAt, err := time.Parse(time.RFC3339, createdAtStr)
if err != nil {
// Try alternative format
createdAt, err = time.Parse("2006-01-02T15:04:05.000Z", createdAtStr)
if err != nil {
continue
}
}
if !createdAt.After(cutoffTime) {
count++
}
}
if err := scanner.Err(); err != nil {
return 0, fmt.Errorf("reading export data: %w", err)
}
return count, nil
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment