Skip to content

Commit b2991c4

Browse files
mmartinvclaude
andcommitted
feat: add auth Identity model and Authenticator interface
Add the Identity type that represents an authenticated request, context helpers for propagating identity through middleware, the Authenticator interface for pluggable auth mechanisms, and the ErrInvalidCredentials sentinel error. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Miguel Martín <mmartinv@redhat.com>
1 parent 7b8d919 commit b2991c4

3 files changed

Lines changed: 183 additions & 0 deletions

File tree

internal/auth/authenticator.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// SPDX-FileCopyrightText: (C) 2026 Red Hat Inc.
2+
// SPDX-License-Identifier: Apache 2.0
3+
4+
package auth
5+
6+
import (
7+
"context"
8+
"net/http"
9+
)
10+
11+
// Authenticator extracts and validates credentials from an HTTP request.
12+
//
13+
// Authenticate returns:
14+
// - (*Identity, nil) on successful authentication
15+
// - (nil, nil) when no credentials are present (the request is unauthenticated
16+
// but another authenticator may handle it)
17+
// - (nil, error) when credentials are present but invalid or an internal error occurs
18+
type Authenticator interface {
19+
Name() string
20+
Authenticate(ctx context.Context, r *http.Request) (*Identity, error)
21+
}

internal/auth/identity.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// SPDX-FileCopyrightText: (C) 2026 Red Hat Inc.
2+
// SPDX-License-Identifier: Apache 2.0
3+
4+
package auth
5+
6+
import (
7+
"context"
8+
"errors"
9+
)
10+
11+
type contextKey struct{}
12+
13+
var ErrInvalidCredentials = errors.New("invalid credentials")
14+
15+
type Identity struct {
16+
subject string
17+
name string
18+
authMethod string
19+
roles []string
20+
scopes []string
21+
metadata map[string]string
22+
}
23+
24+
func NewIdentity(subject, name, authMethod string, roles, scopes []string, metadata map[string]string) *Identity {
25+
id := &Identity{
26+
subject: subject,
27+
name: name,
28+
authMethod: authMethod,
29+
}
30+
if roles != nil {
31+
id.roles = make([]string, len(roles))
32+
copy(id.roles, roles)
33+
}
34+
if scopes != nil {
35+
id.scopes = make([]string, len(scopes))
36+
copy(id.scopes, scopes)
37+
}
38+
if metadata != nil {
39+
id.metadata = make(map[string]string, len(metadata))
40+
for k, v := range metadata {
41+
id.metadata[k] = v
42+
}
43+
}
44+
return id
45+
}
46+
47+
func (i *Identity) Subject() string { return i.subject }
48+
func (i *Identity) Name() string { return i.name }
49+
func (i *Identity) AuthMethod() string { return i.authMethod }
50+
51+
func (i *Identity) Roles() []string {
52+
out := make([]string, len(i.roles))
53+
copy(out, i.roles)
54+
return out
55+
}
56+
57+
func (i *Identity) Scopes() []string {
58+
out := make([]string, len(i.scopes))
59+
copy(out, i.scopes)
60+
return out
61+
}
62+
63+
func (i *Identity) Metadata() map[string]string {
64+
out := make(map[string]string, len(i.metadata))
65+
for k, v := range i.metadata {
66+
out[k] = v
67+
}
68+
return out
69+
}
70+
71+
func (i *Identity) HasAllScopes(required []string) bool {
72+
if len(required) == 0 {
73+
return true
74+
}
75+
if i == nil || len(i.scopes) < len(required) {
76+
return false
77+
}
78+
have := make(map[string]struct{}, len(i.scopes))
79+
for _, s := range i.scopes {
80+
have[s] = struct{}{}
81+
}
82+
for _, r := range required {
83+
if _, ok := have[r]; !ok {
84+
return false
85+
}
86+
}
87+
return true
88+
}
89+
90+
func ContextWithIdentity(ctx context.Context, id *Identity) context.Context {
91+
if id == nil {
92+
return ctx
93+
}
94+
return context.WithValue(ctx, contextKey{}, id)
95+
}
96+
97+
// IdentityFromContext retrieves the authenticated identity from the context.
98+
// Returns (nil, false) when no identity is present.
99+
func IdentityFromContext(ctx context.Context) (*Identity, bool) {
100+
id, ok := ctx.Value(contextKey{}).(*Identity)
101+
return id, ok
102+
}

internal/auth/identity_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// SPDX-FileCopyrightText: (C) 2026 Red Hat Inc.
2+
// SPDX-License-Identifier: Apache 2.0
3+
4+
package auth
5+
6+
import (
7+
"context"
8+
"testing"
9+
)
10+
11+
func TestHasAllScopes(t *testing.T) {
12+
id := NewIdentity("", "", "", nil, []string{"vouchers:read", "vouchers:write", "vouchers:delete", "device-ca:read"}, nil)
13+
14+
tests := []struct {
15+
name string
16+
required []string
17+
want bool
18+
}{
19+
{"single present scope", []string{"vouchers:read"}, true},
20+
{"multiple present scopes", []string{"vouchers:read", "vouchers:write"}, true},
21+
{"all present scopes", []string{"vouchers:delete"}, true},
22+
{"missing scope", []string{"vouchers:extend"}, false},
23+
{"one present one missing", []string{"vouchers:read", "vouchers:extend"}, false},
24+
{"nil required", nil, true},
25+
{"empty required", []string{}, true},
26+
}
27+
28+
for _, tt := range tests {
29+
t.Run(tt.name, func(t *testing.T) {
30+
got := id.HasAllScopes(tt.required)
31+
if got != tt.want {
32+
t.Errorf("HasAllScopes(%v) = %v, want %v", tt.required, got, tt.want)
33+
}
34+
})
35+
}
36+
}
37+
38+
func TestIdentityContext(t *testing.T) {
39+
id := NewIdentity("user-123", "admin", "", nil, nil, nil)
40+
ctx := ContextWithIdentity(context.Background(), id)
41+
42+
found, ok := IdentityFromContext(ctx)
43+
if !ok || found == nil {
44+
t.Fatal("expected identity in context, got nil")
45+
}
46+
if found.Subject() != "user-123" {
47+
t.Errorf("expected subject 'user-123', got %q", found.Subject())
48+
}
49+
50+
empty, ok := IdentityFromContext(context.Background())
51+
if ok || empty != nil {
52+
t.Errorf("expected no identity from empty context, got %v", empty)
53+
}
54+
55+
nilCtx := ContextWithIdentity(context.Background(), nil)
56+
nilID, nilOK := IdentityFromContext(nilCtx)
57+
if nilOK || nilID != nil {
58+
t.Errorf("expected (nil, false) after storing nil identity, got (%v, %v)", nilID, nilOK)
59+
}
60+
}

0 commit comments

Comments
 (0)