Skip to content

Commit bb0ba00

Browse files
mmartinvclaude
andcommitted
feat: add API key authenticator
Implement APIKeyAuthenticator with SHA-256 hash verification, prefix-based lookup, scope resolution (unrestricted vs restricted intersection), background last_used_at updates with WaitGroup for graceful shutdown, and proper error classification (ErrInvalidCredentials vs errInternal). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Miguel Martín <mmartinv@redhat.com>
1 parent 9493b72 commit bb0ba00

2 files changed

Lines changed: 456 additions & 0 deletions

File tree

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// SPDX-FileCopyrightText: (C) 2026 Red Hat Inc.
2+
// SPDX-License-Identifier: Apache 2.0
3+
4+
package apikey
5+
6+
import (
7+
"context"
8+
"crypto/sha256"
9+
"crypto/subtle"
10+
"errors"
11+
"log/slog"
12+
"net/http"
13+
"strings"
14+
"sync"
15+
"time"
16+
17+
"gorm.io/gorm"
18+
19+
"github.com/fido-device-onboard/go-fdo-server/internal/auth"
20+
"github.com/fido-device-onboard/go-fdo-server/internal/state"
21+
)
22+
23+
var errInternal = errors.New("internal authentication error")
24+
25+
const (
26+
headerName = "X-API-Key"
27+
keyPrefix = "fdo_"
28+
minKeyLen = 10
29+
prefixStartIdx = 4
30+
prefixEndIdx = 10
31+
)
32+
33+
var _ auth.Authenticator = (*APIKeyAuthenticator)(nil)
34+
35+
type APIKeyAuthenticator struct {
36+
db *gorm.DB
37+
wg sync.WaitGroup
38+
}
39+
40+
func New(db *gorm.DB) *APIKeyAuthenticator {
41+
return &APIKeyAuthenticator{db: db}
42+
}
43+
44+
func (a *APIKeyAuthenticator) Wait() {
45+
a.wg.Wait()
46+
}
47+
48+
func (a *APIKeyAuthenticator) Name() string { return "api-key" }
49+
50+
func (a *APIKeyAuthenticator) Authenticate(ctx context.Context, r *http.Request) (*auth.Identity, error) {
51+
key := r.Header.Get(headerName)
52+
if key == "" {
53+
return nil, nil
54+
}
55+
56+
if !strings.HasPrefix(key, keyPrefix) || len(key) < minKeyLen {
57+
slog.Debug("API key format invalid")
58+
return nil, auth.ErrInvalidCredentials
59+
}
60+
61+
prefix := key[prefixStartIdx:prefixEndIdx]
62+
63+
candidates, err := state.FindAPIKeysByPrefix(ctx, a.db, prefix)
64+
if err != nil {
65+
slog.Error("Failed to look up API keys by prefix", "prefix", prefix, "error", err)
66+
return nil, errInternal
67+
}
68+
69+
hash := sha256.Sum256([]byte(key))
70+
71+
for _, candidate := range candidates {
72+
if len(candidate.HashedKey) != len(hash) || subtle.ConstantTimeCompare(candidate.HashedKey, hash[:]) != 1 {
73+
continue
74+
}
75+
76+
if candidate.ExpiresAt != nil && candidate.ExpiresAt.Before(time.Now()) {
77+
slog.Debug("API key expired", "prefix", prefix)
78+
return nil, auth.ErrInvalidCredentials
79+
}
80+
81+
user, err := state.GetUserByID(ctx, a.db, candidate.UserID)
82+
if err != nil {
83+
slog.Error("Failed to look up API key owner", "user_id", candidate.UserID, "error", err)
84+
return nil, errInternal
85+
}
86+
if !user.Active {
87+
slog.Warn("API key authentication rejected: user inactive", "user_id", candidate.UserID, "prefix", prefix)
88+
return nil, auth.ErrInvalidCredentials
89+
}
90+
91+
a.wg.Add(1)
92+
go func() {
93+
defer a.wg.Done()
94+
bgCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
95+
defer cancel()
96+
state.UpdateAPIKeyLastUsed(bgCtx, a.db, candidate.ID)
97+
}()
98+
99+
scopes, err := a.resolveScopes(ctx, &candidate)
100+
if err != nil {
101+
slog.Error("Failed to resolve scopes for API key", "api_key_id", candidate.ID, "error", err)
102+
return nil, errInternal
103+
}
104+
105+
roles, err := state.GetUserRoles(ctx, a.db, candidate.UserID)
106+
if err != nil {
107+
slog.Error("Failed to look up user roles", "user_id", candidate.UserID, "error", err)
108+
return nil, errInternal
109+
}
110+
roleNames := make([]string, 0, len(roles))
111+
for _, r := range roles {
112+
roleNames = append(roleNames, r.Name)
113+
}
114+
115+
return auth.NewIdentity(
116+
candidate.UserID,
117+
user.Name,
118+
"api-key",
119+
roleNames,
120+
scopes,
121+
map[string]string{"api_key_prefix": prefix, "api_key_name": candidate.Name},
122+
), nil
123+
}
124+
125+
slog.Debug("API key authentication failed: no matching key", "prefix", prefix)
126+
return nil, auth.ErrInvalidCredentials
127+
}
128+
129+
func (a *APIKeyAuthenticator) resolveScopes(ctx context.Context, apiKey *state.APIKey) ([]string, error) {
130+
userScopes, err := state.GetUserScopes(ctx, a.db, apiKey.UserID)
131+
if err != nil {
132+
return nil, err
133+
}
134+
135+
if !apiKey.ScopeRestricted {
136+
return userScopes, nil
137+
}
138+
139+
keyScopes, err := apiKey.ScopesList()
140+
if err != nil {
141+
return nil, err
142+
}
143+
if len(keyScopes) == 0 {
144+
return nil, nil
145+
}
146+
147+
userScopeSet := make(map[string]struct{}, len(userScopes))
148+
for _, s := range userScopes {
149+
userScopeSet[s] = struct{}{}
150+
}
151+
152+
effective := make([]string, 0, len(keyScopes))
153+
for _, s := range keyScopes {
154+
if _, ok := userScopeSet[s]; ok {
155+
effective = append(effective, s)
156+
}
157+
}
158+
return effective, nil
159+
}

0 commit comments

Comments
 (0)