Skip to content
Merged
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
7 changes: 4 additions & 3 deletions auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,10 @@ const UserEmailKey Key = "userEmail"
const UserIdKey Key = "userId"

type CookieConfig struct {
Domain string // For cross-subdomain auth
Secure bool // HTTPS only (default: true)
SameSite http.SameSite // Default: SameSiteStrictMode
Domain string // For cross-subdomain auth
Secure bool // HTTPS only (default: true)
SameSite http.SameSite // Default: SameSiteStrictMode
RefreshPath string // Cookie path for refresh token. Default: "/auth/refresh"
}

type CSRFConfig struct {
Expand Down
99 changes: 99 additions & 0 deletions auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"strings"
"testing"
"time"

"github.com/google/uuid"
)

// Phase 1: URL Encoding Tests
Expand Down Expand Up @@ -138,6 +140,103 @@ func TestLoginStep1_ErrorsWhenNoReturnUrlsConfigured(t *testing.T) {
}
}

func TestLoginStep2_RejectsWrongCode(t *testing.T) {
auth := createTestAuth()
email := "test@example.com"
err := auth.Db.StoreVerificationCode(email, "123456", time.Now().Add(5*time.Minute))
if err != nil {
t.Fatalf("failed to store code: %v", err)
}

confirmed, tokens, err := auth.LoginStep2ConfirmCode(context.Background(), email, "000000")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if confirmed {
t.Error("expected confirmed to be false for wrong code")
}
if tokens != nil {
t.Error("expected tokens to be nil for wrong code")
}
}

func TestLoginStep2_RejectsExpiredCode(t *testing.T) {
auth := createTestAuth()
email := "test@example.com"
err := auth.Db.StoreVerificationCode(email, "123456", time.Now().Add(-1*time.Minute))
if err != nil {
t.Fatalf("failed to store code: %v", err)
}

confirmed, tokens, err := auth.LoginStep2ConfirmCode(context.Background(), email, "123456")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if confirmed {
t.Error("expected confirmed to be false for expired code")
}
if tokens != nil {
t.Error("expected tokens to be nil for expired code")
}
}

func TestRefreshToken_RejectsExpiredJWT(t *testing.T) {
auth := createTestAuth()

// Create an expired refresh token
expiredToken, err := createRefreshToken(
uuid.New(),
"test@example.com",
auth.Cfg.RefreshTokenSecret,
-1*time.Hour, // already expired
)
if err != nil {
t.Fatalf("failed to create token: %v", err)
}

_, err = auth.RefreshToken(context.Background(), expiredToken.Token)
if err == nil {
t.Error("expected error for expired JWT")
}
}

func TestRefreshToken_RejectsInvalidJWT(t *testing.T) {
auth := createTestAuth()

_, err := auth.RefreshToken(context.Background(), "not-a-valid-jwt")
if err == nil {
t.Error("expected error for invalid JWT")
}
}

func TestRefreshToken_ReturnsNewTokenPair(t *testing.T) {
auth := createTestAuth()

refreshToken, err := createRefreshToken(
uuid.New(),
"test@example.com",
auth.Cfg.RefreshTokenSecret,
auth.Cfg.RefreshTokenValidityPeriod,
)
if err != nil {
t.Fatalf("failed to create token: %v", err)
}

tokens, err := auth.RefreshToken(context.Background(), refreshToken.Token)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tokens == nil {
t.Fatal("expected non-nil token response")
}
if tokens.AccessToken == nil || tokens.AccessToken.Token == "" {
t.Error("expected non-empty access token")
}
if tokens.RefreshToken == nil || tokens.RefreshToken.Token == "" {
t.Error("expected non-empty refresh token")
}
}

func TestLoginStep1_AcceptsValidReturnUrl(t *testing.T) {
mockSender := &MockSender{}
auth := New(NewMemDb(), mockSender, &MockUserCreator{}, NewEmailValidator(), nil, nil, &Config{
Expand Down
14 changes: 12 additions & 2 deletions cookies.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ func (a *Auth) SetTokenCookies(w http.ResponseWriter, tokens *TokenResponse) {
cfg = &CookieConfig{Secure: true, SameSite: http.SameSiteStrictMode}
}

refreshPath := cfg.RefreshPath
if refreshPath == "" {
refreshPath = "/auth/refresh"
}

http.SetCookie(w, &http.Cookie{
Name: "access_token",
Value: tokens.AccessToken.Token,
Expand All @@ -28,7 +33,7 @@ func (a *Auth) SetTokenCookies(w http.ResponseWriter, tokens *TokenResponse) {
HttpOnly: true,
Secure: cfg.Secure,
SameSite: cfg.SameSite,
Path: "/auth/refresh", // Only sent to refresh endpoint
Path: refreshPath,
Expires: tokens.RefreshToken.TokenExpiry,
Domain: cfg.Domain,
})
Expand All @@ -40,6 +45,11 @@ func (a *Auth) ClearTokenCookies(w http.ResponseWriter) {
cfg = &CookieConfig{Secure: true, SameSite: http.SameSiteStrictMode}
}

refreshPath := cfg.RefreshPath
if refreshPath == "" {
refreshPath = "/auth/refresh"
}

expired := time.Now().Add(-24 * time.Hour)

http.SetCookie(w, &http.Cookie{
Expand All @@ -60,7 +70,7 @@ func (a *Auth) ClearTokenCookies(w http.ResponseWriter) {
HttpOnly: true,
Secure: cfg.Secure,
SameSite: cfg.SameSite,
Path: "/auth/refresh",
Path: refreshPath,
Expires: expired,
MaxAge: -1,
Domain: cfg.Domain,
Expand Down
163 changes: 163 additions & 0 deletions cookies_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,169 @@ func TestSetTokenCookies_SetsDomain(t *testing.T) {
}
}

func TestClearTokenCookies_UsesCorrectPaths(t *testing.T) {
auth := createTestAuth()

w := httptest.NewRecorder()
auth.ClearTokenCookies(w)

cookies := w.Result().Cookies()
accessCookie := findCookie(cookies, "access_token")
refreshCookie := findCookie(cookies, "refresh_token")

if accessCookie == nil {
t.Fatal("expected access_token cookie")
}
if accessCookie.Path != "/" {
t.Errorf("expected access_token path '/', got '%s'", accessCookie.Path)
}

if refreshCookie == nil {
t.Fatal("expected refresh_token cookie")
}
if refreshCookie.Path != "/auth/refresh" {
t.Errorf("expected refresh_token path '/auth/refresh', got '%s'", refreshCookie.Path)
}
}

func TestClearTokenCookies_UsesSecureDefaultsWhenConfigNil(t *testing.T) {
auth := createTestAuth() // No CookieConfig

w := httptest.NewRecorder()
auth.ClearTokenCookies(w)

cookies := w.Result().Cookies()
accessCookie := findCookie(cookies, "access_token")
refreshCookie := findCookie(cookies, "refresh_token")

if accessCookie == nil {
t.Fatal("expected access_token cookie")
}
if !accessCookie.Secure {
t.Error("expected access_token Secure to be true by default")
}

if refreshCookie == nil {
t.Fatal("expected refresh_token cookie")
}
if !refreshCookie.Secure {
t.Error("expected refresh_token Secure to be true by default")
}
}

func TestSetTokenCookies_SetsRefreshTokenExpiry(t *testing.T) {
auth := createTestAuth()

expiry := time.Now().Add(24 * time.Hour)
tokens := &TokenResponse{
AccessToken: &Token{Token: "access123", TokenExpiry: time.Now().Add(time.Hour)},
RefreshToken: &Token{Token: "refresh123", TokenExpiry: expiry},
}

w := httptest.NewRecorder()
auth.SetTokenCookies(w, tokens)

cookies := w.Result().Cookies()
refreshCookie := findCookie(cookies, "refresh_token")

if refreshCookie == nil {
t.Fatal("expected refresh_token cookie")
}
// Allow 1 second tolerance for test execution time
diff := refreshCookie.Expires.Sub(expiry)
if diff < -time.Second || diff > time.Second {
t.Errorf("expected refresh_token Expires close to %v, got %v", expiry, refreshCookie.Expires)
}
}

func TestSetTokenCookies_SetsRefreshTokenSecureAndDomain(t *testing.T) {
auth := createTestAuthWithConfig(&Config{
ReturnUrls: []string{"https://app.com/login"},
AccessTokenSecret: "secret",
RefreshTokenSecret: "secret",
CodeValidityPeriod: 5 * time.Minute,
AccessTokenValidityPeriod: 1 * time.Hour,
RefreshTokenValidityPeriod: 24 * time.Hour,
CookieConfig: &CookieConfig{Domain: ".example.com", Secure: true},
})

tokens := &TokenResponse{
AccessToken: &Token{Token: "access123", TokenExpiry: time.Now().Add(time.Hour)},
RefreshToken: &Token{Token: "refresh123", TokenExpiry: time.Now().Add(24 * time.Hour)},
}

w := httptest.NewRecorder()
auth.SetTokenCookies(w, tokens)

cookies := w.Result().Cookies()
refreshCookie := findCookie(cookies, "refresh_token")

if refreshCookie == nil {
t.Fatal("expected refresh_token cookie")
}
if !refreshCookie.Secure {
t.Error("expected refresh_token Secure to be true")
}
if refreshCookie.Domain != "example.com" {
t.Errorf("expected refresh_token domain 'example.com', got '%s'", refreshCookie.Domain)
}
}

func TestSetTokenCookies_UsesCustomRefreshPath(t *testing.T) {
auth := createTestAuthWithConfig(&Config{
ReturnUrls: []string{"https://app.com/login"},
AccessTokenSecret: "secret",
RefreshTokenSecret: "secret",
CodeValidityPeriod: 5 * time.Minute,
AccessTokenValidityPeriod: 1 * time.Hour,
RefreshTokenValidityPeriod: 24 * time.Hour,
CookieConfig: &CookieConfig{Secure: true, RefreshPath: "/api/v1/auth/refresh"},
})

tokens := &TokenResponse{
AccessToken: &Token{Token: "access123", TokenExpiry: time.Now().Add(time.Hour)},
RefreshToken: &Token{Token: "refresh123", TokenExpiry: time.Now().Add(24 * time.Hour)},
}

w := httptest.NewRecorder()
auth.SetTokenCookies(w, tokens)

cookies := w.Result().Cookies()
refreshCookie := findCookie(cookies, "refresh_token")

if refreshCookie == nil {
t.Fatal("expected refresh_token cookie")
}
if refreshCookie.Path != "/api/v1/auth/refresh" {
t.Errorf("expected path '/api/v1/auth/refresh', got '%s'", refreshCookie.Path)
}
}

func TestClearTokenCookies_UsesCustomRefreshPath(t *testing.T) {
auth := createTestAuthWithConfig(&Config{
ReturnUrls: []string{"https://app.com/login"},
AccessTokenSecret: "secret",
RefreshTokenSecret: "secret",
CodeValidityPeriod: 5 * time.Minute,
AccessTokenValidityPeriod: 1 * time.Hour,
RefreshTokenValidityPeriod: 24 * time.Hour,
CookieConfig: &CookieConfig{Secure: true, RefreshPath: "/api/v1/auth/refresh"},
})

w := httptest.NewRecorder()
auth.ClearTokenCookies(w)

cookies := w.Result().Cookies()
refreshCookie := findCookie(cookies, "refresh_token")

if refreshCookie == nil {
t.Fatal("expected refresh_token cookie")
}
if refreshCookie.Path != "/api/v1/auth/refresh" {
t.Errorf("expected path '/api/v1/auth/refresh', got '%s'", refreshCookie.Path)
}
}

func TestClearTokenCookies_ExpiresAllTokenCookies(t *testing.T) {
auth := createTestAuth()

Expand Down
Loading