From 9effbff0caba9f113da00258db77b49d45b013fb Mon Sep 17 00:00:00 2001 From: GW228g <4115764+GW228g@users.noreply.github.com> Date: Wed, 1 Apr 2026 09:05:48 -0400 Subject: [PATCH 1/2] Fix Apple School Manager authentication --- README.md | 17 ++++++++++----- abmclient/client.go | 8 ++++---- cmd/access_token.go | 10 ++------- cmd/request.go | 10 ++------- cmd/root.go | 34 ++++++++++++++++++++++++++++-- config/config.go | 48 ++++++++++++++++++++++++++++++++++++++++++- config/config_test.go | 47 ++++++++++++++++++++++++++++++++++++++++++ settings.example.yaml | 6 ++++++ 8 files changed, 152 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index cf3edab..489a5c4 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,13 @@ cp settings.example.yaml settings.yaml See [settings.example.yaml](settings.example.yaml) for all options and documentation. +`abm.client_id` now drives the Apple API defaults automatically: + +- `BUSINESSAPI.*` uses `business.api` with `https://api-business.apple.com/` +- `SCHOOLAPI.*` uses `school.api` with `https://api-school.apple.com/` + +If Apple changes those requirements, you can override them with `abm.oauth_scope` and `abm.api_base_url`. + ## Usage ```bash @@ -124,11 +131,11 @@ axm2snipe sync -v --log-file /var/log/axm2snipe.log # JSON logs to file axm2snipe sync -v --log-format json --log-file /var/log/axm2snipe.log -# Print an ABM access token (useful for manual API testing with curl) +# Print an ABM/ASM access token (useful for manual API testing with curl) axm2snipe access-token -# Make an authenticated ABM API request -axm2snipe request https://mdmenrollment.apple.com/server/devices +# Make an authenticated ABM/ASM API request +axm2snipe request https://api-school.apple.com/v1/orgDevices ``` ### Commands @@ -139,8 +146,8 @@ axm2snipe request https://mdmenrollment.apple.com/server/devices | `download` | Download ABM/ASM data to local cache | | `setup` | Create AXM custom fields in Snipe-IT and save mappings to config | | `test` | Test connections to ABM/ASM and Snipe-IT | -| `access-token` | Print an ABM API access token | -| `request ` | Make an authenticated ABM API GET request | +| `access-token` | Print an ABM/ASM API access token | +| `request ` | Make an authenticated ABM/ASM API GET request | ### Global Flags diff --git a/abmclient/client.go b/abmclient/client.go index d8f8b31..6f464bf 100644 --- a/abmclient/client.go +++ b/abmclient/client.go @@ -35,20 +35,20 @@ type Client struct { abm *abm.Client } -// NewClient creates a new ABM client using the abm library for auth. +// NewClient creates a new ABM/ASM client using the abm library for auth. // Rate limiting and retry logic are handled by the upstream abm library. -func NewClient(ctx context.Context, clientID, keyID, privateKey string) (*Client, error) { +func NewClient(ctx context.Context, clientID, keyID, privateKey, scope, baseURL string) (*Client, error) { assertion, err := abm.NewAssertion(ctx, clientID, keyID, privateKey) if err != nil { return nil, fmt.Errorf("creating ABM assertion: %w", err) } - ts, err := abm.NewTokenSource(ctx, nil, clientID, assertion, "") + ts, err := abm.NewTokenSource(ctx, nil, clientID, assertion, scope) if err != nil { return nil, fmt.Errorf("creating ABM token source: %w", err) } - client, err := abm.NewClient(nil, ts) + client, err := abm.NewClientWithBaseURL(nil, ts, baseURL) if err != nil { return nil, fmt.Errorf("creating ABM client: %w", err) } diff --git a/cmd/access_token.go b/cmd/access_token.go index 80e984b..d973391 100644 --- a/cmd/access_token.go +++ b/cmd/access_token.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/spf13/cobra" - "github.com/CampusTech/abm" ) // NewAccessTokenCmd creates the access-token command. @@ -25,14 +24,9 @@ func runAccessToken(cmd *cobra.Command, args []string) error { ctx := context.Background() - assertion, err := abm.NewAssertion(ctx, Cfg.ABM.ClientID, Cfg.ABM.KeyID, Cfg.ABM.PrivateKeyValue()) + ts, err := newABMTokenSource(ctx) if err != nil { - return fmt.Errorf("creating ABM assertion: %w", err) - } - - ts, err := abm.NewTokenSource(ctx, nil, Cfg.ABM.ClientID, assertion, "") - if err != nil { - return fmt.Errorf("creating ABM token source: %w", err) + return err } token, err := ts.Token() diff --git a/cmd/request.go b/cmd/request.go index 5afb33e..5cebd46 100644 --- a/cmd/request.go +++ b/cmd/request.go @@ -7,7 +7,6 @@ import ( "net/http" "github.com/spf13/cobra" - "github.com/CampusTech/abm" "golang.org/x/oauth2" ) @@ -29,14 +28,9 @@ func runRequest(cmd *cobra.Command, args []string) error { ctx := context.Background() - assertion, err := abm.NewAssertion(ctx, Cfg.ABM.ClientID, Cfg.ABM.KeyID, Cfg.ABM.PrivateKeyValue()) + ts, err := newABMTokenSource(ctx) if err != nil { - return fmt.Errorf("creating ABM assertion: %w", err) - } - - ts, err := abm.NewTokenSource(ctx, nil, Cfg.ABM.ClientID, assertion, "") - if err != nil { - return fmt.Errorf("creating ABM token source: %w", err) + return err } client := oauth2.NewClient(ctx, ts) diff --git a/cmd/root.go b/cmd/root.go index c7705b3..cb94d15 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,8 +9,10 @@ import ( "strings" "syscall" + "github.com/CampusTech/abm" "github.com/sirupsen/logrus" "github.com/spf13/cobra" + "golang.org/x/oauth2" "github.com/CampusTech/axm2snipe/abmclient" "github.com/CampusTech/axm2snipe/config" @@ -184,14 +186,42 @@ func contextWithSignal() (context.Context, context.CancelFunc) { // newABMClient creates and returns a new ABM client from global config. func newABMClient(ctx context.Context) (*abmclient.Client, error) { - log.Info("Connecting to Apple Business Manager...") - client, err := abmclient.NewClient(ctx, Cfg.ABM.ClientID, Cfg.ABM.KeyID, Cfg.ABM.PrivateKeyValue()) + log.Infof("Connecting to Apple %s Manager...", appleManagerName()) + client, err := abmclient.NewClient( + ctx, + Cfg.ABM.ClientID, + Cfg.ABM.KeyID, + Cfg.ABM.PrivateKeyValue(), + Cfg.ABM.OAuthScopeValue(), + Cfg.ABM.APIBaseURLValue(), + ) if err != nil { return nil, fmt.Errorf("creating ABM client: %w", err) } return client, nil } +func newABMTokenSource(ctx context.Context) (oauth2.TokenSource, error) { + assertion, err := abm.NewAssertion(ctx, Cfg.ABM.ClientID, Cfg.ABM.KeyID, Cfg.ABM.PrivateKeyValue()) + if err != nil { + return nil, fmt.Errorf("creating ABM assertion: %w", err) + } + + ts, err := abm.NewTokenSource(ctx, nil, Cfg.ABM.ClientID, assertion, Cfg.ABM.OAuthScopeValue()) + if err != nil { + return nil, fmt.Errorf("creating ABM token source: %w", err) + } + + return ts, nil +} + +func appleManagerName() string { + if Cfg != nil && Cfg.ABM.IsSchoolManager() { + return "School" + } + return "Business" +} + // newSnipeClient creates and returns a new Snipe-IT client from global config. func newSnipeClient() (*snipe.Client, error) { log.Info("Connecting to Snipe-IT...") diff --git a/config/config.go b/config/config.go index 33589bd..1113c15 100644 --- a/config/config.go +++ b/config/config.go @@ -35,7 +35,9 @@ type SlackConfig struct { type ABMConfig struct { ClientID string `yaml:"client_id"` KeyID string `yaml:"key_id"` - PrivateKey string `yaml:"private_key"` // path to PEM file or raw PEM string + PrivateKey string `yaml:"private_key"` // path to PEM file or raw PEM string + OAuthScope string `yaml:"oauth_scope"` // optional explicit override; auto-detected from client_id when empty + APIBaseURL string `yaml:"api_base_url"` // optional explicit override; auto-detected from client_id when empty } // SnipeITConfig holds Snipe-IT API settings. @@ -129,6 +131,44 @@ func (c *ABMConfig) PrivateKeyValue() string { return "-----BEGIN EC PRIVATE KEY-----\n" + key + "\n-----END EC PRIVATE KEY-----\n" } +const ( + BusinessAPIClientPrefix = "BUSINESSAPI." + SchoolAPIClientPrefix = "SCHOOLAPI." + BusinessAPIScope = "business.api" + SchoolAPIScope = "school.api" + BusinessAPIBaseURL = "https://api-business.apple.com/" + SchoolAPIBaseURL = "https://api-school.apple.com/" +) + +// IsSchoolManager reports whether the configured client ID belongs to ASM. +func (c *ABMConfig) IsSchoolManager() bool { + return strings.HasPrefix(strings.TrimSpace(c.ClientID), SchoolAPIClientPrefix) +} + +// OAuthScopeValue returns the configured OAuth scope, auto-detecting a default +// from the client ID prefix when not explicitly set. +func (c *ABMConfig) OAuthScopeValue() string { + if v := strings.TrimSpace(c.OAuthScope); v != "" { + return v + } + if c.IsSchoolManager() { + return SchoolAPIScope + } + return BusinessAPIScope +} + +// APIBaseURLValue returns the configured API base URL, auto-detecting a +// default from the client ID prefix when not explicitly set. +func (c *ABMConfig) APIBaseURLValue() string { + if v := strings.TrimSpace(c.APIBaseURL); v != "" { + return v + } + if c.IsSchoolManager() { + return SchoolAPIBaseURL + } + return BusinessAPIBaseURL +} + // ValidateABM checks that ABM credentials are set. func (c *Config) ValidateABM() error { if c.ABM.ClientID == "" { @@ -140,6 +180,12 @@ func (c *Config) ValidateABM() error { if c.ABM.PrivateKey == "" { return fmt.Errorf("abm.private_key is required (file path, inline PEM, or bare base64)") } + if strings.TrimSpace(c.ABM.OAuthScopeValue()) == "" { + return fmt.Errorf("abm.oauth_scope could not be determined") + } + if strings.TrimSpace(c.ABM.APIBaseURLValue()) == "" { + return fmt.Errorf("abm.api_base_url could not be determined") + } return nil } diff --git a/config/config_test.go b/config/config_test.go index f273540..ca6e4b9 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -213,6 +213,53 @@ func TestValidateABM_MissingPrivateKey(t *testing.T) { } } +func TestABMConfig_AutoDetectsScopeAndBaseURL(t *testing.T) { + tests := []struct { + name string + cfg ABMConfig + want string + wantBase string + }{ + { + name: "school", + cfg: ABMConfig{ + ClientID: "SCHOOLAPI.test-id", + }, + want: SchoolAPIScope, + wantBase: SchoolAPIBaseURL, + }, + { + name: "business", + cfg: ABMConfig{ + ClientID: "BUSINESSAPI.test-id", + }, + want: BusinessAPIScope, + wantBase: BusinessAPIBaseURL, + }, + { + name: "override", + cfg: ABMConfig{ + ClientID: "SCHOOLAPI.test-id", + OAuthScope: "custom.scope", + APIBaseURL: "https://example.invalid/", + }, + want: "custom.scope", + wantBase: "https://example.invalid/", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.cfg.OAuthScopeValue(); got != tt.want { + t.Fatalf("OAuthScopeValue() = %q, want %q", got, tt.want) + } + if got := tt.cfg.APIBaseURLValue(); got != tt.wantBase { + t.Fatalf("APIBaseURLValue() = %q, want %q", got, tt.wantBase) + } + }) + } +} + func TestValidateSnipeIT_MissingURL(t *testing.T) { cfg := &Config{SnipeIT: SnipeITConfig{APIKey: "test", ManufacturerID: 1, DefaultStatusID: 1, CategoryID: 1}} if err := cfg.ValidateSnipeIT(); err == nil { diff --git a/settings.example.yaml b/settings.example.yaml index 7d041b1..b9af734 100644 --- a/settings.example.yaml +++ b/settings.example.yaml @@ -17,6 +17,12 @@ abm: # 3. Bare base64 (the key body without PEM headers — useful for env vars): # private_key: "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg..." # AXM_ABM_PRIVATE_KEY accepts all three formats as well. + # OAuth scope and API base URL are auto-detected from client_id: + # BUSINESSAPI.* -> business.api + https://api-business.apple.com/ + # SCHOOLAPI.* -> school.api + https://api-school.apple.com/ + # Optional explicit overrides: + # oauth_scope: "school.api" + # api_base_url: "https://api-school.apple.com/" snipe_it: # Snipe-IT instance URL (no trailing slash) From d32313618cfc0bcd8f4906f026e9ab648c1c527b Mon Sep 17 00:00:00 2001 From: GW228g <4115764+GW228g@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:25:46 -0400 Subject: [PATCH 2/2] Add ABM auth env overrides and fallback test --- config/config.go | 14 ++++++++++---- config/config_test.go | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/config/config.go b/config/config.go index 1113c15..b988d1e 100644 --- a/config/config.go +++ b/config/config.go @@ -82,19 +82,25 @@ func Load(path string) (*Config, error) { } // Environment variable overrides - if v := os.Getenv("AXM_ABM_CLIENT_ID"); v != "" { + if v := strings.TrimSpace(os.Getenv("AXM_ABM_CLIENT_ID")); v != "" { cfg.ABM.ClientID = v } - if v := os.Getenv("AXM_ABM_KEY_ID"); v != "" { + if v := strings.TrimSpace(os.Getenv("AXM_ABM_KEY_ID")); v != "" { cfg.ABM.KeyID = v } if v := os.Getenv("AXM_ABM_PRIVATE_KEY"); v != "" { cfg.ABM.PrivateKey = v } - if v := os.Getenv("AXM_SNIPE_URL"); v != "" { + if v := strings.TrimSpace(os.Getenv("AXM_ABM_OAUTH_SCOPE")); v != "" { + cfg.ABM.OAuthScope = v + } + if v := strings.TrimSpace(os.Getenv("AXM_ABM_API_BASE_URL")); v != "" { + cfg.ABM.APIBaseURL = v + } + if v := strings.TrimSpace(os.Getenv("AXM_SNIPE_URL")); v != "" { cfg.SnipeIT.URL = v } - if v := os.Getenv("AXM_SNIPE_API_KEY"); v != "" { + if v := strings.TrimSpace(os.Getenv("AXM_SNIPE_API_KEY")); v != "" { cfg.SnipeIT.APIKey = v } diff --git a/config/config_test.go b/config/config_test.go index ca6e4b9..9de944d 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -139,6 +139,8 @@ snipe_it: } t.Setenv("AXM_ABM_CLIENT_ID", "env-client-id") + t.Setenv("AXM_ABM_OAUTH_SCOPE", "env.scope") + t.Setenv("AXM_ABM_API_BASE_URL", "https://env.example.invalid/") t.Setenv("AXM_SNIPE_URL", "https://env.example.com") cfg, err := Load(path) @@ -149,6 +151,12 @@ snipe_it: if cfg.ABM.ClientID != "env-client-id" { t.Errorf("env override ABM.ClientID = %q, want env-client-id", cfg.ABM.ClientID) } + if cfg.ABM.OAuthScope != "env.scope" { + t.Errorf("env override ABM.OAuthScope = %q, want env.scope", cfg.ABM.OAuthScope) + } + if cfg.ABM.APIBaseURL != "https://env.example.invalid/" { + t.Errorf("env override ABM.APIBaseURL = %q", cfg.ABM.APIBaseURL) + } if cfg.SnipeIT.URL != "https://env.example.com" { t.Errorf("env override SnipeIT.URL = %q", cfg.SnipeIT.URL) } @@ -236,6 +244,14 @@ func TestABMConfig_AutoDetectsScopeAndBaseURL(t *testing.T) { want: BusinessAPIScope, wantBase: BusinessAPIBaseURL, }, + { + name: "unknown prefix defaults to business", + cfg: ABMConfig{ + ClientID: "UNKNOWN.test-id", + }, + want: BusinessAPIScope, + wantBase: BusinessAPIBaseURL, + }, { name: "override", cfg: ABMConfig{