package identity import ( "context" "errors" "fmt" "time" ) // AuthorizationSnapshotLoader is the Identity module's single persistence // seam. A false found result means that the account does not exist. type AuthorizationSnapshotLoader interface { FindAuthorizationSnapshot(context.Context, string) (AuthorizationSnapshot, bool, error) } // AuthorizationSnapshot contains all database-authoritative claims needed to // authorize one signed session. type AuthorizationSnapshot struct { Account AccountSnapshot `json:"account"` Organization *OrganizationSnapshot `json:"organization"` } type AccountSnapshot struct { ID string `json:"id"` Phone string `json:"phone"` DisplayName string `json:"displayName"` Role string `json:"role"` OrganizationID string `json:"organizationId,omitempty"` Status string `json:"status"` SessionVersion int `json:"sessionVersion"` } type OrganizationSnapshot struct { ID string `json:"id"` Name string `json:"name"` Status string `json:"status"` } type RejectionReason string const ( RejectionInvalidSession RejectionReason = "invalid_session" RejectionClientMismatch RejectionReason = "client_mismatch" RejectionAccountNotFound RejectionReason = "account_not_found" RejectionAccountDisabled RejectionReason = "account_disabled" RejectionSessionVersionMismatch RejectionReason = "session_version_mismatch" RejectionOrganizationRequired RejectionReason = "organization_required" RejectionOrganizationNotActive RejectionReason = "organization_not_active" RejectionInvalidRole RejectionReason = "invalid_role" ) var ErrUnauthenticated = errors.New("unauthenticated") // UnauthenticatedError retains a diagnostic reason while allowing callers to // collapse all authorization denials with errors.Is(err, ErrUnauthenticated). type UnauthenticatedError struct { Reason RejectionReason } func (err *UnauthenticatedError) Error() string { return fmt.Sprintf("%s: %s", ErrUnauthenticated, err.Reason) } func (err *UnauthenticatedError) Unwrap() error { return ErrUnauthenticated } type Resolver struct { loader AuthorizationSnapshotLoader secret string requiredClientID string now func() time.Time } func NewResolver(loader AuthorizationSnapshotLoader, secret, requiredClientID string, now func() time.Time) *Resolver { if now == nil { now = time.Now } if requiredClientID == "" { requiredClientID = "platform" } return &Resolver{ loader: loader, secret: secret, requiredClientID: requiredClientID, now: now, } } // Resolve authenticates the signed cookie, reloads its account authorization // state, and returns a session whose authorization claims all come from the // database snapshot. func (resolver *Resolver) Resolve(ctx context.Context, cookieValue string) (Session, error) { if resolver == nil || resolver.loader == nil || resolver.secret == "" { return Session{}, fmt.Errorf("identity resolver is not configured") } session, err := Parse(cookieValue, resolver.secret, resolver.now()) if err != nil { return Session{}, reject(RejectionInvalidSession) } if session.User.ClientID != resolver.requiredClientID { return Session{}, reject(RejectionClientMismatch) } snapshot, found, err := resolver.loader.FindAuthorizationSnapshot(ctx, session.User.ID) if err != nil { return Session{}, err } if !found { return Session{}, reject(RejectionAccountNotFound) } account := snapshot.Account if account.Status != "active" { return Session{}, reject(RejectionAccountDisabled) } if session.SessionVersion != nil && *session.SessionVersion != 0 && *session.SessionVersion != account.SessionVersion { return Session{}, reject(RejectionSessionVersionMismatch) } authMode, authorities, validRole := roleClaims(account.Role) if !validRole { return Session{}, reject(RejectionInvalidRole) } if account.Role != "super_admin" { if account.OrganizationID == "" { return Session{}, reject(RejectionOrganizationRequired) } if snapshot.Organization == nil || snapshot.Organization.ID != account.OrganizationID || snapshot.Organization.Status != "active" { return Session{}, reject(RejectionOrganizationNotActive) } } currentVersion := account.SessionVersion resolved := Session{ Version: session.Version, AuthMode: authMode, IssuedAt: session.IssuedAt, ExpiresAt: session.ExpiresAt, SessionVersion: ¤tVersion, AccessToken: session.AccessToken, TokenType: session.TokenType, User: User{ ID: account.ID, Subject: account.ID, Username: account.Phone, Phone: account.Phone, DisplayName: account.DisplayName, ClientID: resolver.requiredClientID, OrganizationID: account.OrganizationID, Role: account.Role, Status: account.Status, Authorities: authorities, Scope: []string{}, }, } if snapshot.Organization != nil && snapshot.Organization.ID == account.OrganizationID { resolved.User.OrganizationName = snapshot.Organization.Name } return resolved, nil } func roleClaims(role string) (AuthMode, []string, bool) { switch role { case "user": return AuthModeUser, []string{"ROLE_USER"}, true case "organization_admin": return AuthModeAdmin, []string{"ROLE_ORGANIZATION_ADMIN", "ORGANIZATION_ADMIN"}, true case "super_admin": return AuthModeAdmin, []string{"ROLE_SUPER_ADMIN", "SUPER_ADMIN"}, true default: return "", nil, false } } func reject(reason RejectionReason) error { return &UnauthenticatedError{Reason: reason} }