package api_impl import ( "context" "crypto/subtle" "database/sql" "encoding/json" "errors" "fmt" "log/slog" "net/http" "strings" "time" "atlas9.dev/c/core" "atlas9.dev/c/core/dbi" "atlas9.dev/c/core/iam" "atlas9.dev/c/demo/api" "atlas9.dev/c/demo/bots" "atlas9.dev/c/demo/lib/access" ) // RequireAuth rejects requests that have no authenticated principal with // 401, telling the client to (re-)authenticate. Routes registered behind it // are private by construction; public routes must be explicitly registered // outside of it. func RequireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if iam.GetPrincipal(r.Context()).Subject == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) json.NewEncoder(w).Encode(api.ErrorResponse{Message: iam.ErrUnauthorized.Error()}) return } next.ServeHTTP(w, r) }) } type IamMiddleware struct { DB *sql.DB Access dbi.Factory[access.Store] Sessions dbi.Factory[iam.SessionStore] Keys dbi.Factory[bots.KeyStore] SessionMan *iam.SessionMan } func (i *IamMiddleware) Handler(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() var principalID core.ID var acc access.Access err := dbi.ReadOnly(ctx, i.DB, func(tx dbi.DBI) error { fromSession := false // Try to load a user session. if cookie, _ := r.Cookie(i.SessionMan.CookieName); cookie != nil { s, err := i.SessionMan.LoadSession(ctx, tx, cookie.Value) switch { case err == core.ErrNotFound: case err != nil: return err default: principalID = s.Principal fromSession = true } } // If principal is still empty, then try to load a bot identity. if principalID.IsEmpty() { b, err := loadBot(ctx, r, i.Keys(tx)) if err != nil { return err } principalID = b } // If principal is still empty, then the request is anonymous. if principalID.IsEmpty() { return nil } // Only session principals get the authenticated baseline // (system-scoped Tenants.Create); bots hold exactly the caps // they've been granted. if fromSession { acc.Authenticated() } return i.Access(tx).Load(ctx, principalID, &acc) }) if errors.Is(err, iam.ErrUnauthorized) { // A credential was presented but is invalid. Unlike the // anonymous case this is answered immediately, before routing. w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) json.NewEncoder(w).Encode(api.ErrorResponse{Message: iam.ErrUnauthorized.Error()}) return } if writeErr(ctx, w, err) { return } ctx = iam.PutPrincipal(ctx, iam.Principal{Subject: principalID.String()}) ctx = access.Put(ctx, acc) slog.InfoContext(ctx, "http identity", "subject", principalID.String()) r = r.WithContext(ctx) next.ServeHTTP(w, r) }) } // loadBot authenticates a bot API-key bearer token of the form // "Bearer .". A missing or non-Bearer Authorization header is // anonymous; a presented-but-invalid credential is iam.ErrUnauthorized. func loadBot(ctx context.Context, r *http.Request, keys bots.KeyStore) (core.ID, error) { token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") if !ok { return core.ID{}, nil } keyIDStr, secret, err := bots.ParseToken(token) if err != nil { slog.InfoContext(ctx, "malformed api key token") return core.ID{}, iam.ErrUnauthorized } keyID, err := core.ParseID(keyIDStr) if err != nil { slog.InfoContext(ctx, "invalid api key id") return core.ID{}, iam.ErrUnauthorized } // The middleware runs before any principal is established, so the key // lookup runs under a scoped system grant — the same pre-auth pattern // as the login flow's user lookup. sysCtx := access.PutSystem(ctx, bots.Cap_BotKeys_Read) // Get the API key var key bots.Key err = keys.GetByID(sysCtx, keyID, &key) if errors.Is(err, core.ErrNotFound) { slog.InfoContext(ctx, "api key not found", "ID", keyID) return core.ID{}, iam.ErrUnauthorized } if err != nil { return core.ID{}, fmt.Errorf("reading api key: %w", err) } // Only the hash is stored; compare hashes in constant time. if subtle.ConstantTimeCompare([]byte(bots.HashSecret(secret)), []byte(key.SecretHash)) != 1 { slog.InfoContext(ctx, "api key secret mismatch", "ID", keyID) return core.ID{}, iam.ErrUnauthorized } // Check if key is expired if time.Now().After(key.ExpiresAt) { slog.InfoContext(ctx, "api key expired", "ID", keyID, "ExpiresAt", key.ExpiresAt) return core.ID{}, iam.ErrUnauthorized } slog.InfoContext(ctx, "api key accepted", "ID", key.ID, "Bot", key.Bot, "Tenant", key.Tenant.String(), ) // Set principal to the bot ID return key.Bot, nil }