~bigbes/sr-ht-dolt

ref: 60fdb6dd03bcfe04a5448b24ee3a5db8fd9ebce8 sr-ht-dolt/authn/token.go -rw-r--r-- 4.7 KiB
60fdb6dd — Eugene Blikh beads: show the stored rows behind an issue on its detail pane 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)
}