package agent import ( "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" ) // ErrOutpostNotFound marks a search that returned no exactly-named outpost. var ErrOutpostNotFound = errors.New("outpost not found") // AuthentikClient talks to the Authentik REST API with a bearer API token. The // internal CA is in the OS trust store, so the default transport suffices. type AuthentikClient struct { BaseURL string Token string HTTP *http.Client } // NewAuthentikClient builds a client for the given Authentik base URL. func NewAuthentikClient(baseURL, token string) *AuthentikClient { return &AuthentikClient{BaseURL: strings.TrimRight(baseURL, "/"), Token: token, HTTP: httpClient} } // Outpost is the subset of Authentik's outpost object we need. type Outpost struct { PK string `json:"pk"` Name string `json:"name"` TokenIdentifier string `json:"token_identifier"` } // get issues an authenticated GET and decodes into out. Error text never // includes a successful response body, which may carry key material. func (c *AuthentikClient) get(path string, out any) error { req, err := http.NewRequest(http.MethodGet, c.BaseURL+path, nil) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+c.Token) req.Header.Set("Accept", "application/json") resp, err := c.HTTP.Do(req) if err != nil { return fmt.Errorf("authentik GET %s: %w", path, err) } defer func() { _ = resp.Body.Close() }() data, _ := io.ReadAll(resp.Body) if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("authentik GET %s: HTTP %d: %s", path, resp.StatusCode, strings.TrimSpace(string(data))) } if err := json.Unmarshal(data, out); err != nil { return fmt.Errorf("authentik GET %s: decoding response: %w", path, err) } return nil } // FindOutpost searches outpost instances and returns the one whose name matches // exactly (search is a substring match, so the exact name is re-checked here). func (c *AuthentikClient) FindOutpost(name string) (Outpost, error) { var out struct { Results []Outpost `json:"results"` } path := "/api/v3/outposts/instances/?search=" + url.QueryEscape(name) if err := c.get(path, &out); err != nil { return Outpost{}, err } for _, o := range out.Results { if o.Name == name { return o, nil } } return Outpost{}, fmt.Errorf("authentik outpost %q: %w (searched %d result(s))", name, ErrOutpostNotFound, len(out.Results)) } // TokenKey returns the key behind a token identifier // (GET /api/v3/core/tokens//view_key/). func (c *AuthentikClient) TokenKey(identifier string) (string, error) { var out struct { Key string `json:"key"` } path := "/api/v3/core/tokens/" + url.PathEscape(identifier) + "/view_key/" if err := c.get(path, &out); err != nil { return "", err } if out.Key == "" { return "", fmt.Errorf("authentik view_key for %q: response has no key field", identifier) } return out.Key, nil }