Merge branch 'master' of git.maxamundsen.dev:maxamundsen/kjol
This commit is contained in:
274
go/auth/auth.go
Normal file
274
go/auth/auth.go
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
// Package auth is a storage-agnostic authentication and authorization engine.
|
||||||
|
//
|
||||||
|
// The engine owns the mechanics every app otherwise reimplements: password login
|
||||||
|
// with timing-attack mitigation and lockout, opaque session keys carried by
|
||||||
|
// either cookie or bearer token, session expiry and revocation, per-request
|
||||||
|
// permission resolution, and the authn/authz middleware.
|
||||||
|
//
|
||||||
|
// It owns no schema. Everything app-specific is injected through three
|
||||||
|
// interfaces:
|
||||||
|
//
|
||||||
|
// Store persist and look up sessions
|
||||||
|
// Directory find a user by login identifier, record login outcomes
|
||||||
|
// PermissionResolver turn a session into an effective permission set
|
||||||
|
//
|
||||||
|
// so one app can key login on a username and another on an email-or-phone
|
||||||
|
// without the engine knowing that either concept exists. Apps keep their own
|
||||||
|
// permission constants; the engine only needs to be told which one means
|
||||||
|
// "superuser" (Config.SuperPermission).
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Errors an app's Store or Directory is expected to return. The engine maps all
|
||||||
|
// of them onto ErrInvalidCredentials at the login boundary so a caller can never
|
||||||
|
// distinguish "no such user" from "wrong password" by inspecting the error.
|
||||||
|
var (
|
||||||
|
ErrSessionNotFound = errors.New("auth: session not found")
|
||||||
|
ErrUserNotFound = errors.New("auth: user not found")
|
||||||
|
ErrAPIKeyNotFound = errors.New("auth: api key not found")
|
||||||
|
ErrInvalidCredentials = errors.New("auth: invalid credentials")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Session is a live login. It is the engine's view of whatever row the app
|
||||||
|
// stores; the app converts to and from its own model in its Store.
|
||||||
|
//
|
||||||
|
// ID is assigned by the app's store (typically a database default), so it is
|
||||||
|
// zero on the Session handed to Store.Insert.
|
||||||
|
type Session struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
Key string // opaque secret; the cookie/bearer value
|
||||||
|
UserID uuid.UUID
|
||||||
|
OrgID *uuid.UUID // set when the login is scoped to one organization
|
||||||
|
Expiration time.Time
|
||||||
|
Created time.Time
|
||||||
|
UserAgent string
|
||||||
|
IPAddr string
|
||||||
|
Timezone string
|
||||||
|
Revoked bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expired reports whether the session is past its expiration.
|
||||||
|
func (s Session) Expired() bool { return s.Expiration.Before(time.Now()) }
|
||||||
|
|
||||||
|
// Valid reports whether the session may still authenticate a request.
|
||||||
|
func (s Session) Valid() bool { return !s.Revoked && !s.Expired() }
|
||||||
|
|
||||||
|
// User is the engine's view of a login-capable account: the minimum it needs to
|
||||||
|
// verify a password and seed a session. The app's Directory projects its own
|
||||||
|
// user model onto this.
|
||||||
|
type User struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
PasswordHash string
|
||||||
|
FailedLoginAttempts int
|
||||||
|
Timezone string
|
||||||
|
OrgID *uuid.UUID // pre-selected org, when the app can infer one at login
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIKey is a non-interactive, organization-scoped credential. Optional: an app
|
||||||
|
// only sees these if it sets Config.APIKeys.
|
||||||
|
type APIKey struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
OrgID uuid.UUID
|
||||||
|
Revoked bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Principal is the authenticated state of one request. It is attached to the
|
||||||
|
// request context by LoadContext and read back with PrincipalFrom.
|
||||||
|
//
|
||||||
|
// An unauthenticated request carries the zero Principal rather than nothing, so
|
||||||
|
// handlers behind an optional-auth route can read it without a nil check.
|
||||||
|
type Principal struct {
|
||||||
|
Authenticated bool
|
||||||
|
Session Session
|
||||||
|
Permissions map[string]bool
|
||||||
|
IsAPIKey bool // authenticated by APIKey rather than a user session
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionMeta is the per-request provenance recorded on a new session.
|
||||||
|
type SessionMeta struct {
|
||||||
|
IPAddr string
|
||||||
|
UserAgent string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store persists sessions. Implementations are expected to be safe for
|
||||||
|
// concurrent use.
|
||||||
|
type Store interface {
|
||||||
|
// FetchByKey returns the session with the given opaque key, or
|
||||||
|
// ErrSessionNotFound. Returning a revoked or expired session is fine; the
|
||||||
|
// engine checks both.
|
||||||
|
FetchByKey(ctx context.Context, key string) (Session, error)
|
||||||
|
|
||||||
|
// Insert persists a newly minted session. The session's ID is zero; a store
|
||||||
|
// that generates IDs (or lets the database do it) may ignore the field.
|
||||||
|
Insert(ctx context.Context, s Session) error
|
||||||
|
|
||||||
|
// Revoke marks the session with the given key as revoked.
|
||||||
|
Revoke(ctx context.Context, key string) error
|
||||||
|
|
||||||
|
// EnforceSessionLimit revokes the user's oldest active sessions until at
|
||||||
|
// most limit remain. Called before Insert, and only when
|
||||||
|
// Config.MaxActiveSessions is positive.
|
||||||
|
EnforceSessionLimit(ctx context.Context, userID uuid.UUID, limit int) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directory resolves login identifiers to users and records login outcomes.
|
||||||
|
// What an identifier *is* — username, email, phone — is entirely the app's
|
||||||
|
// business.
|
||||||
|
type Directory interface {
|
||||||
|
// FindByIdentifier returns the user for a login identifier, or
|
||||||
|
// ErrUserNotFound. The engine treats every error as a failed login.
|
||||||
|
FindByIdentifier(ctx context.Context, identifier string) (User, error)
|
||||||
|
|
||||||
|
// RecordLoginResult records a login attempt: on success, reset the failure
|
||||||
|
// counter and stamp last-login; on failure, increment the counter. The
|
||||||
|
// engine ignores the returned error beyond logging, so a failure here can
|
||||||
|
// never turn a bad password into a good one.
|
||||||
|
RecordLoginResult(ctx context.Context, u User, success bool) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// PermissionResolver computes a session's effective permissions. The engine
|
||||||
|
// calls it on every authenticated request, so implementations should be cheap or
|
||||||
|
// cached.
|
||||||
|
//
|
||||||
|
// It takes the whole Session, not just a user ID, so a resolver can vary by how
|
||||||
|
// the session was created — e.g. returning a snapshot captured at SSO login
|
||||||
|
// instead of aggregating live from the database.
|
||||||
|
type PermissionResolver interface {
|
||||||
|
Resolve(ctx context.Context, session Session) map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIKeyDirectory resolves organization-scoped API keys. Optional.
|
||||||
|
type APIKeyDirectory interface {
|
||||||
|
// FindAPIKeyByHash looks up a key by its digest (see HashAPIKey), or returns
|
||||||
|
// ErrAPIKeyNotFound. Raw keys are never persisted.
|
||||||
|
FindAPIKeyByHash(ctx context.Context, hash string) (APIKey, error)
|
||||||
|
|
||||||
|
// TouchAPIKey records that the key was used. Called on a background
|
||||||
|
// goroutine; errors are ignored.
|
||||||
|
TouchAPIKey(ctx context.Context, id uuid.UUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasswordPolicy is the complexity floor enforced by CheckPassword. The zero
|
||||||
|
// policy accepts any non-blank password.
|
||||||
|
type PasswordPolicy struct {
|
||||||
|
MinLength int
|
||||||
|
RequiredUppercase int
|
||||||
|
RequiredLowercase int
|
||||||
|
RequiredNumbers int
|
||||||
|
RequiredSymbols int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config parameterizes the engine. It carries no secrets and no schema — only
|
||||||
|
// the knobs that differ between applications.
|
||||||
|
type Config struct {
|
||||||
|
// Session cookie and the paths the middleware redirects between.
|
||||||
|
CookieName string
|
||||||
|
LoginPath string
|
||||||
|
LogoutPath string
|
||||||
|
DefaultPath string
|
||||||
|
|
||||||
|
// Redirect sends unauthenticated browser requests to LoginPath. When false,
|
||||||
|
// they get a bare 401. Bearer-token and /api/ requests never redirect
|
||||||
|
// regardless.
|
||||||
|
Redirect bool
|
||||||
|
|
||||||
|
// SessionTTL is how long a new session stays valid.
|
||||||
|
SessionTTL time.Duration
|
||||||
|
|
||||||
|
// KeyBytes is the entropy of a session key, in bytes.
|
||||||
|
KeyBytes int
|
||||||
|
|
||||||
|
// MaxActiveSessions caps concurrent sessions per user; the oldest are
|
||||||
|
// revoked past the cap. Zero or less disables the cap.
|
||||||
|
MaxActiveSessions int
|
||||||
|
|
||||||
|
// MaxLoginAttempts locks an account out once its consecutive failure count
|
||||||
|
// exceeds this. Zero or less disables lockout.
|
||||||
|
MaxLoginAttempts int
|
||||||
|
|
||||||
|
// SuperPermission is the app's wildcard permission (conventionally "*"),
|
||||||
|
// which satisfies any Require check. Empty means no wildcard exists.
|
||||||
|
SuperPermission string
|
||||||
|
|
||||||
|
// Implications grants permissions transitively: holding the key grants every
|
||||||
|
// permission in the value. Applied after the resolver returns, so a
|
||||||
|
// "manage X" permission can imply "view X" without every role having to list
|
||||||
|
// both. An explicit deny (present in the map, set false) is never overridden.
|
||||||
|
Implications map[string][]string
|
||||||
|
|
||||||
|
// Password is the complexity policy enforced by CheckPassword.
|
||||||
|
Password PasswordPolicy
|
||||||
|
|
||||||
|
// APIKeys enables the LoadAPIKey middleware. Optional; nil means the app has
|
||||||
|
// no API-key credentials.
|
||||||
|
APIKeys APIKeyDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticator is the engine. Build one with New and keep it for the process
|
||||||
|
// lifetime; it is safe for concurrent use.
|
||||||
|
type Authenticator struct {
|
||||||
|
cfg Config
|
||||||
|
store Store
|
||||||
|
dir Directory
|
||||||
|
resolver PermissionResolver
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds an Authenticator from a config and the app's three adapters. All
|
||||||
|
// three are required; APIKeys is set on Config when the app has API keys.
|
||||||
|
func New(cfg Config, store Store, dir Directory, resolver PermissionResolver) *Authenticator {
|
||||||
|
return &Authenticator{cfg: cfg, store: store, dir: dir, resolver: resolver}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config returns the engine's configuration.
|
||||||
|
func (a *Authenticator) Config() Config { return a.cfg }
|
||||||
|
|
||||||
|
// principalCtxKey types the request-context slot holding the Principal. It is
|
||||||
|
// unexported, so nothing outside this package can plant or forge one.
|
||||||
|
type principalCtxKey struct{}
|
||||||
|
|
||||||
|
// PrincipalFrom returns the Principal that LoadContext (or LoadAPIKey) attached
|
||||||
|
// to the request. Requests that did not pass through the middleware, and
|
||||||
|
// unauthenticated ones, yield the zero Principal.
|
||||||
|
func PrincipalFrom(r *http.Request) Principal {
|
||||||
|
p, _ := r.Context().Value(principalCtxKey{}).(Principal)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// withPrincipal returns r carrying p.
|
||||||
|
func withPrincipal(r *http.Request, p Principal) *http.Request {
|
||||||
|
return r.WithContext(context.WithValue(r.Context(), principalCtxKey{}, p))
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvePermissions computes a session's permissions and applies Implications.
|
||||||
|
func (a *Authenticator) resolvePermissions(ctx context.Context, s Session) map[string]bool {
|
||||||
|
perms := a.resolver.Resolve(ctx, s)
|
||||||
|
if perms == nil {
|
||||||
|
perms = map[string]bool{}
|
||||||
|
}
|
||||||
|
applyImplications(perms, a.cfg.Implications)
|
||||||
|
return perms
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyImplications expands perms in place: holding a source permission grants
|
||||||
|
// its targets. An explicit deny (key present and false) stays denied.
|
||||||
|
func applyImplications(perms map[string]bool, implications map[string][]string) {
|
||||||
|
for source, targets := range implications {
|
||||||
|
if !perms[source] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, target := range targets {
|
||||||
|
if granted, explicit := perms[target]; explicit && !granted {
|
||||||
|
continue // explicit deny wins
|
||||||
|
}
|
||||||
|
perms[target] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1014
go/auth/auth_test.go
Normal file
1014
go/auth/auth_test.go
Normal file
File diff suppressed because it is too large
Load Diff
346
go/auth/authn.go
Normal file
346
go/auth/authn.go
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"kjol/security"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dummyHash is compared against when no user matches, so a login for an unknown
|
||||||
|
// identifier costs the same bcrypt work as one for a known identifier with the
|
||||||
|
// wrong password. Without it, response time alone would enumerate valid accounts.
|
||||||
|
const dummyHash = "$2a$14$KW5OO1wZqGGq3SrpBFj0Oema5DG8Ph7lZJvq0ECkkYBpNFom6b9vO"
|
||||||
|
|
||||||
|
// maxLoginJitter bounds the random delay added to every login.
|
||||||
|
const maxLoginJitter = 500 * time.Millisecond
|
||||||
|
|
||||||
|
// Login verifies an identifier and password and, on success, mints and persists
|
||||||
|
// a session. It returns the resulting Principal and the raw session key, which
|
||||||
|
// is the credential the caller hands to the client — as a cookie via
|
||||||
|
// SetSessionCookie, or as a bearer token in a JSON response.
|
||||||
|
//
|
||||||
|
// Every failure path returns ErrInvalidCredentials: unknown identifier, wrong
|
||||||
|
// password, and locked-out account are indistinguishable to the caller.
|
||||||
|
func (a *Authenticator) Login(ctx context.Context, identifier, password string, meta SessionMeta) (Principal, string, error) {
|
||||||
|
// Partial timing-attack mitigation: a uniform random delay on every attempt.
|
||||||
|
//
|
||||||
|
// This does not eliminate the timing signal — an attacker averaging enough
|
||||||
|
// samples can still separate "user exists, bad password" from "no such user"
|
||||||
|
// — but it raises the sample count needed by orders of magnitude, which is
|
||||||
|
// what makes it effective in combination with the lockout below and
|
||||||
|
// per-IP rate limiting at the edge.
|
||||||
|
//
|
||||||
|
// https://security.stackexchange.com/questions/96489/can-i-prevent-timing-attacks-with-random-delays/96493#96493
|
||||||
|
jitter, err := rand.Int(rand.Reader, big.NewInt(int64(maxLoginJitter/time.Millisecond)))
|
||||||
|
if err == nil {
|
||||||
|
time.Sleep(time.Duration(jitter.Int64()) * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := a.dir.FindByIdentifier(ctx, identifier)
|
||||||
|
lockedOut := a.cfg.MaxLoginAttempts > 0 && user.FailedLoginAttempts > a.cfg.MaxLoginAttempts
|
||||||
|
|
||||||
|
if err != nil || lockedOut {
|
||||||
|
// Spend the same bcrypt time as the success path before bailing.
|
||||||
|
security.ComparePasswords(password, dummyHash)
|
||||||
|
return Principal{}, "", ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
if !security.ComparePasswords(password, user.PasswordHash) {
|
||||||
|
// Best-effort: a failure to record the attempt must not grant access.
|
||||||
|
_ = a.dir.RecordLoginResult(ctx, user, false)
|
||||||
|
return Principal{}, "", ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.dir.RecordLoginResult(ctx, user, true); err != nil {
|
||||||
|
return Principal{}, "", fmt.Errorf("auth: record login: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.StartSession(ctx, user, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartSession mints, persists, and resolves permissions for a session for an
|
||||||
|
// already-authenticated user. Login calls it after verifying a password; an app
|
||||||
|
// calls it directly when a user proved themselves some other way (SSO callback,
|
||||||
|
// an email magic link, impersonation by an admin).
|
||||||
|
//
|
||||||
|
// It returns the Principal and the raw session key.
|
||||||
|
func (a *Authenticator) StartSession(ctx context.Context, user User, meta SessionMeta) (Principal, string, error) {
|
||||||
|
key, err := security.GenerateRandomKeyBase64(a.cfg.KeyBytes)
|
||||||
|
if err != nil {
|
||||||
|
return Principal{}, "", fmt.Errorf("auth: generate session key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
session := Session{
|
||||||
|
// ID is intentionally left zero: the store (or its database default)
|
||||||
|
// assigns it.
|
||||||
|
Key: key,
|
||||||
|
UserID: user.ID,
|
||||||
|
OrgID: user.OrgID,
|
||||||
|
Created: now,
|
||||||
|
Expiration: now.Add(a.cfg.SessionTTL),
|
||||||
|
Timezone: user.Timezone,
|
||||||
|
IPAddr: meta.IPAddr,
|
||||||
|
UserAgent: meta.UserAgent,
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.cfg.MaxActiveSessions > 0 {
|
||||||
|
if err := a.store.EnforceSessionLimit(ctx, user.ID, a.cfg.MaxActiveSessions); err != nil {
|
||||||
|
return Principal{}, "", fmt.Errorf("auth: enforce session limit: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := a.store.Insert(ctx, session); err != nil {
|
||||||
|
return Principal{}, "", fmt.Errorf("auth: insert session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Principal{
|
||||||
|
Authenticated: true,
|
||||||
|
Session: session,
|
||||||
|
Permissions: a.resolvePermissions(ctx, session),
|
||||||
|
}, key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout revokes the session with the given key. Revoking an already-revoked or
|
||||||
|
// unknown session is not an error.
|
||||||
|
func (a *Authenticator) Logout(ctx context.Context, key string) error {
|
||||||
|
if key == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := a.store.Revoke(ctx, key); err != nil {
|
||||||
|
if errors.Is(err, ErrSessionNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("auth: revoke session: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionKeyFrom extracts the session key a request presents, preferring an
|
||||||
|
// Authorization: Bearer token over the session cookie. It returns the key and
|
||||||
|
// whether it came from a bearer token.
|
||||||
|
func (a *Authenticator) SessionKeyFrom(r *http.Request) (key string, bearer bool) {
|
||||||
|
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
||||||
|
return strings.TrimPrefix(h, "Bearer "), true
|
||||||
|
}
|
||||||
|
if c, err := r.Cookie(a.cfg.CookieName); err == nil && c != nil {
|
||||||
|
return c.Value, false
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadContext resolves the request's session and permissions and attaches the
|
||||||
|
// Principal to its context. It is the outermost auth middleware; Require and
|
||||||
|
// friends read what it attached.
|
||||||
|
//
|
||||||
|
// With requireAuth false, an unauthenticated request is served anyway, carrying
|
||||||
|
// the zero Principal — for pages that render differently when signed in. With
|
||||||
|
// requireAuth true, an unauthenticated request is rejected: JSON 401 for API and
|
||||||
|
// bearer-token clients, a redirect to LoginPath for browsers (or a bare 401 when
|
||||||
|
// Config.Redirect is false).
|
||||||
|
func (a *Authenticator) LoadContext(h http.HandlerFunc, requireAuth bool) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Every one of these is per-request state. Hoisting any of it into the
|
||||||
|
// enclosing closure would share it across concurrent requests on this
|
||||||
|
// route: a single bearer-token request could then disable redirects for
|
||||||
|
// every browser request that followed.
|
||||||
|
key, bearer := a.SessionKeyFrom(r)
|
||||||
|
wantsJSON := bearer || isAPIRequest(r)
|
||||||
|
redirect := a.cfg.Redirect && !wantsJSON
|
||||||
|
|
||||||
|
session, err := a.lookupSession(r.Context(), key)
|
||||||
|
if err != nil || !session.Valid() {
|
||||||
|
expired := err == nil && !session.Revoked && session.Expired()
|
||||||
|
a.rejectUnauthenticated(w, r, h, requireAuth, wantsJSON, redirect, expired)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
principal := Principal{
|
||||||
|
Authenticated: true,
|
||||||
|
Session: session,
|
||||||
|
Permissions: a.resolvePermissions(r.Context(), session),
|
||||||
|
}
|
||||||
|
|
||||||
|
// An authenticated user asking for the login page gets sent home.
|
||||||
|
if redirect && r.URL.Path == a.cfg.LoginPath {
|
||||||
|
http.Redirect(w, r, a.cfg.DefaultPath, http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h(w, withPrincipal(r, principal))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// lookupSession fetches the session for a key, treating an empty key as a miss
|
||||||
|
// without troubling the store.
|
||||||
|
func (a *Authenticator) lookupSession(ctx context.Context, key string) (Session, error) {
|
||||||
|
if key == "" {
|
||||||
|
return Session{}, ErrSessionNotFound
|
||||||
|
}
|
||||||
|
return a.store.FetchByKey(ctx, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rejectUnauthenticated handles a request with no usable session: serve it
|
||||||
|
// anyway when auth is optional, otherwise reject in whichever dialect the client
|
||||||
|
// speaks.
|
||||||
|
func (a *Authenticator) rejectUnauthenticated(
|
||||||
|
w http.ResponseWriter, r *http.Request, h http.HandlerFunc,
|
||||||
|
requireAuth, wantsJSON, redirect, expired bool,
|
||||||
|
) {
|
||||||
|
if !requireAuth {
|
||||||
|
h(w, withPrincipal(r, Principal{}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if wantsJSON {
|
||||||
|
reason := "valid authentication required"
|
||||||
|
if expired {
|
||||||
|
reason = "session expired"
|
||||||
|
}
|
||||||
|
respondJSONError(w, http.StatusUnauthorized, "Unauthorized: "+reason)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if expired {
|
||||||
|
// The browser is holding a cookie that will never work again.
|
||||||
|
a.ClearSessionCookie(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't bounce the login/logout pages back to themselves.
|
||||||
|
if redirect && r.URL.Path != a.cfg.LoginPath && r.URL.Path != a.cfg.LogoutPath {
|
||||||
|
http.Redirect(w, r, a.cfg.LoginPath+"?redirect="+url.QueryEscape(r.URL.String()), http.StatusFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !redirect && r.URL.Path != a.cfg.LoginPath {
|
||||||
|
http.Error(w, "Error: Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unauthenticated, on the login page itself: serve it.
|
||||||
|
h(w, withPrincipal(r, Principal{}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxCookieBytes is the per-domain cookie budget browsers enforce.
|
||||||
|
const maxCookieBytes = 4096
|
||||||
|
|
||||||
|
// SetSessionCookie writes the session key as the app's HttpOnly session cookie.
|
||||||
|
// Use it for browser form logins; API clients get the raw key from Login instead.
|
||||||
|
//
|
||||||
|
// When remember is false the cookie is a session cookie: it dies with the
|
||||||
|
// browser. When true it carries the session's own expiration.
|
||||||
|
//
|
||||||
|
// It fails rather than silently truncating if the domain's other cookies leave
|
||||||
|
// no room — a session cookie that the browser drops would look like a login that
|
||||||
|
// mysteriously does nothing.
|
||||||
|
func (a *Authenticator) SetSessionCookie(w http.ResponseWriter, r *http.Request, p Principal, remember bool) error {
|
||||||
|
var otherBytes int
|
||||||
|
for _, c := range r.Cookies() {
|
||||||
|
if c.Name != a.cfg.CookieName {
|
||||||
|
otherBytes += len(c.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 8 bytes are slack for cookie framing overhead.
|
||||||
|
if len(p.Session.Key)+otherBytes+8 > maxCookieBytes {
|
||||||
|
return fmt.Errorf("auth: cannot set session cookie, domain cookies exceed %d bytes", maxCookieBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
cookie := &http.Cookie{
|
||||||
|
Name: a.cfg.CookieName,
|
||||||
|
Value: p.Session.Key,
|
||||||
|
HttpOnly: true,
|
||||||
|
Path: "/",
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
Secure: r.TLS != nil,
|
||||||
|
}
|
||||||
|
if remember {
|
||||||
|
cookie.Expires = p.Session.Expiration
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, cookie)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearSessionCookie expires the session cookie in the client. It does not
|
||||||
|
// revoke the session server-side — pair it with Logout.
|
||||||
|
func (a *Authenticator) ClearSessionCookie(w http.ResponseWriter) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: a.cfg.CookieName,
|
||||||
|
Value: "",
|
||||||
|
MaxAge: -1,
|
||||||
|
Expires: time.Unix(1, 0),
|
||||||
|
Path: "/",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashAPIKey returns the digest under which an API key is stored and looked up.
|
||||||
|
// Only the digest is ever persisted, so a leaked database yields no usable keys.
|
||||||
|
func HashAPIKey(raw string) string {
|
||||||
|
sum := sha256.Sum256([]byte(raw))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadAPIKey authenticates a request with an organization-scoped API key from
|
||||||
|
// Authorization: Bearer, and attaches an API-key Principal. It is the
|
||||||
|
// non-interactive counterpart to LoadContext, for machine-facing endpoints.
|
||||||
|
//
|
||||||
|
// The Principal carries the key's org but no user and no permissions. Routes
|
||||||
|
// mounted behind this middleware are therefore gated by the mount itself, not by
|
||||||
|
// Require — which would deny them, there being no permissions to match.
|
||||||
|
//
|
||||||
|
// Requires Config.APIKeys; without it every request is rejected.
|
||||||
|
func (a *Authenticator) LoadAPIKey(h http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if a.cfg.APIKeys == nil {
|
||||||
|
respondJSONError(w, http.StatusUnauthorized, "Unauthorized: API key authentication is not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
|
respondJSONError(w, http.StatusUnauthorized, "Unauthorized: API key required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := a.cfg.APIKeys.FindAPIKeyByHash(r.Context(), HashAPIKey(strings.TrimPrefix(authHeader, "Bearer ")))
|
||||||
|
if err != nil || key.Revoked {
|
||||||
|
respondJSONError(w, http.StatusUnauthorized, "Unauthorized: invalid or revoked API key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last-used is bookkeeping: don't make the caller wait on it, and don't
|
||||||
|
// let the request's cancellation abort the write.
|
||||||
|
go a.cfg.APIKeys.TouchAPIKey(context.WithoutCancel(r.Context()), key.ID)
|
||||||
|
|
||||||
|
h(w, withPrincipal(r, Principal{
|
||||||
|
Authenticated: true,
|
||||||
|
IsAPIKey: true,
|
||||||
|
Session: Session{OrgID: &key.OrgID},
|
||||||
|
Permissions: map[string]bool{},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAPIRequest reports whether a request should be answered in JSON rather than
|
||||||
|
// with a redirect.
|
||||||
|
func isAPIRequest(r *http.Request) bool {
|
||||||
|
return strings.HasPrefix(r.URL.Path, "/api/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// respondJSONError writes a JSON error body. The message is engine-authored, so
|
||||||
|
// it needs no escaping.
|
||||||
|
func respondJSONError(w http.ResponseWriter, status int, message string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
fmt.Fprintf(w, `{"error": %q}`, message)
|
||||||
|
}
|
||||||
153
go/auth/authz.go
Normal file
153
go/auth/authz.go
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
. "kjol/basic"
|
||||||
|
"kjol/l4g"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Permissions are plain strings, aggregated per request by the app's
|
||||||
|
// PermissionResolver into a single map. The engine does not care where they come
|
||||||
|
// from — a user's direct grants, the groups they belong to, an organization
|
||||||
|
// role, a claim snapshot from an SSO login — only whether the map contains what
|
||||||
|
// a route requires.
|
||||||
|
//
|
||||||
|
// Two families of check live here, and the difference matters:
|
||||||
|
//
|
||||||
|
// - Authorized / AuthorizedExact / AuthorizedAny are pure predicates, for
|
||||||
|
// display logic. They are expected to fail routinely (most users are not
|
||||||
|
// admins, and every "admin only" menu asks on every page), so they log
|
||||||
|
// nothing.
|
||||||
|
//
|
||||||
|
// - Require / RequireExact / RequireAny are middleware. A failure here means a
|
||||||
|
// request actually reached a route the caller may not have, so it is logged.
|
||||||
|
//
|
||||||
|
// The pairing is deliberate: gate the link with Authorized so the request is
|
||||||
|
// never made, and gate the route with Require so it cannot be made anyway.
|
||||||
|
|
||||||
|
// Authorized reports whether the principal holds every listed permission.
|
||||||
|
// The super permission satisfies any requirement.
|
||||||
|
func (a *Authenticator) Authorized(p Principal, requirements ...string) bool {
|
||||||
|
if a.hasSuper(p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return holdsAll(p, requirements)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthorizedExact reports whether the principal holds every listed permission,
|
||||||
|
// with no super-permission bypass.
|
||||||
|
//
|
||||||
|
// Use it where the question is "does this user really have X" rather than "may
|
||||||
|
// this user do X" — e.g. showing an admin their own effective grants, where the
|
||||||
|
// wildcard would otherwise make every box look ticked.
|
||||||
|
func (a *Authenticator) AuthorizedExact(p Principal, requirements ...string) bool {
|
||||||
|
return holdsAll(p, requirements)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthorizedAny reports whether the principal holds at least one of the listed
|
||||||
|
// permissions. The super permission satisfies it.
|
||||||
|
//
|
||||||
|
// This is for resources read by several pages whose own permissions differ — a
|
||||||
|
// product-options list fetched by both the inventory editor and the sales
|
||||||
|
// terminal — where requiring all of them would lock out callers who legitimately
|
||||||
|
// hold only one.
|
||||||
|
func (a *Authenticator) AuthorizedAny(p Principal, anyOf ...string) bool {
|
||||||
|
if a.hasSuper(p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, requirement := range anyOf {
|
||||||
|
if p.Permissions[requirement] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require gates a handler on the caller holding every listed permission,
|
||||||
|
// answering 403 otherwise. Mount it inside LoadContext.
|
||||||
|
func (a *Authenticator) Require(h http.HandlerFunc, requirements ...string) http.HandlerFunc {
|
||||||
|
return a.gate(h, func(p Principal) bool { return a.Authorized(p, requirements...) }, requirements)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireExact gates a handler on the caller holding every listed permission,
|
||||||
|
// with no super-permission bypass.
|
||||||
|
func (a *Authenticator) RequireExact(h http.HandlerFunc, requirements ...string) http.HandlerFunc {
|
||||||
|
return a.gate(h, func(p Principal) bool { return a.AuthorizedExact(p, requirements...) }, requirements)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireAny gates a handler on the caller holding at least one of the listed
|
||||||
|
// permissions.
|
||||||
|
func (a *Authenticator) RequireAny(h http.HandlerFunc, anyOf ...string) http.HandlerFunc {
|
||||||
|
return a.gate(h, func(p Principal) bool { return a.AuthorizedAny(p, anyOf...) }, anyOf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// gate is the shared middleware body: check, log-and-deny, or serve.
|
||||||
|
func (a *Authenticator) gate(h http.HandlerFunc, allow func(Principal) bool, requirements []string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
p := PrincipalFrom(r)
|
||||||
|
|
||||||
|
if !allow(p) {
|
||||||
|
a.logDenial(r, p, requirements)
|
||||||
|
if isAPIRequest(r) {
|
||||||
|
respondJSONError(w, http.StatusForbidden, "Forbidden: insufficient permissions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// logDenial records a failed route authorization.
|
||||||
|
//
|
||||||
|
// Unlike a failed display check, this means a request was actually issued for a
|
||||||
|
// route the caller cannot have: either a user is missing access they need, or
|
||||||
|
// someone is probing. Both are worth seeing, so this is the one authorization
|
||||||
|
// path that logs.
|
||||||
|
func (a *Authenticator) logDenial(r *http.Request, p Principal, requirements []string) {
|
||||||
|
entry := l4g.Entry{
|
||||||
|
Category: l4g.CATEGORY_AUTH,
|
||||||
|
LogType: l4g.TYPE_WARN,
|
||||||
|
Content: MakePtr("Failed Endpoint Authorization"),
|
||||||
|
StructuredContent: l4g.Serialize(struct {
|
||||||
|
Route string
|
||||||
|
RequiredPermissions []string
|
||||||
|
CurrentPermissions map[string]bool
|
||||||
|
Authenticated bool
|
||||||
|
}{
|
||||||
|
Route: r.URL.Path,
|
||||||
|
RequiredPermissions: requirements,
|
||||||
|
CurrentPermissions: p.Permissions,
|
||||||
|
Authenticated: p.Authenticated,
|
||||||
|
}),
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anonymous denials carry no user to attribute the entry to.
|
||||||
|
if p.Authenticated {
|
||||||
|
entry.AppUserID = MakePtr(p.Session.UserID)
|
||||||
|
entry.OrgID = p.Session.OrgID
|
||||||
|
entry.IdentityID = MakePtr(p.Session.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
l4g.Write(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasSuper reports whether the principal holds the app's wildcard permission.
|
||||||
|
func (a *Authenticator) hasSuper(p Principal) bool {
|
||||||
|
return a.cfg.SuperPermission != "" && p.Permissions[a.cfg.SuperPermission]
|
||||||
|
}
|
||||||
|
|
||||||
|
// holdsAll reports whether the principal holds every listed permission.
|
||||||
|
func holdsAll(p Principal, requirements []string) bool {
|
||||||
|
for _, requirement := range requirements {
|
||||||
|
if !p.Permissions[requirement] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
67
go/auth/password.go
Normal file
67
go/auth/password.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CheckPassword reports whether a password satisfies the configured policy,
|
||||||
|
// returning an error phrased for the end user (the caller can surface it
|
||||||
|
// verbatim on a signup or change-password form).
|
||||||
|
func (a *Authenticator) CheckPassword(password string) error {
|
||||||
|
return CheckPasswordPolicy(a.cfg.Password, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckPasswordPolicy is CheckPassword against an explicit policy, for callers
|
||||||
|
// that validate a password without an Authenticator in hand.
|
||||||
|
func CheckPasswordPolicy(policy PasswordPolicy, password string) error {
|
||||||
|
if strings.TrimSpace(password) == "" {
|
||||||
|
return errors.New("Password cannot be blank.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(password) < policy.MinLength {
|
||||||
|
return errors.New("Password must be at least " + strconv.Itoa(policy.MinLength) + " characters long.")
|
||||||
|
}
|
||||||
|
|
||||||
|
var uppercase, lowercase, numbers, symbols int
|
||||||
|
for _, r := range password {
|
||||||
|
switch {
|
||||||
|
case unicode.IsUpper(r):
|
||||||
|
uppercase++
|
||||||
|
case unicode.IsLower(r):
|
||||||
|
lowercase++
|
||||||
|
case unicode.IsNumber(r):
|
||||||
|
numbers++
|
||||||
|
default:
|
||||||
|
symbols++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case uppercase < policy.RequiredUppercase:
|
||||||
|
return errors.New("Password must contain at least " + strconv.Itoa(policy.RequiredUppercase) + " uppercase character(s).")
|
||||||
|
case lowercase < policy.RequiredLowercase:
|
||||||
|
return errors.New("Password must contain at least " + strconv.Itoa(policy.RequiredLowercase) + " lowercase character(s).")
|
||||||
|
case numbers < policy.RequiredNumbers:
|
||||||
|
return errors.New("Password must contain at least " + strconv.Itoa(policy.RequiredNumbers) + " number(s).")
|
||||||
|
case symbols < policy.RequiredSymbols:
|
||||||
|
return errors.New("Password must contain at least " + strconv.Itoa(policy.RequiredSymbols) + " symbol(s).")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeneratePasswordResetToken returns a 32-byte crypto-random token, hex-encoded
|
||||||
|
// (64 characters). The app stores it against the user with an expiry and mails
|
||||||
|
// the same string out.
|
||||||
|
func GeneratePasswordResetToken() (string, error) {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b), nil
|
||||||
|
}
|
||||||
@@ -31,8 +31,12 @@ var faStyleDirs = map[string]string{
|
|||||||
var (
|
var (
|
||||||
// icon="name" / icon: "name"
|
// icon="name" / icon: "name"
|
||||||
reIconAttr = regexp.MustCompile(`\bicon\s*(?:=|:)\s*"([a-z0-9][a-z0-9-]*)"`)
|
reIconAttr = regexp.MustCompile(`\bicon\s*(?:=|:)\s*"([a-z0-9][a-z0-9-]*)"`)
|
||||||
// icon={ ... } — dynamic expressions; pull any string literals (ternaries etc.)
|
// icon={ ... } — a dynamic expression; pull any string literals out of it
|
||||||
reIconBrace = regexp.MustCompile(`\bicon\s*=\s*\{([^}]*)\}`)
|
// (ternaries etc.). The `$` is optional because both authoring styles in this
|
||||||
|
// codebase have to be seen: JSX writes icon={"bell"}, while a solid-js/html
|
||||||
|
// tagged template writes icon=${"bell"}. Missing the second form drops the
|
||||||
|
// icon from the registry silently — it simply renders blank at runtime.
|
||||||
|
reIconBrace = regexp.MustCompile(`\bicon\s*=\s*\$?\{([^}]*)\}`)
|
||||||
reStrLit = regexp.MustCompile(`"([a-z0-9][a-z0-9-]*)"`)
|
reStrLit = regexp.MustCompile(`"([a-z0-9][a-z0-9-]*)"`)
|
||||||
reViewBox = regexp.MustCompile(`viewBox="0 0 ([0-9.]+) ([0-9.]+)"`)
|
reViewBox = regexp.MustCompile(`viewBox="0 0 ([0-9.]+) ([0-9.]+)"`)
|
||||||
rePathD = regexp.MustCompile(`<path[^>]*\bd="([^"]+)"`)
|
rePathD = regexp.MustCompile(`<path[^>]*\bd="([^"]+)"`)
|
||||||
|
|||||||
70
go/jsbundler/faicons_test.go
Normal file
70
go/jsbundler/faicons_test.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package jsbundler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// scanIconNames has to see every authoring style used across the apps. Both are
|
||||||
|
// live in the same file in practice: a page written as a solid-js/html tagged
|
||||||
|
// template says icon=${"bell"}, while a .tsx page says icon={"bell"} or
|
||||||
|
// icon="bell". An icon the scanner misses is not a build error — it is simply
|
||||||
|
// absent from the generated registry and renders as a blank space at runtime,
|
||||||
|
// which is exactly how a missing bell/circle-question went unnoticed.
|
||||||
|
func TestScanIconNamesAcrossAuthoringStyles(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
src := `
|
||||||
|
// plain attribute (JSX or template)
|
||||||
|
<Icon icon="notebook" size={25} />
|
||||||
|
|
||||||
|
// JSX dynamic expression
|
||||||
|
<Icon icon={"gear"} />
|
||||||
|
|
||||||
|
// solid-js/html tagged template — interpolated string
|
||||||
|
<${Icon} icon=${"bell"} size=${28} />
|
||||||
|
<${Icon} icon=${"circle-question"} size=${28} />
|
||||||
|
|
||||||
|
// object-literal property
|
||||||
|
const item = { icon: "chart-line", label: "Reports" };
|
||||||
|
|
||||||
|
// dynamic expressions: every string literal inside is a candidate
|
||||||
|
<Icon icon={open ? "chevron-up" : "chevron-down"} />
|
||||||
|
<${Icon} icon=${busy ? "spinner" : "check"} />
|
||||||
|
|
||||||
|
// an in-app custom icon
|
||||||
|
registerIcon("playground", "<svg/>");
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "page.tsx"), []byte(src), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
names, custom, err := scanIconNames([]string{dir})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("scanIconNames: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
found := make(map[string]bool, len(names))
|
||||||
|
for _, n := range names {
|
||||||
|
found[n] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
"notebook", // icon="name"
|
||||||
|
"gear", // icon={"name"}
|
||||||
|
"bell", // icon=${"name"} <- the regression
|
||||||
|
"circle-question", // icon=${"name"} <- the regression
|
||||||
|
"chart-line", // icon: "name"
|
||||||
|
"chevron-up", "chevron-down", // JSX ternary
|
||||||
|
"spinner", "check", // template-literal ternary
|
||||||
|
} {
|
||||||
|
if !found[want] {
|
||||||
|
t.Errorf("icon %q was not scanned; it would render blank at runtime", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !custom["playground"] {
|
||||||
|
t.Error("registerIcon(\"playground\") was not picked up as a custom icon")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user