Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions api/auth/cookie.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package auth

import (
"net/http"
"time"
)

const SessionCookieName = "git-bug-session"

func SessionCookie(token string, ttl time.Duration) *http.Cookie {
return &http.Cookie{
Name: SessionCookieName,
Value: token,
Path: "/",
MaxAge: int(ttl.Seconds()),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}
}

func ExpiredCookie() *http.Cookie {
return &http.Cookie{
Name: SessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}
}
46 changes: 46 additions & 0 deletions api/auth/jwt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package auth

import (
"time"

"github.com/golang-jwt/jwt/v5"

"github.com/git-bug/git-bug/entity"
)

const DefaultSessionTTL = 24 * time.Hour

type claims struct {
jwt.RegisteredClaims
}

// CreateToken issues a signed JWT encoding the given identity ID.
func CreateToken(id entity.Id, secret []byte, ttl time.Duration) (string, error) {
now := time.Now()
c := claims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: id.String(),
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, c).SignedString(secret)
}

// ValidateToken parses and validates a JWT, returning the encoded identity ID.
func ValidateToken(tokenStr string, secret []byte) (entity.Id, error) {
tok, err := jwt.ParseWithClaims(tokenStr, &claims{}, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, ErrNotAuthenticated
}
return secret, nil
})
if err != nil {
return entity.UnsetId, ErrNotAuthenticated
}
c, ok := tok.Claims.(*claims)
if !ok || !tok.Valid {
return entity.UnsetId, ErrNotAuthenticated
}
return entity.Id(c.Subject), nil
}
50 changes: 50 additions & 0 deletions api/auth/jwt_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package auth

import (
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/git-bug/git-bug/entity"
)

var testSecret = []byte("test-secret-key-32-bytes-long-xx")

func TestJWT_RoundTrip(t *testing.T) {
id := entity.Id("abc123def456")
tok, err := CreateToken(id, testSecret, time.Hour)
require.NoError(t, err)
require.NotEmpty(t, tok)

got, err := ValidateToken(tok, testSecret)
require.NoError(t, err)
assert.Equal(t, id, got)
}

func TestJWT_ExpiredToken(t *testing.T) {
id := entity.Id("abc123def456")
tok, err := CreateToken(id, testSecret, -time.Second)
require.NoError(t, err)

_, err = ValidateToken(tok, testSecret)
assert.ErrorIs(t, err, ErrNotAuthenticated)
}

func TestJWT_WrongSecret(t *testing.T) {
id := entity.Id("abc123def456")
tok, err := CreateToken(id, testSecret, time.Hour)
require.NoError(t, err)

_, err = ValidateToken(tok, []byte("wrong-secret"))
assert.ErrorIs(t, err, ErrNotAuthenticated)
}

func TestJWT_Malformed(t *testing.T) {
_, err := ValidateToken("not.a.jwt", testSecret)
assert.ErrorIs(t, err, ErrNotAuthenticated)

_, err = ValidateToken("", testSecret)
assert.ErrorIs(t, err, ErrNotAuthenticated)
}
89 changes: 89 additions & 0 deletions api/auth/middleware_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package auth

import (
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/git-bug/git-bug/entity"
)

func TestJWTMiddleware_ValidToken(t *testing.T) {
id := entity.Id("abc123def456")
tok, err := CreateToken(id, testSecret, time.Hour)
require.NoError(t, err)

var capturedId entity.Id
handler := JWTMiddleware(testSecret, time.Hour)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

Check failure on line 21 in api/auth/middleware_test.go

View workflow job for this annotation

GitHub Actions / build-and-test / with-go (1.26.x, macos-latest)

undefined: JWTMiddleware

Check failure on line 21 in api/auth/middleware_test.go

View workflow job for this annotation

GitHub Actions / build-and-test / with-go (1.26.x, ubuntu-latest)

undefined: JWTMiddleware

Check failure on line 21 in api/auth/middleware_test.go

View workflow job for this annotation

GitHub Actions / build-and-test / with-go (1.25.x, ubuntu-latest)

undefined: JWTMiddleware

Check failure on line 21 in api/auth/middleware_test.go

View workflow job for this annotation

GitHub Actions / build-and-test / with-go (1.25.x, macos-latest)

undefined: JWTMiddleware
// Resolve the raw id from context without a real repo.
raw, ok := r.Context().Value(identityCtxKey).(entity.Id)
if ok {
capturedId = raw
}
}))

req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: tok})
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)

assert.Equal(t, id, capturedId)
// Sliding window: a fresh Set-Cookie must be present and valid.
cookies := rr.Result().Cookies()
var found bool
for _, c := range cookies {
if c.Name == SessionCookieName {
found = true
_, err := ValidateToken(c.Value, testSecret)
assert.NoError(t, err, "reissued cookie must contain a valid token")
}
}
assert.True(t, found, "expected Set-Cookie header")
}

func TestJWTMiddleware_NoCookie(t *testing.T) {
var called bool
handler := JWTMiddleware(testSecret, time.Hour)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

Check failure on line 50 in api/auth/middleware_test.go

View workflow job for this annotation

GitHub Actions / build-and-test / with-go (1.26.x, macos-latest)

undefined: JWTMiddleware

Check failure on line 50 in api/auth/middleware_test.go

View workflow job for this annotation

GitHub Actions / build-and-test / with-go (1.26.x, ubuntu-latest)

undefined: JWTMiddleware

Check failure on line 50 in api/auth/middleware_test.go

View workflow job for this annotation

GitHub Actions / build-and-test / with-go (1.25.x, ubuntu-latest)

undefined: JWTMiddleware

Check failure on line 50 in api/auth/middleware_test.go

View workflow job for this annotation

GitHub Actions / build-and-test / with-go (1.25.x, macos-latest)

undefined: JWTMiddleware
called = true
// Context must not have an identity.
_, ok := r.Context().Value(identityCtxKey).(entity.Id)
assert.False(t, ok)
}))

req := httptest.NewRequest("GET", "/", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)

assert.True(t, called)
// No Set-Cookie should be issued.
assert.Empty(t, rr.Result().Cookies())
}

func TestJWTMiddleware_ExpiredToken(t *testing.T) {
id := entity.Id("abc123def456")
tok, err := CreateToken(id, testSecret, -time.Second)
require.NoError(t, err)

var called bool
handler := JWTMiddleware(testSecret, time.Hour)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

Check failure on line 72 in api/auth/middleware_test.go

View workflow job for this annotation

GitHub Actions / build-and-test / with-go (1.26.x, macos-latest)

undefined: JWTMiddleware

Check failure on line 72 in api/auth/middleware_test.go

View workflow job for this annotation

GitHub Actions / build-and-test / with-go (1.26.x, ubuntu-latest)

undefined: JWTMiddleware

Check failure on line 72 in api/auth/middleware_test.go

View workflow job for this annotation

GitHub Actions / build-and-test / with-go (1.25.x, ubuntu-latest)

undefined: JWTMiddleware

Check failure on line 72 in api/auth/middleware_test.go

View workflow job for this annotation

GitHub Actions / build-and-test / with-go (1.25.x, macos-latest)

undefined: JWTMiddleware
called = true
_, ok := r.Context().Value(identityCtxKey).(entity.Id)
assert.False(t, ok, "expired token must not set identity in context")
}))

req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: tok})
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)

assert.True(t, called)
// Stale cookie must be cleared.
cookies := rr.Result().Cookies()
require.Len(t, cookies, 1)
assert.Equal(t, SessionCookieName, cookies[0].Name)
assert.True(t, cookies[0].MaxAge < 0, "expired cookie must have negative MaxAge")
}
70 changes: 70 additions & 0 deletions api/auth/oauth/handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package oauth

import (
"net/http"
"time"

"github.com/markbates/goth"
"github.com/markbates/goth/gothic"

"github.com/git-bug/git-bug/api/auth"
"github.com/git-bug/git-bug/cache"
)

// Handler handles the OAuth login/callback/logout routes.
type Handler struct {
identities *cache.RepoCacheIdentity
secret []byte
ttl time.Duration
// completeAuth is the function that completes the OAuth dance and returns the
// authenticated user. Replaced in tests to avoid real network calls.
completeAuth func(w http.ResponseWriter, r *http.Request) (goth.User, error)
}

// NewHandler creates a new OAuth handler.
func NewHandler(identities *cache.RepoCacheIdentity, secret []byte, ttl time.Duration) *Handler {
return &Handler{
identities: identities,
secret: secret,
ttl: ttl,
completeAuth: gothic.CompleteUserAuth,
}
}

// Begin redirects the user to the provider's authorization URL.
func (h *Handler) Begin(w http.ResponseWriter, r *http.Request) {
gothic.BeginAuthHandler(w, r)
}

// Callback handles the provider redirect, imports the identity, and sets the
// session cookie before redirecting back to the webui root.
func (h *Handler) Callback(w http.ResponseWriter, r *http.Request) {
gothUser, err := h.completeAuth(w, r)
if err != nil {
http.Error(w, "authentication failed: "+err.Error(), http.StatusUnauthorized)
return
}

provider := gothUser.Provider

identity, err := FindOrImport(h.identities, gothUser, provider)
if err != nil {
http.Error(w, "identity error: "+err.Error(), http.StatusInternalServerError)
return
}

token, err := auth.CreateToken(identity.Id(), h.secret, h.ttl)
if err != nil {
http.Error(w, "session error: "+err.Error(), http.StatusInternalServerError)
return
}

http.SetCookie(w, auth.SessionCookie(token, h.ttl))
http.Redirect(w, r, "/", http.StatusSeeOther)
}

// Logout clears the session cookie.
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, auth.ExpiredCookie())
http.Redirect(w, r, "/", http.StatusSeeOther)
}
88 changes: 88 additions & 0 deletions api/auth/oauth/handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package oauth

import (
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/markbates/goth"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/git-bug/git-bug/api/auth"
)

var testSecret = []byte("test-secret-key-32-bytes-long-xx")

func makeHandler(t *testing.T) (*Handler, func(goth.User, error)) {
t.Helper()
identities := newTestCache(t)

var stubUser goth.User
var stubErr error
h := NewHandler(identities, testSecret, time.Hour)
h.completeAuth = func(w http.ResponseWriter, r *http.Request) (goth.User, error) {
return stubUser, stubErr
}

setStub := func(u goth.User, err error) {
stubUser = u
stubErr = err
}
return h, setStub
}

func TestCallback_SetsSessionCookieAndRedirects(t *testing.T) {
h, setStub := makeHandler(t)
setStub(makeGothUser("12345678", "Alice", "[email protected]", "alice", ""), nil)

req := httptest.NewRequest("GET", "/auth/github/callback?code=fakecode", nil)
rr := httptest.NewRecorder()
h.Callback(rr, req)

assert.Equal(t, http.StatusSeeOther, rr.Code)
assert.Equal(t, "/", rr.Header().Get("Location"))

cookies := rr.Result().Cookies()
var sessionCookie *http.Cookie
for _, c := range cookies {
if c.Name == auth.SessionCookieName {
sessionCookie = c
}
}
require.NotNil(t, sessionCookie, "session cookie must be set")
assert.True(t, sessionCookie.HttpOnly)
assert.NotEmpty(t, sessionCookie.Value)

// Token must decode to a valid identity ID.
id, err := auth.ValidateToken(sessionCookie.Value, testSecret)
require.NoError(t, err)
assert.NotEmpty(t, id)
}

func TestCallback_ProviderErrorReturns401(t *testing.T) {
h, setStub := makeHandler(t)
setStub(goth.User{}, assert.AnError)

req := httptest.NewRequest("GET", "/auth/github/callback", nil)
rr := httptest.NewRecorder()
h.Callback(rr, req)

assert.Equal(t, http.StatusUnauthorized, rr.Code)
assert.Empty(t, rr.Result().Cookies())
}

func TestLogout_ClearsSessionCookie(t *testing.T) {
h, _ := makeHandler(t)

req := httptest.NewRequest("POST", "/auth/logout", nil)
rr := httptest.NewRecorder()
h.Logout(rr, req)

assert.Equal(t, http.StatusSeeOther, rr.Code)
cookies := rr.Result().Cookies()
require.Len(t, cookies, 1)
assert.Equal(t, auth.SessionCookieName, cookies[0].Name)
assert.True(t, cookies[0].MaxAge < 0, "logout must expire the session cookie")
}
Loading
Loading