coverage~bigbes/sr-ht-doltede3b0bbauthn/token.go

Coverage
97.7% 43/44 statements
Δ
Blob
b1107ec
Uncovered L112-L113
1 package authn
2
3 import (
4 "context"
5 "crypto/sha512"
6 "fmt"
7 "sync"
8 "time"
9
10 "sourcecraft.dev/bigbes/sr-ht-core/auth"
11
12 "sourcecraft.dev/bigbes/sr-ht-dolt/core"
13 )
14
15 // RepoScope is the OAuth grant scope a meta.sr.ht personal access token must
16 // carry to act on dolt.sr.ht repositories: "dolt.sr.ht/repos". Reads require
17 // ":RO", pushes require ":RW". Personal tokens with no explicit grants are
18 // universal and pass unconditionally (auth.Grants.HasAll semantics).
19 const RepoScope = "dolt.sr.ht/repos"
20
21 // tokenCacheTTL bounds how long a positively-resolved Basic token is trusted
22 // without re-checking revocation on meta.sr.ht. A single push issues many RPCs;
23 // caching keeps each from hammering meta while bounding the revocation-lag
24 // window to this duration.
25 const tokenCacheTTL = 60 * time.Second
26
27 type cacheEntry struct {
28 ac *auth.AuthContext
29 expires time.Time
30 }
31
32 var (
33 tokenCacheMu sync.Mutex
34 tokenCache = map[[64]byte]cacheEntry{}
35 // nowFn is overridable in tests to exercise cache expiry deterministically.
36 nowFn = time.Now
37 )
38
39 23 func cacheGet(key [64]byte) *auth.AuthContext {
40 23 tokenCacheMu.Lock()
41 23 defer tokenCacheMu.Unlock()
42 23 e, ok := tokenCache[key]
43 23 if !ok {
44 20 return nil
45 20 }
46 3 if !nowFn().Before(e.expires) {
47 1 delete(tokenCache, key)
48 1 return nil
49 1 }
50 2 return e.ac
51 }
52
53 11 func cachePut(key [64]byte, ac *auth.AuthContext) {
54 11 tokenCacheMu.Lock()
55 11 defer tokenCacheMu.Unlock()
56 11 tokenCache[key] = cacheEntry{ac: ac, expires: nowFn().Add(tokenCacheTTL)}
57 11 }
58
59 // ResolveBasic resolves the caller for a Basic-auth credential: a meta.sr.ht
60 // personal access token presented as the password alongside username. It
61 // implements core-go's OAuth2 validation trio, offline-first:
62 //
63 // 1. auth.DecodeBearerToken(password) — offline HMAC + expiry check.
64 // 2. The token's own username must equal the presented username (case- and
65 // "~"-insensitive), so a token cannot be used to impersonate another user.
66 // 3. meta.LookupUser (mirror the profile) + meta.IsRevoked (revocation check).
67 //
68 // A positive result is cached for tokenCacheTTL keyed by sha512(password);
69 // negative results are never cached. Suspended users resolve successfully — the
70 // suspension flag rides on the caller and gates writes at the access layer.
71 //
72 // Permanent rejections (bad/expired token, username mismatch, revoked) wrap
73 // ErrInvalidToken; a backend failure (meta unreachable, database error) is
74 // returned unwrapped so callers treat it as transient. See package core for how
75 // the resulting grants are enforced (TokenGrantsAllow).
76 23 func ResolveBasic(ctx context.Context, username, password string) (*auth.AuthContext, error) {
77 23 hash := sha512.Sum512([]byte(password))
78 23
79 23 if ac := cacheGet(hash); ac != nil {
80 2 // Guard against a cached entry being reused under a different presented
81 2 // username (same password could only be the same token, but check
82 2 // anyway — defence in depth costs nothing here).
83 2 if equalUsername(username, ac.Username) {
84 2 return ac, nil
85 2 }
86 }
87
88 21 bt := auth.DecodeBearerToken(password)
89 21 if bt == nil {
90 2 return nil, fmt.Errorf("%w: token failed HMAC/expiry validation", ErrInvalidToken)
91 2 }
92 19 if !equalUsername(bt.Username, username) {
93 1 return nil, fmt.Errorf("%w: token belongs to %q, not presented user %q",
94 1 ErrInvalidToken, bt.Username, username)
95 1 }
96
97 18 var ac auth.AuthContext
98 18 if err := meta.LookupUser(ctx, bt.Username, &ac); err != nil {
99 3 return nil, fmt.Errorf("looking up user %q: %w", bt.Username, err)
100 3 }
101
102 15 revoked, err := meta.IsRevoked(ctx, bt.Username, hash, bt.ClientID)
103 15 if err != nil {
104 2 return nil, fmt.Errorf("checking token revocation for %q: %w", bt.Username, err)
105 2 }
106 13 if revoked {
107 2 return nil, fmt.Errorf("%w: token has been revoked", ErrInvalidToken)
108 2 }
109
110 11 grants, err := auth.DecodeGrants(ctx, bt.Grants)
111 11 if err != nil {
112 0 return nil, fmt.Errorf("%w: decoding token grants: %v", ErrInvalidToken, err)
113 0 }
114
115 11 ac.AuthMethod = auth.AUTH_OAUTH2
116 11 ac.BearerToken = bt
117 11 ac.TokenHash = hash
118 11 ac.Grants = grants
119 11
120 11 cachePut(hash, &ac)
121 11 return &ac, nil
122 }
123
124 // TokenGrantsAllow reports whether the caller's token grants permit access at
125 // the given mode (core.AccessRO for browse/clone, core.AccessRW for push) on
126 // dolt.sr.ht repositories. It is the OAuth-grant gate that complements the ACL
127 // decision in core.Allowed: a token must carry BOTH sufficient grants and a
128 // sufficient ACL/visibility to act.
129 //
130 // Non-token callers (anonymous, cookie, or dolt-key auth) carry no OAuth grants
131 // and are not scoped by them, so they pass this gate unconditionally; their
132 // access is decided solely by core.Allowed. Personal tokens with empty grants
133 // are universal and also pass.
134 14 func TokenGrantsAllow(ac *auth.AuthContext, mode core.AccessMode) bool {
135 14 if ac == nil || ac.BearerToken == nil {
136 3 return true
137 3 }
138 11 kind := auth.RO
139 11 if mode == core.AccessRW {
140 3 kind = auth.RW
141 3 }
142 11 return ac.Grants.Has(RepoScope, kind)
143 }