mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-08-20 20:06:33 +00:00
236 lines
7.7 KiB
Go
236 lines
7.7 KiB
Go
package apperror
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
type Code string
|
|
|
|
// #nosec G101 -- these are client-visible error codes, not credentials
|
|
const (
|
|
CodeInternal Code = "internal_error"
|
|
CodeValidationFailed Code = "validation_failed"
|
|
CodeInvalidRequestBody Code = "invalid_request_body"
|
|
CodeRequestTimeout Code = "request_timeout"
|
|
CodeNotFound Code = "not_found"
|
|
CodeAlreadyInUse Code = "already_in_use"
|
|
CodeForbidden Code = "forbidden"
|
|
CodeInvalidToken Code = "invalid_token"
|
|
CodeRateLimited Code = "rate_limited"
|
|
CodeFileTooLarge Code = "file_too_large"
|
|
CodeInvalidImage Code = "invalid_image"
|
|
CodeInvalidWebAuthnResponse Code = "invalid_webauthn_response"
|
|
CodeWebAuthnAuthenticationFailed Code = "webauthn_authentication_failed"
|
|
CodePasskeyUserVerificationRequired Code = "passkey_user_verification_required"
|
|
CodeSyncedPasskeyNotAllowed Code = "synced_passkey_not_allowed"
|
|
CodeInvalidWebAuthnSession Code = "invalid_webauthn_session"
|
|
CodeUserNotFound Code = "user_not_found"
|
|
CodeUserDisabled Code = "user_disabled"
|
|
CodeAPIKeyNotFound Code = "api_key_not_found"
|
|
CodeInvalidAPIKey Code = "invalid_api_key"
|
|
CodeDeviceLoginExpired Code = "device_login_expired"
|
|
CodeReauthenticationRequired Code = "reauthentication_required"
|
|
CodeEmailVerificationTokenInvalid Code = "invalid_email_verification_token"
|
|
CodeSetupNotAvailable Code = "setup_not_available"
|
|
CodeSetupAlreadyCompleted Code = "setup_already_completed"
|
|
CodeTokenInvalidOrExpired Code = "token_invalid_or_expired"
|
|
CodeDeviceCodeInvalid Code = "device_code_invalid"
|
|
CodeOidcMissingAuthorization Code = "oidc_missing_authorization"
|
|
CodeOidcInvalidCallbackURL Code = "oidc_invalid_callback_url"
|
|
CodeFileTypeNotSupported Code = "file_type_not_supported"
|
|
CodeNotSignedIn Code = "not_signed_in"
|
|
CodeMissingSessionID Code = "missing_session_id"
|
|
CodeReservedClaim Code = "reserved_claim"
|
|
CodeDuplicateClaim Code = "duplicate_claim"
|
|
CodeLdapDisabled Code = "ldap_disabled"
|
|
CodeLdapUserUpdate Code = "ldap_user_update"
|
|
CodeLdapUserGroupUpdate Code = "ldap_user_group_update"
|
|
CodeOidcAccessDenied Code = "oidc_access_denied"
|
|
CodeOidcClientIDNotMatching Code = "oidc_client_id_not_matching"
|
|
CodeUIConfigDisabled Code = "ui_config_disabled"
|
|
CodeOneTimeAccessDisabled Code = "one_time_access_disabled"
|
|
CodeDeviceLoginDenied Code = "device_login_denied"
|
|
CodeNoAPIKeyProvided Code = "no_api_key_provided"
|
|
CodeAPIKeyNotExpired Code = "api_key_not_expired"
|
|
CodeInvalidAPIKeyExpiration Code = "invalid_api_key_expiration"
|
|
CodeAPIKeyAuthNotAllowed Code = "api_key_auth_not_allowed"
|
|
CodeOidcDeviceCodeExpired Code = "oidc_device_code_expired"
|
|
CodeOidcInvalidDeviceCode Code = "oidc_invalid_device_code"
|
|
CodeOpenSignupDisabled Code = "open_signup_disabled"
|
|
CodeClientIDAlreadyExists Code = "client_id_already_exists"
|
|
CodeUserEmailNotSet Code = "user_email_not_set"
|
|
CodeImageNotFound Code = "image_not_found"
|
|
CodeLogoDownloadFailed Code = "logo_download_failed"
|
|
CodeLogoTypeNotSupported Code = "logo_type_not_supported"
|
|
CodeLogoTooLarge Code = "logo_too_large"
|
|
CodeOidcPARRequired Code = "oidc_par_required"
|
|
)
|
|
|
|
// FieldError describes one safe, client-actionable validation failure
|
|
type FieldError struct {
|
|
Field string `json:"field"`
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// Error is an application error with a stable code, an HTTP status, and an optional internal cause
|
|
type Error struct {
|
|
code Code
|
|
status int
|
|
message string
|
|
details map[string]string
|
|
fields []FieldError
|
|
retryAfter time.Duration
|
|
cause error
|
|
}
|
|
|
|
// New creates a client-safe application error
|
|
func New(code Code, status int, message string) *Error {
|
|
return &Error{code: code, status: status, message: message}
|
|
}
|
|
|
|
// Wrap creates a client-safe application error that retains an internal cause
|
|
func Wrap(cause error, code Code, status int, message string) *Error {
|
|
if cause == nil {
|
|
return New(code, status, message)
|
|
}
|
|
|
|
return &Error{code: code, status: status, message: message, cause: cause}
|
|
}
|
|
|
|
// Internal creates a generic server error that retains a diagnostic cause without exposing it to clients
|
|
func Internal(cause error) *Error {
|
|
return Wrap(cause, CodeInternal, http.StatusInternalServerError, "Something went wrong")
|
|
}
|
|
|
|
// Validation creates a structured validation error
|
|
func Validation(fields []FieldError) *Error {
|
|
return New(CodeValidationFailed, http.StatusBadRequest, "Request validation failed").WithFields(fields)
|
|
}
|
|
|
|
// Error returns diagnostic text including the internal cause when one exists
|
|
func (e *Error) Error() string {
|
|
if e == nil {
|
|
return "<nil>"
|
|
}
|
|
if e.cause == nil {
|
|
return e.message
|
|
}
|
|
|
|
return fmt.Sprintf("%s: %v", e.message, e.cause)
|
|
}
|
|
|
|
// Unwrap exposes the internal cause to errors.Is and errors.As
|
|
func (e *Error) Unwrap() error {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
|
|
return e.cause
|
|
}
|
|
|
|
// Is matches application errors by stable code while ignoring message and detail differences
|
|
func (e *Error) Is(target error) bool {
|
|
targetError, ok := target.(*Error)
|
|
return ok && e != nil && targetError != nil && e.code == targetError.code
|
|
}
|
|
|
|
// IsCode reports whether an error or one of its wrapped causes has the given application code
|
|
func IsCode(err error, code Code) bool {
|
|
return errors.Is(err, &Error{code: code})
|
|
}
|
|
|
|
// Code returns the stable application error code
|
|
func (e *Error) Code() Code {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
|
|
return e.code
|
|
}
|
|
|
|
// HTTPStatus returns the HTTP status associated with the error
|
|
func (e *Error) HTTPStatus() int {
|
|
if e == nil {
|
|
return http.StatusInternalServerError
|
|
}
|
|
|
|
return e.status
|
|
}
|
|
|
|
// ClientMessage returns the message that is safe to include in an HTTP response
|
|
func (e *Error) ClientMessage() string {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
|
|
return e.message
|
|
}
|
|
|
|
// Details returns a copy of the additional client-safe details
|
|
func (e *Error) Details() map[string]string {
|
|
if e == nil || len(e.details) == 0 {
|
|
return nil
|
|
}
|
|
|
|
details := make(map[string]string, len(e.details))
|
|
maps.Copy(details, e.details)
|
|
|
|
return details
|
|
}
|
|
|
|
// Fields returns a copy of the structured validation details
|
|
func (e *Error) Fields() []FieldError {
|
|
if e == nil || len(e.fields) == 0 {
|
|
return nil
|
|
}
|
|
|
|
return append([]FieldError(nil), e.fields...)
|
|
}
|
|
|
|
// RetryAfter returns the duration a client should wait before retrying
|
|
func (e *Error) RetryAfter() time.Duration {
|
|
if e == nil {
|
|
return 0
|
|
}
|
|
|
|
return e.retryAfter
|
|
}
|
|
|
|
// WithFields attaches structured validation details without exposing the cause
|
|
func (e *Error) WithFields(fields []FieldError) *Error {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
|
|
e.fields = append([]FieldError(nil), fields...)
|
|
return e
|
|
}
|
|
|
|
// WithDetail attaches one client-safe string detail
|
|
func (e *Error) WithDetail(key, value string) *Error {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
|
|
if e.details == nil {
|
|
e.details = make(map[string]string)
|
|
}
|
|
e.details[key] = value
|
|
return e
|
|
}
|
|
|
|
// WithRetryAfter attaches a retry delay to the error
|
|
func (e *Error) WithRetryAfter(retryAfter time.Duration) *Error {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
|
|
e.retryAfter = retryAfter
|
|
return e
|
|
}
|