~bigbes/sr-ht-dolt

ref: a1fe404f916f0a66242954c9843115ef7146584c sr-ht-dolt/authn/token.go -rw-r--r-- 4.7 KiB
a1fe404f — Eugene Blikh docs: spell out the clipped-read answers 5 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
package authn

import (
	"context"
	"crypto/sha512"
	"fmt"
	"sync"
	"time"

	"sourcecraft.dev/bigbes/sr-ht-core/auth"

	"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)

// RepoScope is the OAuth grant scope a meta.sr.ht personal access token must
// carry to act on dolt.sr.ht repositories: "dolt.sr.ht/repos". Reads require
// ":RO", pushes require ":RW". Personal tokens with no explicit grants are
// universal and pass unconditionally (auth.Grants.HasAll semantics).
const RepoScope = "dolt.sr.ht/repos"

// tokenCacheTTL bounds how long a positively-resolved Basic token is trusted
// without re-checking revocation on meta.sr.ht. A single push issues many RPCs;
// caching keeps each from hammering meta while bounding the revocation-lag
// window to this duration.
const tokenCacheTTL = 60 * time.Second

type cacheEntry struct {
	ac      *auth.AuthContext
	expires time.Time
}

var (
	tokenCacheMu sync.Mutex
	tokenCache   = map[[64]byte]cacheEntry{}
	// nowFn is overridable in tests to exercise cache expiry deterministically.
	nowFn = time.Now
)

func cacheGet(key [64]byte) *auth.AuthContext {
	tokenCacheMu.Lock()
	defer tokenCacheMu.Unlock()
	e, ok := tokenCache[key]
	if !ok {
		return nil
	}
	if !nowFn().Before(e.expires) {
		delete(tokenCache, key)
		return nil
	}
	return e.ac
}

func cachePut(key [64]byte, ac *auth.AuthContext) {
	tokenCacheMu.Lock()
	defer tokenCacheMu.Unlock()
	tokenCache[key] = cacheEntry{ac: ac, expires: nowFn().Add(tokenCacheTTL)}
}

// ResolveBasic resolves the caller for a Basic-auth credential: a meta.sr.ht
// personal access token presented as the password alongside username. It
// implements core-go's OAuth2 validation trio, offline-first:
//
//  1. auth.DecodeBearerToken(password) — offline HMAC + expiry check.
//  2. The token's own username must equal the presented username (case- and
//     "~"-insensitive), so a token cannot be used to impersonate another user.
//  3. meta.LookupUser (mirror the profile) + meta.IsRevoked (revocation check).
//
// A positive result is cached for tokenCacheTTL keyed by sha512(password);
// negative results are never cached. Suspended users resolve successfully — the
// suspension flag rides on the caller and gates writes at the access layer.
//
// Permanent rejections (bad/expired token, username mismatch, revoked) wrap
// ErrInvalidToken; a backend failure (meta unreachable, database error) is
// returned unwrapped so callers treat it as transient. See package core for how
// the resulting grants are enforced (TokenGrantsAllow).
func ResolveBasic(ctx context.Context, username, password string) (*auth.AuthContext, error) {
	hash := sha512.Sum512([]byte(password))

	if ac := cacheGet(hash); ac != nil {
		// Guard against a cached entry being reused under a different presented
		// username (same password could only be the same token, but check
		// anyway — defence in depth costs nothing here).
		if equalUsername(username, ac.Username) {
			return ac, nil
		}
	}

	bt := auth.DecodeBearerToken(password)
	if bt == nil {
		return nil, fmt.Errorf("%w: token failed HMAC/expiry validation", ErrInvalidToken)
	}
	if !equalUsername(bt.Username, username) {
		return nil, fmt.Errorf("%w: token belongs to %q, not presented user %q",
			ErrInvalidToken, bt.Username, username)
	}

	var ac auth.AuthContext
	if err := meta.LookupUser(ctx, bt.Username, &ac); err != nil {
		return nil, fmt.Errorf("looking up user %q: %w", bt.Username, err)
	}

	revoked, err := meta.IsRevoked(ctx, bt.Username, hash, bt.ClientID)
	if err != nil {
		return nil, fmt.Errorf("checking token revocation for %q: %w", bt.Username, err)
	}
	if revoked {
		return nil, fmt.Errorf("%w: token has been revoked", ErrInvalidToken)
	}

	grants, err := auth.DecodeGrants(ctx, bt.Grants)
	if err != nil {
		return nil, fmt.Errorf("%w: decoding token grants: %v", ErrInvalidToken, err)
	}

	ac.AuthMethod = auth.AUTH_OAUTH2
	ac.BearerToken = bt
	ac.TokenHash = hash
	ac.Grants = grants

	cachePut(hash, &ac)
	return &ac, nil
}

// TokenGrantsAllow reports whether the caller's token grants permit access at
// the given mode (core.AccessRO for browse/clone, core.AccessRW for push) on
// dolt.sr.ht repositories. It is the OAuth-grant gate that complements the ACL
// decision in core.Allowed: a token must carry BOTH sufficient grants and a
// sufficient ACL/visibility to act.
//
// Non-token callers (anonymous, cookie, or dolt-key auth) carry no OAuth grants
// and are not scoped by them, so they pass this gate unconditionally; their
// access is decided solely by core.Allowed. Personal tokens with empty grants
// are universal and also pass.
func TokenGrantsAllow(ac *auth.AuthContext, mode core.AccessMode) bool {
	if ac == nil || ac.BearerToken == nil {
		return true
	}
	kind := auth.RO
	if mode == core.AccessRW {
		kind = auth.RW
	}
	return ac.Grants.Has(RepoScope, kind)
}