ultisuite-backend/internal/authentik/client.go
R3D347HR4Y e4549f29b2
Some checks are pending
CI / Go tests (push) Waiting to run
CI / Integration tests (push) Waiting to run
CI / DB migrations (push) Waiting to run
feat(authentik): implement password recovery flow and API integration
- Added a new blueprint for password recovery (`05-ulti-recovery.yaml`) to facilitate user password reset via email.
- Introduced a new API handler for managing Authentik flow sessions, including starting and responding to flows.
- Implemented flow session management with in-memory storage for tracking user sessions during the recovery process.
- Enhanced error handling for flow session operations and added unit tests for the new functionalities.
- Updated README to include the new recovery flow in the Authentik blueprints documentation.
2026-06-19 22:34:29 +02:00

298 lines
7.6 KiB
Go

package authentik
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type Client struct {
baseURL string
token string
httpClient *http.Client
}
func NewClient(baseURL, token string) *Client {
return &Client{
baseURL: APIBaseURL(baseURL),
token: token,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
type listResponse[T any] struct {
Results []T `json:"results"`
}
type flowRef struct {
PK string `json:"pk"`
Slug string `json:"slug"`
}
type oauth2Provider struct {
PK int `json:"pk"`
Name string `json:"name"`
ClientID string `json:"client_id"`
RedirectURIs string `json:"redirect_uris"`
}
type application struct {
PK int `json:"pk"`
Slug string `json:"slug"`
Name string `json:"name"`
Provider int `json:"provider"`
}
type scopeMapping struct {
PK string `json:"pk"`
ScopeName string `json:"scope_name"`
}
type certKeyPair struct {
PK string `json:"pk"`
Name string `json:"name"`
}
func (c *Client) Ping(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/v3/root/config/", nil)
if err != nil {
return err
}
c.authorize(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("authentik ping: %d", resp.StatusCode)
}
return nil
}
func (c *Client) authorize(req *http.Request) {
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
req.Header.Set("Accept", "application/json")
}
func (c *Client) FindApplicationBySlug(ctx context.Context, slug string) (*application, bool, error) {
q := url.Values{}
q.Set("slug", slug)
var out listResponse[application]
if err := c.getJSON(ctx, "/api/v3/core/applications/?"+q.Encode(), &out); err != nil {
return nil, false, err
}
if len(out.Results) == 0 {
return nil, false, nil
}
return &out.Results[0], true, nil
}
func (c *Client) FindOAuth2ProviderByName(ctx context.Context, name string) (*oauth2Provider, bool, error) {
q := url.Values{}
q.Set("name", name)
var out listResponse[oauth2Provider]
if err := c.getJSON(ctx, "/api/v3/providers/oauth2/?"+q.Encode(), &out); err != nil {
return nil, false, err
}
if len(out.Results) == 0 {
return nil, false, nil
}
return &out.Results[0], true, nil
}
func (c *Client) FindFlowBySlug(ctx context.Context, slug string) (string, error) {
q := url.Values{}
q.Set("slug", slug)
var out listResponse[flowRef]
if err := c.getJSON(ctx, "/api/v3/flows/instances/?"+q.Encode(), &out); err != nil {
return "", err
}
if len(out.Results) == 0 {
return "", fmt.Errorf("flow not found: %s", slug)
}
return out.Results[0].PK, nil
}
func (c *Client) FindScopeMappingPK(ctx context.Context, scopeName string) (string, error) {
q := url.Values{}
q.Set("scope_name", scopeName)
var out listResponse[scopeMapping]
if err := c.getJSON(ctx, "/api/v3/propertymappings/provider/oauth2/?"+q.Encode(), &out); err != nil {
return "", err
}
if len(out.Results) == 0 {
return "", fmt.Errorf("scope mapping not found: %s", scopeName)
}
return out.Results[0].PK, nil
}
func (c *Client) FindSigningKeyPK(ctx context.Context) (string, error) {
var out listResponse[certKeyPair]
if err := c.getJSON(ctx, "/api/v3/crypto/certificatekeypairs/", &out); err != nil {
return "", err
}
for _, k := range out.Results {
if strings.Contains(k.Name, "authentik") && strings.Contains(k.Name, "Certificate") {
return k.PK, nil
}
}
if len(out.Results) > 0 {
return out.Results[0].PK, nil
}
return "", fmt.Errorf("no signing key found")
}
type CreateOAuth2ProviderRequest struct {
Name string
ClientID string
ClientSecret string
RedirectURIs []string
AuthorizationFlowPK string
InvalidationFlowPK string
SigningKeyPK string
PropertyMappingPKs []string
OfflineAccess bool
}
func (c *Client) CreateOAuth2Provider(ctx context.Context, req CreateOAuth2ProviderRequest) (int, error) {
mappings := req.PropertyMappingPKs
if req.OfflineAccess {
if pk, err := c.FindScopeMappingPK(ctx, "offline_access"); err == nil {
mappings = append(mappings, pk)
}
}
body := map[string]any{
"name": req.Name,
"authorization_flow": req.AuthorizationFlowPK,
"invalidation_flow": req.InvalidationFlowPK,
"property_mappings": mappings,
"client_type": "confidential",
"client_id": req.ClientID,
"client_secret": req.ClientSecret,
"redirect_uris": joinRedirectURIs(req.RedirectURIs),
"signing_key": req.SigningKeyPK,
"access_token_validity": "hours=1",
"refresh_token_validity": "days=365",
}
var created oauth2Provider
if err := c.postJSON(ctx, "/api/v3/providers/oauth2/", body, &created); err != nil {
return 0, err
}
return created.PK, nil
}
func (c *Client) UpdateOAuth2ProviderRedirects(ctx context.Context, providerPK int, redirectURIs []string) error {
body := map[string]any{
"redirect_uris": joinRedirectURIs(redirectURIs),
}
return c.patchJSON(ctx, fmt.Sprintf("/api/v3/providers/oauth2/%d/", providerPK), body)
}
type CreateApplicationRequest struct {
Name string
Slug string
Group string
LaunchURL string
Provider int
}
func (c *Client) CreateApplication(ctx context.Context, req CreateApplicationRequest) (int, error) {
body := map[string]any{
"name": req.Name,
"slug": req.Slug,
"group": req.Group,
"provider": req.Provider,
"meta_launch_url": req.LaunchURL,
"policy_engine_mode": "any",
}
var created application
if err := c.postJSON(ctx, "/api/v3/core/applications/", body, &created); err != nil {
return 0, err
}
return created.PK, nil
}
func joinRedirectURIs(uris []string) string {
seen := make(map[string]struct{}, len(uris))
var lines []string
for _, u := range uris {
u = strings.TrimSpace(u)
if u == "" {
continue
}
if _, ok := seen[u]; ok {
continue
}
seen[u] = struct{}{}
lines = append(lines, u)
}
return strings.Join(lines, "\n")
}
func (c *Client) getJSON(ctx context.Context, path string, dest any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
if err != nil {
return err
}
c.authorize(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return decodeResponse(resp, dest)
}
func (c *Client) postJSON(ctx context.Context, path string, body any, dest any) error {
return c.doJSON(ctx, http.MethodPost, path, body, dest)
}
func (c *Client) patchJSON(ctx context.Context, path string, body any) error {
return c.doJSON(ctx, http.MethodPatch, path, body, nil)
}
func (c *Client) doJSON(ctx context.Context, method, path string, body any, dest any) error {
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
return err
}
reader = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
if err != nil {
return err
}
c.authorize(req)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return decodeResponse(resp, dest)
}
func decodeResponse(resp *http.Response, dest any) error {
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("authentik api %s: %d %s", resp.Request.URL.Path, resp.StatusCode, strings.TrimSpace(string(b)))
}
if dest == nil {
return nil
}
return json.NewDecoder(resp.Body).Decode(dest)
}