Created
January 22, 2026 19:17
-
-
Save stahnma/aab60979eabbe8df734e7f7b1653c44a to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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