From fc10bb6688e59ceb206f0d9cc1300799346a7902 Mon Sep 17 00:00:00 2001 From: Thomas <27960254+thomiceli@users.noreply.github.com> Date: Tue, 30 Jun 2026 00:00:37 +0700 Subject: [PATCH] Push to gists with access tokens (#752) --- internal/auth/try_login.go | 32 +++++++++++++++ internal/web/handlers/git/access.go | 6 +-- internal/web/handlers/git/auth.go | 22 ++++++++--- internal/web/handlers/git/http_test.go | 55 ++++++++++++++++++++++++++ internal/web/handlers/git/init.go | 2 +- 5 files changed, 107 insertions(+), 10 deletions(-) diff --git a/internal/auth/try_login.go b/internal/auth/try_login.go index aa7ce1e..4e39262 100644 --- a/internal/auth/try_login.go +++ b/internal/auth/try_login.go @@ -2,6 +2,7 @@ package auth import ( "errors" + "strings" "github.com/rs/zerolog/log" "github.com/thomiceli/opengist/internal/auth/ldap" @@ -37,6 +38,37 @@ func TryAuthentication(username, password string) (*db.User, error) { } } +// TryAuthenticationWithAccessToken attempts to authenticate using a plain-text access token. +// It verifies the token belongs to the given username, is not expired, and has the required scope/permission. +func TryAuthenticationWithAccessToken(username, plainToken string, scope, permission uint) (*db.User, *db.AccessToken, error) { + if !strings.HasPrefix(plainToken, "og_") { + return nil, nil, AuthError{"not an access token"} + } + + token, err := db.GetAccessTokenByToken(plainToken) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil, AuthError{"invalid access token"} + } + log.Error().Err(err).Msg("Cannot get access token") + return nil, nil, err + } + + if token.IsExpired() { + return nil, nil, AuthError{"access token is expired"} + } + + if token.User.Username != username { + return nil, nil, AuthError{"access token does not belong to user"} + } + + if !token.CheckForPermission(scope, permission) { + return nil, nil, AuthError{"access token does not have required permission"} + } + + return &token.User, token, nil +} + func tryDbLogin(user *db.User, password string) (*db.User, error) { if ok, err := passwordpkg.VerifyPassword(password, user.Password); !ok { if err != nil { diff --git a/internal/web/handlers/git/access.go b/internal/web/handlers/git/access.go index 10bef94..7af87b1 100644 --- a/internal/web/handlers/git/access.go +++ b/internal/web/handlers/git/access.go @@ -27,7 +27,7 @@ func handlePull(ctx *context.Context, route *gitRoute, gist *db.Gist, gistExists log.Debug().Str("authUsername", username).Str("gistOwner", gist.User.Username).Msg("Pulling private gist") } - if user, err := authOrFail(ctx, userToCheck, password, 404, "Check your credentials or make sure you have access to the Gist"); user == nil { + if user, err := authOrFail(ctx, userToCheck, password, db.ScopeGist, db.ReadPermission, 404, "Check your credentials or make sure you have access to the Gist"); user == nil { return err } @@ -42,7 +42,7 @@ func handlePush(ctx *context.Context, route *gitRoute, gist *db.Gist, gistExists if gistExists { log.Debug().Str("authUsername", username).Str("gistOwner", gist.User.Username).Msg("Pushing to existing gist") - if user, err := authOrFail(ctx, gist.User.Username, password, 404, "Check your credentials or make sure you have access to the Gist"); user == nil { + if user, err := authOrFail(ctx, gist.User.Username, password, db.ScopeGist, db.ReadWritePermission, 404, "Check your credentials or make sure you have access to the Gist"); user == nil { return err } @@ -57,7 +57,7 @@ func handlePush(ctx *context.Context, route *gitRoute, gist *db.Gist, gistExists // The gist does not exist: the user creates it by pushing to //. log.Debug().Str("authUsername", username).Msg("Creating new gist by pushing") - user, err := authOrFail(ctx, username, password, 404, "Check your credentials or make sure you have access to the Gist") + user, err := authOrFail(ctx, username, password, db.ScopeGist, db.ReadWritePermission, 404, "Check your credentials or make sure you have access to the Gist") if user == nil { return err } diff --git a/internal/web/handlers/git/auth.go b/internal/web/handlers/git/auth.go index 031bb02..f9b89a6 100644 --- a/internal/web/handlers/git/auth.go +++ b/internal/web/handlers/git/auth.go @@ -11,9 +11,11 @@ import ( "github.com/thomiceli/opengist/internal/web/context" ) -// authOrFail authenticates the given credentials. On success it returns the -// user. On failure it writes the appropriate HTTP response and returns a nil -// user, so callers stop with: +// authOrFail authenticates the given credentials. The credential is tried as a +// password (DB or LDAP) first, then as an access token with the required scope +// and permission — this lets SSO/passwordless users authenticate over Git HTTP. +// On success it returns the user. On failure it writes the appropriate HTTP +// response and returns a nil user, so callers stop with: // // user, err := authOrFail(...) // if user == nil { @@ -22,18 +24,26 @@ import ( // // `err` is nil for an already-written invalid-credentials response and a // renderable error for an internal authentication failure. -func authOrFail(ctx *context.Context, username, password string, invalidCode int, invalidMsg string) (*db.User, error) { +func authOrFail(ctx *context.Context, username, password string, scope, permission uint, invalidCode int, invalidMsg string) (*db.User, error) { user, err := auth.TryAuthentication(username, password) if err == nil { return user, nil } var authErr auth.AuthError - if errors.As(err, &authErr) { + if !errors.As(err, &authErr) { + return nil, ctx.ErrorRes(500, "Authentication system error", nil) + } + + // Fall back to access token authentication. + user, token, tokenErr := auth.TryAuthenticationWithAccessToken(username, password, scope, permission) + if tokenErr != nil { log.Warn().Msg("Invalid HTTP authentication attempt from " + ctx.RealIP()) return nil, ctx.PlainText(invalidCode, invalidMsg) } - return nil, ctx.ErrorRes(500, "Authentication system error", nil) + _ = token.UpdateLastUsed() + + return user, nil } func basicAuth(ctx *context.Context) error { diff --git a/internal/web/handlers/git/http_test.go b/internal/web/handlers/git/http_test.go index 5860d0b..73df946 100644 --- a/internal/web/handlers/git/http_test.go +++ b/internal/web/handlers/git/http_test.go @@ -312,6 +312,61 @@ func TestGitInitPushParallel(t *testing.T) { require.EqualValues(t, 0, queued, "init queue should be empty after all pushes complete") } +func TestGitAuthWithAccessToken(t *testing.T) { + s := webtest.Setup(t) + defer webtest.Teardown(t) + + baseUrl := s.StartHttpServer(t) + + s.Register(t, "thomas") + + _, _, user, privateId := s.CreateGist(t, "2") + + // CreateGist logs out at the end; log back in to create tokens for thomas. + s.Login(t, "thomas") + + rwToken := s.CreateAccessToken(t, "rw", db.ReadWritePermission, db.NoPermission) + roToken := s.CreateAccessToken(t, "ro", db.ReadPermission, db.NoPermission) + noToken := s.CreateAccessToken(t, "none", db.NoPermission, db.NoPermission) + + tests := []struct { + name string + token string + // clone (pull) requires gist read permission, push requires gist write permission + canClone bool + canPush bool + }{ + {"ReadWriteToken", rwToken, true, true}, + {"ReadOnlyToken", roToken, true, false}, + {"NoGistPermissionToken", noToken, false, false}, + {"InvalidToken", "og_deadbeef", false, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + creds := "thomas:" + tt.token + + // Clone (pull) the private gist using the token as the password. + dest := t.TempDir() + err := gitClone(baseUrl, creds, user, privateId, dest) + if tt.canClone { + require.NoError(t, err) + } else { + require.Error(t, err) + return + } + + // Push to the gist using the token as the password. + err = gitPush(dest, "token.txt", "from token") + if tt.canPush { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } +} + func TestGitCreatePush(t *testing.T) { s := webtest.Setup(t) defer webtest.Teardown(t) diff --git a/internal/web/handlers/git/init.go b/internal/web/handlers/git/init.go index f0b03b0..f8e4f23 100644 --- a/internal/web/handlers/git/init.go +++ b/internal/web/handlers/git/init.go @@ -49,7 +49,7 @@ func classifyInitRequest(urlPath string) (initKind, string) { // handleInit serves a request belonging to the "git push .../init" flow, after // the caller has been authenticated. func handleInit(ctx *context.Context, route *gitRoute, kind initKind, token, username, password string) error { - user, err := authOrFail(ctx, username, password, 401, "Invalid credentials") + user, err := authOrFail(ctx, username, password, db.ScopeGist, db.ReadWritePermission, 401, "Invalid credentials") if user == nil { return err }