feat: authsession service
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// ErrorCodeInvalidRequest reports malformed or semantically invalid service
|
||||
// input.
|
||||
ErrorCodeInvalidRequest = "invalid_request"
|
||||
|
||||
// ErrorCodeChallengeNotFound reports that the requested challenge does not
|
||||
// exist.
|
||||
ErrorCodeChallengeNotFound = "challenge_not_found"
|
||||
|
||||
// ErrorCodeChallengeExpired reports that the requested challenge may no
|
||||
// longer be confirmed.
|
||||
ErrorCodeChallengeExpired = "challenge_expired"
|
||||
|
||||
// ErrorCodeInvalidCode reports that the submitted confirmation code does not
|
||||
// match the stored challenge.
|
||||
ErrorCodeInvalidCode = "invalid_code"
|
||||
|
||||
// ErrorCodeInvalidClientPublicKey reports that the submitted client public
|
||||
// key does not satisfy the Ed25519/base64 contract.
|
||||
ErrorCodeInvalidClientPublicKey = "invalid_client_public_key"
|
||||
|
||||
// ErrorCodeBlockedByPolicy reports that the auth flow is denied by current
|
||||
// user or registration policy.
|
||||
ErrorCodeBlockedByPolicy = "blocked_by_policy"
|
||||
|
||||
// ErrorCodeSessionLimitExceeded reports that creating another active session
|
||||
// would violate the configured limit.
|
||||
ErrorCodeSessionLimitExceeded = "session_limit_exceeded"
|
||||
|
||||
// ErrorCodeSessionNotFound reports that the requested device session does
|
||||
// not exist.
|
||||
ErrorCodeSessionNotFound = "session_not_found"
|
||||
|
||||
// ErrorCodeSubjectNotFound reports that the requested trusted internal
|
||||
// subject does not exist.
|
||||
ErrorCodeSubjectNotFound = "subject_not_found"
|
||||
|
||||
// ErrorCodeServiceUnavailable reports that a required dependency or
|
||||
// propagation step is temporarily unavailable.
|
||||
ErrorCodeServiceUnavailable = "service_unavailable"
|
||||
|
||||
// ErrorCodeInternalError reports that local state is inconsistent or an
|
||||
// invariant was broken unexpectedly.
|
||||
ErrorCodeInternalError = "internal_error"
|
||||
)
|
||||
|
||||
const genericInvalidRequestMessage = "request is invalid"
|
||||
|
||||
var publicErrorStatusCodes = map[string]int{
|
||||
ErrorCodeInvalidRequest: http.StatusBadRequest,
|
||||
ErrorCodeInvalidClientPublicKey: http.StatusBadRequest,
|
||||
ErrorCodeInvalidCode: http.StatusBadRequest,
|
||||
ErrorCodeChallengeNotFound: http.StatusNotFound,
|
||||
ErrorCodeChallengeExpired: http.StatusGone,
|
||||
ErrorCodeBlockedByPolicy: http.StatusForbidden,
|
||||
ErrorCodeSessionLimitExceeded: http.StatusConflict,
|
||||
ErrorCodeServiceUnavailable: http.StatusServiceUnavailable,
|
||||
}
|
||||
|
||||
var publicStableMessages = map[string]string{
|
||||
ErrorCodeChallengeNotFound: "challenge not found",
|
||||
ErrorCodeChallengeExpired: "challenge expired",
|
||||
ErrorCodeInvalidCode: "confirmation code is invalid",
|
||||
ErrorCodeInvalidClientPublicKey: "client_public_key is not a valid base64-encoded raw 32-byte Ed25519 public key",
|
||||
ErrorCodeBlockedByPolicy: "authentication is blocked by policy",
|
||||
ErrorCodeSessionLimitExceeded: "active session limit would be exceeded",
|
||||
ErrorCodeServiceUnavailable: "service is unavailable",
|
||||
}
|
||||
|
||||
var internalErrorStatusCodes = map[string]int{
|
||||
ErrorCodeInvalidRequest: http.StatusBadRequest,
|
||||
ErrorCodeSessionNotFound: http.StatusNotFound,
|
||||
ErrorCodeSubjectNotFound: http.StatusNotFound,
|
||||
ErrorCodeServiceUnavailable: http.StatusServiceUnavailable,
|
||||
ErrorCodeInternalError: http.StatusInternalServerError,
|
||||
}
|
||||
|
||||
var internalStableMessages = map[string]string{
|
||||
ErrorCodeSessionNotFound: "session not found",
|
||||
ErrorCodeSubjectNotFound: "subject not found",
|
||||
ErrorCodeServiceUnavailable: "service is unavailable",
|
||||
ErrorCodeInternalError: "internal server error",
|
||||
}
|
||||
|
||||
// PublicErrorProjection describes one transport-ready public auth error after
|
||||
// internal service errors have been normalized to the frozen client-safe
|
||||
// surface.
|
||||
type PublicErrorProjection struct {
|
||||
// StatusCode is the HTTP status that should be returned to the public auth
|
||||
// caller.
|
||||
StatusCode int
|
||||
|
||||
// Code is the stable client-safe error code written into the public JSON
|
||||
// envelope.
|
||||
Code string
|
||||
|
||||
// Message is the client-safe error description exposed to the public auth
|
||||
// caller.
|
||||
Message string
|
||||
}
|
||||
|
||||
// InternalErrorProjection describes one transport-ready internal API error
|
||||
// after service-layer failures have been normalized to the frozen trusted
|
||||
// caller surface.
|
||||
type InternalErrorProjection struct {
|
||||
// StatusCode is the HTTP status that should be returned to the trusted
|
||||
// caller.
|
||||
StatusCode int
|
||||
|
||||
// Code is the stable error code written into the internal JSON envelope.
|
||||
Code string
|
||||
|
||||
// Message is the trusted-caller-safe error description exposed by the
|
||||
// internal HTTP API.
|
||||
Message string
|
||||
}
|
||||
|
||||
// ServiceError projects one stable application-layer failure with a service
|
||||
// error code and a caller-safe message.
|
||||
type ServiceError struct {
|
||||
// Code is the stable error code expected by later transport mapping.
|
||||
Code string
|
||||
|
||||
// Message is the caller-safe error description.
|
||||
Message string
|
||||
|
||||
// Err optionally stores the wrapped underlying cause.
|
||||
Err error
|
||||
}
|
||||
|
||||
// Error returns the caller-safe error description.
|
||||
func (e *ServiceError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.TrimSpace(e.Message) != "":
|
||||
return e.Message
|
||||
case strings.TrimSpace(e.Code) != "":
|
||||
return e.Code
|
||||
case e.Err != nil:
|
||||
return e.Err.Error()
|
||||
default:
|
||||
return ErrorCodeInternalError
|
||||
}
|
||||
}
|
||||
|
||||
// Unwrap returns the wrapped cause, if any.
|
||||
func (e *ServiceError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// NewServiceError returns a new typed application-layer error.
|
||||
func NewServiceError(code string, message string, err error) *ServiceError {
|
||||
return &ServiceError{
|
||||
Code: strings.TrimSpace(code),
|
||||
Message: strings.TrimSpace(message),
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// IsPublicErrorCode reports whether code belongs to the frozen public auth
|
||||
// error surface.
|
||||
func IsPublicErrorCode(code string) bool {
|
||||
_, ok := publicErrorStatusCodes[strings.TrimSpace(code)]
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsInternalOnlyErrorCode reports whether code is intentionally excluded from
|
||||
// the public auth transport surface.
|
||||
func IsInternalOnlyErrorCode(code string) bool {
|
||||
switch strings.TrimSpace(code) {
|
||||
case ErrorCodeSessionNotFound, ErrorCodeSubjectNotFound, ErrorCodeInternalError:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsSendEmailCodePublicErrorCode reports whether code may be exposed by the
|
||||
// public send-email-code route after public projection.
|
||||
func IsSendEmailCodePublicErrorCode(code string) bool {
|
||||
switch strings.TrimSpace(code) {
|
||||
case ErrorCodeInvalidRequest, ErrorCodeServiceUnavailable:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsConfirmEmailCodePublicErrorCode reports whether code may be exposed by the
|
||||
// public confirm-email-code route after public projection.
|
||||
func IsConfirmEmailCodePublicErrorCode(code string) bool {
|
||||
switch strings.TrimSpace(code) {
|
||||
case ErrorCodeInvalidRequest,
|
||||
ErrorCodeChallengeNotFound,
|
||||
ErrorCodeChallengeExpired,
|
||||
ErrorCodeInvalidCode,
|
||||
ErrorCodeInvalidClientPublicKey,
|
||||
ErrorCodeBlockedByPolicy,
|
||||
ErrorCodeSessionLimitExceeded,
|
||||
ErrorCodeServiceUnavailable:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// PublicHTTPStatusCode reports the frozen public HTTP status for code. Unknown
|
||||
// or internal-only codes are normalized to 503 service_unavailable.
|
||||
func PublicHTTPStatusCode(code string) int {
|
||||
if statusCode, ok := publicErrorStatusCodes[strings.TrimSpace(code)]; ok {
|
||||
return statusCode
|
||||
}
|
||||
|
||||
return http.StatusServiceUnavailable
|
||||
}
|
||||
|
||||
// ProjectPublicError normalizes err to the frozen public-auth error surface.
|
||||
// Unknown and internal-only service failures are intentionally projected as
|
||||
// 503 service_unavailable so internal invariants do not leak to public callers.
|
||||
func ProjectPublicError(err error) PublicErrorProjection {
|
||||
serviceErr, ok := errors.AsType[*ServiceError](err)
|
||||
code := CodeOf(err)
|
||||
if !IsPublicErrorCode(code) {
|
||||
return PublicErrorProjection{
|
||||
StatusCode: http.StatusServiceUnavailable,
|
||||
Code: ErrorCodeServiceUnavailable,
|
||||
Message: publicMessageForCode(ErrorCodeServiceUnavailable, ""),
|
||||
}
|
||||
}
|
||||
|
||||
message := ""
|
||||
if ok && serviceErr != nil {
|
||||
message = serviceErr.Message
|
||||
}
|
||||
|
||||
return PublicErrorProjection{
|
||||
StatusCode: PublicHTTPStatusCode(code),
|
||||
Code: code,
|
||||
Message: publicMessageForCode(code, message),
|
||||
}
|
||||
}
|
||||
|
||||
// InternalHTTPStatusCode reports the frozen internal HTTP status for code.
|
||||
// Unknown codes are normalized to 500 internal_error.
|
||||
func InternalHTTPStatusCode(code string) int {
|
||||
if statusCode, ok := internalErrorStatusCodes[strings.TrimSpace(code)]; ok {
|
||||
return statusCode
|
||||
}
|
||||
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
// ProjectInternalError normalizes err to the frozen internal trusted HTTP
|
||||
// error surface. Unknown failures are intentionally projected as
|
||||
// 500 internal_error so transport callers do not depend on unclassified local
|
||||
// failures.
|
||||
func ProjectInternalError(err error) InternalErrorProjection {
|
||||
serviceErr, ok := errors.AsType[*ServiceError](err)
|
||||
code := CodeOf(err)
|
||||
if _, known := internalErrorStatusCodes[code]; !known {
|
||||
return InternalErrorProjection{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Code: ErrorCodeInternalError,
|
||||
Message: internalMessageForCode(ErrorCodeInternalError, ""),
|
||||
}
|
||||
}
|
||||
|
||||
message := ""
|
||||
if ok && serviceErr != nil {
|
||||
message = serviceErr.Message
|
||||
}
|
||||
|
||||
return InternalErrorProjection{
|
||||
StatusCode: InternalHTTPStatusCode(code),
|
||||
Code: code,
|
||||
Message: internalMessageForCode(code, message),
|
||||
}
|
||||
}
|
||||
|
||||
// InvalidRequest reports one malformed or semantically invalid caller input.
|
||||
func InvalidRequest(message string) *ServiceError {
|
||||
return NewServiceError(ErrorCodeInvalidRequest, message, nil)
|
||||
}
|
||||
|
||||
// ChallengeNotFound reports that the requested challenge does not exist.
|
||||
func ChallengeNotFound() *ServiceError {
|
||||
return NewServiceError(ErrorCodeChallengeNotFound, "challenge not found", nil)
|
||||
}
|
||||
|
||||
// ChallengeExpired reports that the requested challenge is expired.
|
||||
func ChallengeExpired() *ServiceError {
|
||||
return NewServiceError(ErrorCodeChallengeExpired, "challenge expired", nil)
|
||||
}
|
||||
|
||||
// InvalidCode reports that the submitted confirmation code is invalid.
|
||||
func InvalidCode() *ServiceError {
|
||||
return NewServiceError(ErrorCodeInvalidCode, "confirmation code is invalid", nil)
|
||||
}
|
||||
|
||||
// InvalidClientPublicKey reports that the submitted client public key does not
|
||||
// satisfy the frozen contract.
|
||||
func InvalidClientPublicKey() *ServiceError {
|
||||
return NewServiceError(
|
||||
ErrorCodeInvalidClientPublicKey,
|
||||
"client_public_key is not a valid base64-encoded raw 32-byte Ed25519 public key",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
// BlockedByPolicy reports that the current auth flow is denied by policy.
|
||||
func BlockedByPolicy() *ServiceError {
|
||||
return NewServiceError(ErrorCodeBlockedByPolicy, "authentication is blocked by policy", nil)
|
||||
}
|
||||
|
||||
// SessionLimitExceeded reports that creating another active session would
|
||||
// exceed the current configured limit.
|
||||
func SessionLimitExceeded() *ServiceError {
|
||||
return NewServiceError(ErrorCodeSessionLimitExceeded, "active session limit would be exceeded", nil)
|
||||
}
|
||||
|
||||
// SessionNotFound reports that the requested session does not exist.
|
||||
func SessionNotFound() *ServiceError {
|
||||
return NewServiceError(ErrorCodeSessionNotFound, "session not found", nil)
|
||||
}
|
||||
|
||||
// SubjectNotFound reports that the requested internal subject does not exist.
|
||||
func SubjectNotFound() *ServiceError {
|
||||
return NewServiceError(ErrorCodeSubjectNotFound, "subject not found", nil)
|
||||
}
|
||||
|
||||
// ServiceUnavailable reports that a required dependency or propagation step is
|
||||
// temporarily unavailable.
|
||||
func ServiceUnavailable(err error) *ServiceError {
|
||||
return NewServiceError(ErrorCodeServiceUnavailable, "service is unavailable", err)
|
||||
}
|
||||
|
||||
// InternalError reports an invariant-breaking local failure.
|
||||
func InternalError(err error) *ServiceError {
|
||||
return NewServiceError(ErrorCodeInternalError, "internal error", err)
|
||||
}
|
||||
|
||||
// CodeOf returns the stable service error code of err when err wraps a
|
||||
// ServiceError. Otherwise it returns ErrorCodeInternalError.
|
||||
func CodeOf(err error) string {
|
||||
serviceErr, ok := errors.AsType[*ServiceError](err)
|
||||
if !ok || serviceErr == nil || strings.TrimSpace(serviceErr.Code) == "" {
|
||||
return ErrorCodeInternalError
|
||||
}
|
||||
|
||||
return serviceErr.Code
|
||||
}
|
||||
|
||||
func publicMessageForCode(code string, message string) string {
|
||||
trimmedMessage := strings.TrimSpace(message)
|
||||
|
||||
switch strings.TrimSpace(code) {
|
||||
case ErrorCodeInvalidRequest:
|
||||
if trimmedMessage != "" {
|
||||
return trimmedMessage
|
||||
}
|
||||
return genericInvalidRequestMessage
|
||||
case ErrorCodeServiceUnavailable:
|
||||
return publicStableMessages[ErrorCodeServiceUnavailable]
|
||||
default:
|
||||
if stableMessage, ok := publicStableMessages[strings.TrimSpace(code)]; ok {
|
||||
return stableMessage
|
||||
}
|
||||
return publicStableMessages[ErrorCodeServiceUnavailable]
|
||||
}
|
||||
}
|
||||
|
||||
func internalMessageForCode(code string, message string) string {
|
||||
trimmedMessage := strings.TrimSpace(message)
|
||||
|
||||
switch strings.TrimSpace(code) {
|
||||
case ErrorCodeInvalidRequest:
|
||||
if trimmedMessage != "" {
|
||||
return trimmedMessage
|
||||
}
|
||||
return genericInvalidRequestMessage
|
||||
case ErrorCodeSessionNotFound,
|
||||
ErrorCodeSubjectNotFound,
|
||||
ErrorCodeServiceUnavailable,
|
||||
ErrorCodeInternalError:
|
||||
if stableMessage, ok := internalStableMessages[strings.TrimSpace(code)]; ok {
|
||||
return stableMessage
|
||||
}
|
||||
return internalStableMessages[ErrorCodeInternalError]
|
||||
default:
|
||||
return internalStableMessages[ErrorCodeInternalError]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user