diff options
| author | Xe Iaso <me@xeiaso.net> | 2025-04-03 19:58:52 -0400 |
|---|---|---|
| committer | Xe Iaso <me@xeiaso.net> | 2025-04-03 19:58:52 -0400 |
| commit | 5b1a06499de6bbc1a11601796b3d507e3cded6da (patch) | |
| tree | 8d00dd6b322d8a9018a0886fbb75dc272795a79b | |
| parent | 196e188328e3f9c8e711a862b3029535e0cdbf20 (diff) | |
| download | x-5b1a06499de6bbc1a11601796b3d507e3cded6da.tar.xz x-5b1a06499de6bbc1a11601796b3d507e3cded6da.zip | |
oops
Signed-off-by: Xe Iaso <me@xeiaso.net>
| -rw-r--r-- | .github/workflows/package-builds.yml | 2 | ||||
| -rw-r--r-- | web/ollama/hallucinate.go | 119 | ||||
| -rw-r--r-- | web/pocketid/pocketid.go | 1929 |
3 files changed, 2049 insertions, 1 deletions
diff --git a/.github/workflows/package-builds.yml b/.github/workflows/package-builds.yml index 7a71696..4d55f39 100644 --- a/.github/workflows/package-builds.yml +++ b/.github/workflows/package-builds.yml @@ -5,7 +5,7 @@ on: types: [published] permissions: - contents: read + contents: write actions: write jobs: diff --git a/web/ollama/hallucinate.go b/web/ollama/hallucinate.go new file mode 100644 index 0000000..ac5a7f3 --- /dev/null +++ b/web/ollama/hallucinate.go @@ -0,0 +1,119 @@ +//go:build ignore + +package ollama + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "time" + + "github.com/swaggest/jsonschema-go" + "within.website/x/valid" + "within.website/x/web" +) + +// Hallucinate prompts the model to hallucinate a "valid" JSON response to the given input. +func Hallucinate[T valid.Interface](ctx context.Context, c *Client, opts HallucinateOpts) (*T, error) { + reflector := jsonschema.Reflector{} + + schema, err := reflector.Reflect(new(T)) + if err != nil { + return nil, err + } + + inp := &CompleteRequest{ + Model: opts.Model, + Messages: opts.Messages, + Format: p("json"), + Stream: true, + KeepAlive: (9999 * time.Minute).String(), + } + tries := 0 + for tries <= 5 { + tries++ + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + buf := &bytes.Buffer{} + if err := json.NewEncoder(buf).Encode(inp); err != nil { + return nil, fmt.Errorf("ollama: error encoding request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/chat", buf) + if err != nil { + return nil, fmt.Errorf("ollama: error creating request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("ollama: error making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, web.NewError(http.StatusOK, resp) + } + + whitespaceCount := 0 + + dec := json.NewDecoder(resp.Body) + buf = bytes.NewBuffer(nil) + + for { + var cr CompleteResponse + err := dec.Decode(&cr) + if err != nil { + if !errors.Is(err, io.EOF) { + return nil, fmt.Errorf("ollama: error decoding response: %w", err) + } else { + break + } + } + + //slog.Debug("got response", "response", cr.Message.Content) + + if _, err := fmt.Fprint(buf, cr.Message.Content); err != nil { + return nil, fmt.Errorf("ollama: error writing response to buffer: %w", err) + } + + for _, r := range cr.Message.Content { + if r == '\n' { + whitespaceCount++ + } + } + + if whitespaceCount > 10 { + cancel() + } + + //slog.Debug("buffer is now", "buffer", buf.String()) + + var result T + if err := json.NewDecoder(bytes.NewBuffer(buf.Bytes())).Decode(&result); err != nil { + //slog.Debug("error decoding response", "err", err) + continue + } + + if err := result.Valid(); err != nil { + slog.Debug("error validating response", "err", err) + continue + } + + //slog.Debug("got valid response", "response", result) + cancel() + + return &result, nil + } + } + + return nil, fmt.Errorf("ollama: failed to hallucinate a valid response after 5 tries") +} diff --git a/web/pocketid/pocketid.go b/web/pocketid/pocketid.go new file mode 100644 index 0000000..60066d6 --- /dev/null +++ b/web/pocketid/pocketid.go @@ -0,0 +1,1929 @@ +// Package pocketid provides a Go client for interacting with the PocketID API. +// +// PocketID is an identity provider for your homelab. This client allows you to +// manage users, groups, OIDC clients, API keys, and more. +// +// The client uses a standard HTTP client for all API interactions, allowing for +// customization of timeouts, transport, and other settings. +// +// This was generated from the Swagger docs with Google Gemini. +package pocketid + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// Client is the main client for interacting with the PocketID API. +// +// Use NewClient to create a new instance. +type Client struct { + // BaseURL is the base URL for the PocketID API. + // + // For example: "https://pocket-id.example.com". + BaseURL string + + // HTTPClient is the underlying HTTP client used for API requests. + // + // This allows for customization of timeouts, transport, and other settings. + HTTPClient *http.Client + + // apiKey is the API key used for authentication. + apiKey string +} + +// NewClient creates a new PocketID client. +// +// baseURL is the base URL for the PocketID API (e.g., "https://pocket-id.example.com"). +// apiKey is the API key to use for authentication. +// httpClient is an optional *http.Client to use for requests. If nil, http.DefaultClient will be used. +func NewClient(baseURL string, apiKey string, httpClient *http.Client) *Client { + if httpClient == nil { + httpClient = http.DefaultClient + } + + return &Client{ + BaseURL: baseURL, + HTTPClient: httpClient, + apiKey: apiKey, + } +} + +// ErrorResponse represents a generic error response from the PocketID API. +type ErrorResponse struct { + Error string `json:"error"` +} + +// setAPIKeyHeader sets the X-API-KEY header on the given request. +func (c *Client) setAPIKeyHeader(req *http.Request) { + if c.apiKey != "" { + req.Header.Set("X-API-KEY", c.apiKey) + } +} + +// Helper to decode to a Paginated type +func decodePaginated[T any](resp *http.Response, paginated *Paginated[T]) error { + return json.NewDecoder(resp.Body).Decode(paginated) +} + +// request performs an HTTP request to the PocketID API. +func (c *Client) request(method, path string, body io.Reader, contentType string) (*http.Response, error) { + req, err := http.NewRequest(method, fmt.Sprintf("%s%s", c.BaseURL, path), body) + if err != nil { + return nil, err + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + c.setAPIKeyHeader(req) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, err + } + + return resp, nil +} + +// --- Well Known --- + +// GetJWKS returns the JSON Web Key Set used for token verification. +// +// See https://pocket-id.example.com/.well-known/jwks.json +func (c *Client) GetJWKS() (map[string]interface{}, error) { + resp, err := c.request("GET", "/.well-known/jwks.json", nil, "") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var jwks map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&jwks) + if err != nil { + return nil, err + } + + return jwks, nil +} + +// GetOpenIDConfiguration returns the OpenID Connect discovery document. +// +// See https://pocket-id.example.com/.well-known/openid-configuration +func (c *Client) GetOpenIDConfiguration() (map[string]interface{}, error) { + resp, err := c.request("GET", "/.well-known/openid-configuration", nil, "") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var config map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&config) + if err != nil { + return nil, err + } + + return config, nil +} + +// --- API Keys --- + +// ApiKeyCreateDto represents the request body for creating a new API key. +type ApiKeyCreateDto struct { + Name string `json:"name" validate:"required,min=3,max=50"` + Description string `json:"description"` + ExpiresAt time.Time `json:"expiresAt" validate:"required"` +} + +// Valid validates the ApiKeyCreateDto fields. +func (t ApiKeyCreateDto) Valid() error { + var errs []error + + if len(t.Name) < 3 || len(t.Name) > 50 { + errs = append(errs, fmt.Errorf("name must be between 3 and 50 characters")) + } + + if t.ExpiresAt.IsZero() { + errs = append(errs, fmt.Errorf("expiresAt is required")) + } + + if len(errs) != 0 { + return fmt.Errorf("validation errors: %v", errs) + } + + return nil +} + +// ApiKeyDto represents an API key. +type ApiKeyDto struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt string `json:"createdAt"` + ExpiresAt string `json:"expiresAt"` + LastUsedAt string `json:"lastUsedAt"` +} + +// ApiKeyResponseDto represents the response body when creating a new API key. +type ApiKeyResponseDto struct { + ApiKey *ApiKeyDto `json:"apiKey"` + Token string `json:"token"` +} + +// Paginated represents a paginated response. +type Paginated[T any] struct { + Data []T `json:"data"` + Pagination Pagination `json:"pagination"` +} + +// Pagination represents pagination metadata. +type Pagination struct { + CurrentPage int `json:"currentPage"` + ItemsPerPage int `json:"itemsPerPage"` + TotalItems int `json:"totalItems"` + TotalPages int `json:"totalPages"` +} + +// ListAPIKeys gets a paginated list of API keys belonging to the current user. +// +// See https://pocket-id.example.com/api-keys +func (c *Client) ListAPIKeys(page, limit int, sortColumn, sortDirection string) (*Paginated[ApiKeyDto], error) { + params := url.Values{} + params.Set("page", strconv.Itoa(page)) + params.Set("limit", strconv.Itoa(limit)) + params.Set("sort_column", sortColumn) + params.Set("sort_direction", sortDirection) + + resp, err := c.request("GET", fmt.Sprintf("/api-keys?%s", params.Encode()), nil, "") + + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var apiKeys Paginated[ApiKeyDto] + if err := decodePaginated(resp, &apiKeys); err != nil { + return nil, err + } + + return &apiKeys, nil +} + +// CreateAPIKey creates a new API key for the current user. +// +// See https://pocket-id.example.com/api-keys +func (c *Client) CreateAPIKey(apiKeyCreateDto ApiKeyCreateDto) (*ApiKeyResponseDto, error) { + if err := apiKeyCreateDto.Valid(); err != nil { + return nil, err + } + + body, err := json.Marshal(apiKeyCreateDto) + if err != nil { + return nil, err + } + + resp, err := c.request("POST", "/api-keys", bytes.NewBuffer(body), "application/json") + + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var apiKeyResponseDto ApiKeyResponseDto + err = json.NewDecoder(resp.Body).Decode(&apiKeyResponseDto) + if err != nil { + return nil, err + } + + return &apiKeyResponseDto, nil +} + +// RevokeAPIKey revokes (deletes) an existing API key by ID. +// +// See https://pocket-id.example.com/api-keys/{id} +func (c *Client) RevokeAPIKey(id string) error { + resp, err := c.request("DELETE", fmt.Sprintf("/api-keys/%s", id), nil, "") + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + return nil +} + +// --- Application Configuration --- + +// AppConfigUpdateDto represents the request body for updating application configuration. +type AppConfigUpdateDto struct { + AppName string `json:"appName" validate:"required,min=1,max=30"` + AllowOwnAccountEdit string `json:"allowOwnAccountEdit" validate:"required"` + EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" validate:"required"` + EmailOneTimeAccessEnabled string `json:"emailOneTimeAccessEnabled" validate:"required"` + EmailsVerified string `json:"emailsVerified" validate:"required"` + LdapEnabled string `json:"ldapEnabled" validate:"required"` + SessionDuration string `json:"sessionDuration" validate:"required"` + SmtpTls string `json:"smtpTls" validate:"required,oneof=none starttls tls"` + LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"` + LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"` + LdapAttributeGroupName string `json:"ldapAttributeGroupName"` + LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"` + LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"` + LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"` + LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"` + LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture"` + LdapAttributeUserUniqueIdentifier string `json:"ldapAttributeUserUniqueIdentifier"` + LdapAttributeUserUsername string `json:"ldapAttributeUserUsername"` + LdapBase string `json:"ldapBase"` + LdapBindDn string `json:"ldapBindDn"` + LdapBindPassword string `json:"ldapBindPassword"` + LdapSkipCertVerify string `json:"ldapSkipCertVerify"` + LdapUrl string `json:"ldapUrl"` + LdapUserGroupSearchFilter string `json:"ldapUserGroupSearchFilter"` + LdapUserSearchFilter string `json:"ldapUserSearchFilter"` + SmtpFrom string `json:"smtpFrom"` + SmtpHost string `json:"smtpHost"` + SmtpPassword string `json:"smtpPassword"` + SmtpPort string `json:"smtpPort"` + SmtpSkipCertVerify string `json:"smtpSkipCertVerify"` + SmtpUser string `json:"smtpUser"` +} + +// AppConfigVariableDto represents an application configuration variable. +type AppConfigVariableDto struct { + IsPublic bool `json:"isPublic"` + Key string `json:"key"` + Type string `json:"type"` + Value string `json:"value"` +} + +// PublicAppConfigVariableDto represents a public application configuration variable. +type PublicAppConfigVariableDto struct { + Key string `json:"key"` + Type string `json:"type"` + Value string `json:"value"` +} + +// ListPublicAppConfig gets all public application configurations. +// +// See https://pocket-id.example.com/application-configuration +func (c *Client) ListPublicAppConfig() ([]PublicAppConfigVariableDto, error) { + resp, err := c.request("GET", "/application-configuration", nil, "") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var config []PublicAppConfigVariableDto + err = json.NewDecoder(resp.Body).Decode(&config) + if err != nil { + return nil, err + } + + return config, nil +} + +// UpdateAppConfig updates application configuration settings. +// +// See https://pocket-id.example.com/application-configuration +func (c *Client) UpdateAppConfig(config AppConfigUpdateDto) ([]AppConfigVariableDto, error) { + body, err := json.Marshal(config) + if err != nil { + return nil, err + } + + resp, err := c.request("PUT", "/application-configuration", bytes.NewBuffer(body), "application/json") + + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var updatedConfig []AppConfigVariableDto + err = json.NewDecoder(resp.Body).Decode(&updatedConfig) + if err != nil { + return nil, err + } + + return updatedConfig, nil +} + +// ListAllAppConfig gets all application configurations, including private ones. +// +// See https://pocket-id.example.com/application-configuration/all +func (c *Client) ListAllAppConfig() ([]AppConfigVariableDto, error) { + resp, err := c.request("GET", "/application-configuration/all", nil, "") + + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var config []AppConfigVariableDto + err = json.NewDecoder(resp.Body).Decode(&config) + if err != nil { + return nil, err + } + + return config, nil +} + +// GetBackgroundImage gets the background image for the application. +// +// See https://pocket-id.example.com/application-configuration/background-image +func (c *Client) GetBackgroundImage() ([]byte, error) { + resp, err := c.request("GET", "/application-configuration/background-image", nil, "") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d - %w", resp.StatusCode, err) + } + + return io.ReadAll(resp.Body) +} + +// UpdateBackgroundImage updates the application background image. +// +// See https://pocket-id.example.com/application-configuration/background-image +func (c *Client) UpdateBackgroundImage(file io.Reader) error { + var b bytes.Buffer + w := multipart.NewWriter(&b) + fw, err := w.CreateFormFile("file", "background.png") // filename doesn't matter for this endpoint + if err != nil { + return err + } + if _, err = io.Copy(fw, file); err != nil { + return err + } + w.Close() + + resp, err := c.request("PUT", "/application-configuration/background-image", &b, w.FormDataContentType()) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + return nil +} + +// GetFavicon gets the favicon for the application. +// +// See https://pocket-id.example.com/application-configuration/favicon +func (c *Client) GetFavicon() ([]byte, error) { + resp, err := c.request("GET", "/application-configuration/favicon", nil, "") + + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + return io.ReadAll(resp.Body) +} + +// UpdateFavicon updates the application favicon. +// +// See https://pocket-id.example.com/application-configuration/favicon +func (c *Client) UpdateFavicon(file io.Reader) error { + var b bytes.Buffer + w := multipart.NewWriter(&b) + fw, err := w.CreateFormFile("file", "favicon.ico") + if err != nil { + return err + } + if _, err = io.Copy(fw, file); err != nil { + return err + } + w.Close() + + resp, err := c.request("PUT", "/application-configuration/favicon", &b, w.FormDataContentType()) + + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + return nil +} + +// GetLogo gets the logo image for the application. If isLight is true, the light mode logo is returned. +// +// See https://pocket-id.example.com/application-configuration/logo +func (c *Client) GetLogo(isLight bool) ([]byte, error) { + params := url.Values{} + params.Set("light", strconv.FormatBool(isLight)) + resp, err := c.request("GET", fmt.Sprintf("/application-configuration/logo?%s", params.Encode()), nil, "") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + return io.ReadAll(resp.Body) +} + +// UpdateLogo updates the application logo. If isLight is true, the light mode logo is updated. +// +// See https://pocket-id.example.com/application-configuration/logo +func (c *Client) UpdateLogo(file io.Reader, isLight bool) error { + var b bytes.Buffer + w := multipart.NewWriter(&b) + + // Add the 'light' parameter as a form field + if err := w.WriteField("light", strconv.FormatBool(isLight)); err != nil { + return err + } + + fw, err := w.CreateFormFile("file", "logo.png") // filename doesn't matter for this endpoint + if err != nil { + return err + } + if _, err = io.Copy(fw, file); err != nil { + return err + } + w.Close() + + resp, err := c.request("PUT", "/application-configuration/logo", &b, w.FormDataContentType()) + + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + return nil +} + +// SyncLDAP manually triggers LDAP synchronization. +// +// See https://pocket-id.example.com/application-configuration/sync-ldap +func (c *Client) SyncLDAP() error { + resp, err := c.request("POST", "/application-configuration/sync-ldap", nil, "") + + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + return nil +} + +// SendTestEmail sends a test email to verify email configuration. +// +// See https://pocket-id.example.com/application-configuration/test-email +func (c *Client) SendTestEmail() error { + resp, err := c.request("POST", "/application-configuration/test-email", nil, "") + + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + return nil +} + +// --- Audit Logs --- + +// AuditLogDto represents an audit log entry. +type AuditLogDto struct { + ID string `json:"id"` + UserID string `json:"userID"` + Event AuditLogEvent `json:"event"` + IPAddress string `json:"ipAddress"` + Device string `json:"device"` + City string `json:"city"` + Country string `json:"country"` + CreatedAt string `json:"createdAt"` + Data AuditLogData `json:"data"` // Custom type for nested JSON object +} + +// AuditLogEvent represents the type of event in an audit log. +type AuditLogEvent string + +// Constants for AuditLogEvent. +const ( + AuditLogEventSignIn AuditLogEvent = "SIGN_IN" + AuditLogEventOneTimeAccessTokenSignIn AuditLogEvent = "TOKEN_SIGN_IN" + AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION" + AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION" +) + +// AuditLogData is a custom type representing additional data in the AuditLogDto. +type AuditLogData map[string]string + +// ListAuditLogs gets a paginated list of audit logs for the current user. +// +// See https://pocket-id.example.com/audit-logs +func (c *Client) ListAuditLogs(page, limit int, sortColumn, sortDirection string) (*Paginated[AuditLogDto], error) { + params := url.Values{} + params.Set("page", strconv.Itoa(page)) + params.Set("limit", strconv.Itoa(limit)) + params.Set("sort_column", sortColumn) + params.Set("sort_direction", sortDirection) + + resp, err := c.request("GET", fmt.Sprintf("/audit-logs?%s", params.Encode()), nil, "") + + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var auditLogs Paginated[AuditLogDto] + err = decodePaginated(resp, &auditLogs) + if err != nil { + return nil, err + } + + return &auditLogs, nil +} + +// --- Custom Claims --- + +// CustomClaimCreateDto represents the request body for creating or updating custom claims. +type CustomClaimDto struct { + Key string `json:"key" validate:"required"` + Value string `json:"value" validate:"required"` +} + +// Valid validates the CustomClaimCreateDto fields. +func (t CustomClaimDto) Valid() error { + var errs []error + + if t.Key == "" { + errs = append(errs, fmt.Errorf("key is required")) + } + + if t.Value == "" { + errs = append(errs, fmt.Errorf("value is required")) + } + + if len(errs) != 0 { + return fmt.Errorf("validation errors: %v", errs) + } + + return nil +} + +// GetCustomClaimSuggestions gets a list of suggested custom claim names. +// +// See https://pocket-id.example.com/custom-claims/suggestions +func (c *Client) GetCustomClaimSuggestions() ([]string, error) { + resp, err := c.request("GET", "/custom-claims/suggestions", nil, "") + + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var suggestions []string + err = json.NewDecoder(resp.Body).Decode(&suggestions) + if err != nil { + return nil, err + } + + return suggestions, nil +} + +// UpdateCustomClaimsForUserGroup updates or creates custom claims for a specific user group. +// +// See https://pocket-id.example.com/custom-claims/user-group/{userGroupId} +func (c *Client) UpdateCustomClaimsForUserGroup(userGroupID string, claims []CustomClaimDto) ([]CustomClaimDto, error) { + body, err := json.Marshal(claims) + if err != nil { + return nil, err + } + + resp, err := c.request("PUT", fmt.Sprintf("/custom-claims/user-group/%s", userGroupID), bytes.NewBuffer(body), "application/json") + + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var updatedClaims []CustomClaimDto + err = json.NewDecoder(resp.Body).Decode(&updatedClaims) + if err != nil { + return nil, err + } + + return updatedClaims, nil +} + +// UpdateCustomClaimsForUser updates or creates custom claims for a specific user. +// +// See https://pocket-id.example.com/custom-claims/user/{userId} +func (c *Client) UpdateCustomClaimsForUser(userID string, claims []CustomClaimDto) ([]CustomClaimDto, error) { + body, err := json.Marshal(claims) + if err != nil { + return nil, err + } + + resp, err := c.request("PUT", fmt.Sprintf("/custom-claims/user/%s", userID), bytes.NewBuffer(body), "application/json") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var updatedClaims []CustomClaimDto + err = json.NewDecoder(resp.Body).Decode(&updatedClaims) + if err != nil { + return nil, err + } + + return updatedClaims, nil +} + +// --- OIDC --- + +// AuthorizationRequiredDto is used to check if authorization is required. +type AuthorizationRequiredDto struct { + ClientID string `json:"clientID" validate:"required"` + Scope string `json:"scope" validate:"required"` +} + +// AuthorizeOidcClientRequestDto represents the request body for authorizing an OIDC client. +type AuthorizeOidcClientRequestDto struct { + ClientID string `json:"clientID" validate:"required"` + Scope string `json:"scope" validate:"required"` + CallbackURL string `json:"callbackURL"` + CodeChallenge string `json:"codeChallenge"` + CodeChallengeMethod string `json:"codeChallengeMethod"` + Nonce string `json:"nonce"` +} + +// AuthorizeOidcClientResponseDto represents the response body for authorizing an OIDC client. +type AuthorizeOidcClientResponseDto struct { + CallbackURL string `json:"callbackURL"` + Code string `json:"code"` +} + +// OidcClientCreateDto represents the request body for creating an OIDC client. +type OidcClientCreateDto struct { + Name string `json:"name" validate:"required,max=50"` + CallbackURLs []string `json:"callbackURLs" validate:"required"` + IsPublic bool `json:"isPublic"` + LogoutCallbackURLs []string `json:"logoutCallbackURLs"` + PkceEnabled bool `json:"pkceEnabled"` +} + +// OidcClientDto represents an OIDC client. +type OidcClientDto struct { + ID string `json:"id"` + Name string `json:"name"` + CallbackURLs []string `json:"callbackURLs"` + IsPublic bool `json:"isPublic"` + LogoutCallbackURLs []string `json:"logoutCallbackURLs"` + PkceEnabled bool `json:"pkceEnabled"` + HasLogo bool `json:"hasLogo"` +} + +// OidcClientMetaDataDto represents OIDC client metadata. +type OidcClientMetaDataDto struct { + ID string `json:"id"` + Name string `json:"name"` + HasLogo bool `json:"hasLogo"` +} + +// OidcClientWithAllowedUserGroupsDto represents an OIDC client with allowed user groups. +type OidcClientWithAllowedUserGroupsDto struct { + ID string `json:"id"` + Name string `json:"name"` + CallbackURLs []string `json:"callbackURLs"` + IsPublic bool `json:"isPublic"` + LogoutCallbackURLs []string `json:"logoutCallbackURLs"` + PkceEnabled bool `json:"pkceEnabled"` + HasLogo bool `json:"hasLogo"` + AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"` +} + +// UserGroupDtoWithUserCount represents a user group with a user count. +type UserGroupDtoWithUserCount struct { + ID string `json:"id"` + Name string `json:"name"` + FriendlyName string `json:"friendlyName"` + CreatedAt string `json:"createdAt"` + LdapId string `json:"ldapId"` + CustomClaims []CustomClaimDto `json:"customClaims"` + UserCount int `json:"userCount"` +} + +// OidcUpdateAllowedUserGroupsDto represents the request body for updating allowed user groups. +type OidcUpdateAllowedUserGroupsDto struct { + UserGroupIds []string `json:"userGroupIds" validate:"required"` +} + +// CheckAuthorizationRequired checks if the user needs to confirm authorization for the client. +// +// See https://pocket-id.example.com/oidc/authorization-required +func (c *Client) CheckAuthorizationRequired(request AuthorizationRequiredDto) (bool, error) { + body, err := json.Marshal(request) + if err != nil { + return false, err + } + + resp, err := c.request("POST", "/oidc/authorization-required", bytes.NewBuffer(body), "application/json") + if err != nil { + return false, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return false, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var response struct { + AuthorizationRequired bool `json:"authorizationRequired"` + } + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return false, err + } + + return response.AuthorizationRequired, nil +} + +// AuthorizeOIDCClient starts the OIDC authorization process for a client. +// +// See https://pocket-id.example.com/oidc/authorize +func (c *Client) AuthorizeOIDCClient(request AuthorizeOidcClientRequestDto) (*AuthorizeOidcClientResponseDto, error) { + body, err := json.Marshal(request) + if err != nil { + return nil, err + } + + resp, err := c.request("POST", "/oidc/authorize", bytes.NewBuffer(body), "application/json") + + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var responseDto AuthorizeOidcClientResponseDto + err = json.NewDecoder(resp.Body).Decode(&responseDto) + if err != nil { + return nil, err + } + + return &responseDto, nil +} + +// ListOIDCClients gets a paginated list of OIDC clients. +// +// See https://pocket-id.example.com/oidc/clients +func (c *Client) ListOIDCClients(search string, page, limit int, sortColumn, sortDirection string) (*Paginated[OidcClientDto], error) { + params := url.Values{} + params.Set("search", search) + params.Set("page", strconv.Itoa(page)) + params.Set("limit", strconv.Itoa(limit)) + params.Set("sort_column", sortColumn) + params.Set("sort_direction", sortDirection) + + resp, err := c.request("GET", fmt.Sprintf("/oidc/clients?%s", params.Encode()), nil, "") + + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var clients Paginated[OidcClientDto] + err = decodePaginated(resp, &clients) + if err != nil { + return nil, err + } + + return &clients, nil +} + +// CreateOIDCClient creates a new OIDC client. +// +// See https://pocket-id.example.com/oidc/clients +func (c *Client) CreateOIDCClient(clientCreateDto OidcClientCreateDto) (*OidcClientWithAllowedUserGroupsDto, error) { + body, err := json.Marshal(clientCreateDto) + if err != nil { + return nil, err + } + + resp, err := c.request("POST", "/oidc/clients", bytes.NewBuffer(body), "application/json") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var clientDto OidcClientWithAllowedUserGroupsDto + err = json.NewDecoder(resp.Body).Decode(&clientDto) + if err != nil { + return nil, err + } + + return &clientDto, nil +} + +// DeleteOIDCClient deletes an OIDC client by ID. +// +// See https://pocket-id.example.com/oidc/clients/{id} +func (c *Client) DeleteOIDCClient(id string) error { + resp, err := c.request("DELETE", fmt.Sprintf("/oidc/clients/%s", id), nil, "") + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + return nil +} + +// GetOIDCClient gets deta |
