Go · 5906 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package githttp
4
5 import (
6 "context"
7 "encoding/base64"
8 "errors"
9 "strings"
10 "time"
11
12 actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
13 "github.com/tenseleyFlow/shithub/internal/auth/password"
14 "github.com/tenseleyFlow/shithub/internal/auth/pat"
15 "github.com/tenseleyFlow/shithub/internal/auth/runnerjwt"
16 )
17
18 // resolvedAuth carries the resolved identity for a git-over-HTTPS
19 // request. Anonymous = true is the "no Authorization header" case;
20 // callers decide whether anonymous is allowed (yes for pulling a public
21 // repo, no for everything else).
22 type resolvedAuth struct {
23 Anonymous bool
24 UserID int64
25 Username string
26 ViaPAT bool
27 ViaRunnerCheckout bool
28 RunnerCheckoutRepo int64
29 }
30
31 // errBadCredentials is the catch-all for "creds were sent but didn't
32 // resolve." We DON'T distinguish the failure reasons (wrong username,
33 // wrong password, revoked PAT, etc.) so the response is identical to
34 // "no creds at all" — preventing username probes via timing/messaging.
35 var errBadCredentials = errors.New("githttp: bad credentials")
36
37 // resolveBasicAuth parses the Authorization header and resolves it
38 // against the DB. Three outcomes:
39 //
40 // Anonymous=true, err=nil — no credentials supplied
41 // Anonymous=false, err=nil — credentials matched a real user
42 // Anonymous=false, err!=nil — credentials present but invalid
43 //
44 // PAT path is preferred when the secret carries the canonical
45 // `shithub_pat_` prefix; password path is the fallback. A failed PAT
46 // lookup falls through to password — if a user happens to set their
47 // account password to a string that starts with our PAT prefix, we
48 // still try to authenticate them.
49 func (h *Handlers) resolveBasicAuth(ctx context.Context, header string) (resolvedAuth, error) {
50 if header == "" {
51 return resolvedAuth{Anonymous: true}, nil
52 }
53 scheme, rest, ok := strings.Cut(header, " ")
54 if !ok || !strings.EqualFold(scheme, "Basic") {
55 return resolvedAuth{}, errBadCredentials
56 }
57 decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(rest))
58 if err != nil {
59 return resolvedAuth{}, errBadCredentials
60 }
61 user, secret, ok := strings.Cut(string(decoded), ":")
62 if !ok {
63 return resolvedAuth{}, errBadCredentials
64 }
65
66 if got, err := h.resolveViaRunnerCheckout(ctx, secret); err == nil {
67 return got, nil
68 }
69 if strings.HasPrefix(secret, pat.Prefix) {
70 if got, err := h.resolveViaPAT(ctx, secret); err == nil {
71 return got, nil
72 }
73 // Fall through — a non-matching PAT prefix could still be a
74 // user-chosen password.
75 }
76 return h.resolveViaPassword(ctx, user, secret)
77 }
78
79 func (h *Handlers) resolveViaRunnerCheckout(ctx context.Context, raw string) (resolvedAuth, error) {
80 if h.d.RunnerJWT == nil || raw == "" {
81 return resolvedAuth{}, errBadCredentials
82 }
83 claims, err := h.d.RunnerJWT.Verify(raw)
84 if err != nil || claims.Purpose != runnerjwt.PurposeCheckout {
85 return resolvedAuth{}, errBadCredentials
86 }
87 runnerID, err := claims.RunnerID()
88 if err != nil {
89 return resolvedAuth{}, errBadCredentials
90 }
91 job, err := h.aq.GetWorkflowJobByID(ctx, h.d.Pool, claims.JobID)
92 if err != nil {
93 return resolvedAuth{}, errBadCredentials
94 }
95 run, err := h.aq.GetWorkflowRunByID(ctx, h.d.Pool, job.RunID)
96 if err != nil {
97 return resolvedAuth{}, errBadCredentials
98 }
99 if job.RunID != claims.RunID ||
100 run.RepoID != claims.RepoID ||
101 job.Status != actionsdb.WorkflowJobStatusRunning ||
102 !job.RunnerID.Valid ||
103 job.RunnerID.Int64 != runnerID {
104 return resolvedAuth{}, errBadCredentials
105 }
106 return resolvedAuth{
107 Username: "shithub-actions",
108 ViaRunnerCheckout: true,
109 RunnerCheckoutRepo: claims.RepoID,
110 }, nil
111 }
112
113 // resolveViaPAT looks up the token by its sha256 hash, checks
114 // revoked/expired/suspended, and returns the owning user. Returns
115 // errBadCredentials on any failure (no leak about which check failed).
116 func (h *Handlers) resolveViaPAT(ctx context.Context, raw string) (resolvedAuth, error) {
117 hash, err := pat.HashOf(raw)
118 if err != nil {
119 return resolvedAuth{}, errBadCredentials
120 }
121 row, err := h.uq.GetUserTokenByHash(ctx, h.d.Pool, hash)
122 if err != nil {
123 return resolvedAuth{}, errBadCredentials
124 }
125 if row.RevokedAt.Valid {
126 return resolvedAuth{}, errBadCredentials
127 }
128 if row.ExpiresAt.Valid && time.Now().After(row.ExpiresAt.Time) {
129 return resolvedAuth{}, errBadCredentials
130 }
131 user, err := h.uq.GetUserByID(ctx, h.d.Pool, row.UserID)
132 if err != nil || user.SuspendedAt.Valid {
133 return resolvedAuth{}, errBadCredentials
134 }
135 return resolvedAuth{
136 UserID: user.ID, Username: user.Username, ViaPAT: true,
137 }, nil
138 }
139
140 // resolveViaPassword verifies the supplied secret against the user's
141 // argon2id hash. The username supplied in the Basic header is taken at
142 // face value here — git's credential prompt typically asks "Username
143 // for shithub:" and the user types their shithub username.
144 //
145 // Constant-time discipline: when the username doesn't exist we still
146 // run VerifyAgainstDummy so the response time is the same as a wrong
147 // password.
148 func (h *Handlers) resolveViaPassword(ctx context.Context, username, secret string) (resolvedAuth, error) {
149 if username == "" {
150 // No way to look up; still burn time on a dummy to avoid timing leaks.
151 password.VerifyAgainstDummy(secret)
152 return resolvedAuth{}, errBadCredentials
153 }
154 user, err := h.uq.GetUserByUsername(ctx, h.d.Pool, username)
155 if err != nil {
156 password.VerifyAgainstDummy(secret)
157 return resolvedAuth{}, errBadCredentials
158 }
159 if user.SuspendedAt.Valid || user.DeletedAt.Valid {
160 password.VerifyAgainstDummy(secret)
161 return resolvedAuth{}, errBadCredentials
162 }
163 ok, err := password.Verify(secret, user.PasswordHash)
164 if err != nil || !ok {
165 return resolvedAuth{}, errBadCredentials
166 }
167 return resolvedAuth{
168 UserID: user.ID, Username: user.Username, ViaPAT: false,
169 }, nil
170 }
171