Implement JWT authentication and app passkey support
- Add JWT token generation and validation - Implement bcrypt password hashing - Create auth service with register/login/refresh/logout - Add app passkey generation and management - Implement protected routes and auth middleware - Add comprehensive tests for new functionality
This commit is contained in:
243
internal/service/auth.go
Normal file
243
internal/service/auth.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/auth"
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
"github.com/dhao2001/mygo/internal/repository"
|
||||
)
|
||||
|
||||
// TokenPair contains the access and refresh tokens returned after authentication.
|
||||
type TokenPair struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
// CreatedPasskey contains the raw token for a newly created app passkey.
|
||||
type CreatedPasskey struct {
|
||||
ID string `json:"id"`
|
||||
Raw string `json:"raw"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// AuthService handles user authentication and session management.
|
||||
type AuthService struct {
|
||||
userRepo repository.UserRepository
|
||||
sessionRepo repository.SessionRepository
|
||||
credentialRepo repository.CredentialRepository
|
||||
jwtSecret []byte
|
||||
accessTTL time.Duration
|
||||
refreshTTL time.Duration
|
||||
}
|
||||
|
||||
// NewAuthService creates an AuthService.
|
||||
func NewAuthService(
|
||||
userRepo repository.UserRepository,
|
||||
sessionRepo repository.SessionRepository,
|
||||
credentialRepo repository.CredentialRepository,
|
||||
jwtSecret []byte,
|
||||
accessTTL time.Duration,
|
||||
refreshTTL time.Duration,
|
||||
) *AuthService {
|
||||
return &AuthService{
|
||||
userRepo: userRepo,
|
||||
sessionRepo: sessionRepo,
|
||||
credentialRepo: credentialRepo,
|
||||
jwtSecret: jwtSecret,
|
||||
accessTTL: accessTTL,
|
||||
refreshTTL: refreshTTL,
|
||||
}
|
||||
}
|
||||
|
||||
// Register creates a new user account.
|
||||
func (s *AuthService) Register(ctx context.Context, username, email, password string) (*model.User, error) {
|
||||
if username == "" || email == "" || password == "" {
|
||||
return nil, fmt.Errorf("username, email, and password are required")
|
||||
}
|
||||
|
||||
passwordHash, err := auth.HashPassword(password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
|
||||
user := &model.User{
|
||||
ID: uuid.NewString(),
|
||||
Username: username,
|
||||
Email: email,
|
||||
PasswordHash: passwordHash,
|
||||
}
|
||||
|
||||
if err := s.userRepo.Create(ctx, user); err != nil {
|
||||
if errors.Is(err, model.ErrDuplicate) {
|
||||
return nil, fmt.Errorf("username or email already exists")
|
||||
}
|
||||
return nil, fmt.Errorf("create user: %w", err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Login authenticates a user by email and password, returning a token pair.
|
||||
func (s *AuthService) Login(ctx context.Context, email, password string) (*TokenPair, error) {
|
||||
user, err := s.userRepo.FindByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, fmt.Errorf("invalid email or password")
|
||||
}
|
||||
return nil, fmt.Errorf("find user: %w", err)
|
||||
}
|
||||
|
||||
if err := auth.VerifyPassword(user.PasswordHash, password); err != nil {
|
||||
return nil, fmt.Errorf("invalid email or password")
|
||||
}
|
||||
|
||||
return s.issueTokens(ctx, user.ID)
|
||||
}
|
||||
|
||||
// Refresh validates a refresh token and returns a new token pair.
|
||||
// Each refresh token is single-use: the old session is deleted.
|
||||
func (s *AuthService) Refresh(ctx context.Context, refreshTokenStr string) (*TokenPair, error) {
|
||||
claims, err := auth.ParseToken(refreshTokenStr, s.jwtSecret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
tokenHash := auth.HashToken(refreshTokenStr)
|
||||
session, err := s.sessionRepo.FindByTokenHash(ctx, tokenHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
return nil, fmt.Errorf("find session: %w", err)
|
||||
}
|
||||
|
||||
if session.UserID != claims.UserID {
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
if err := s.sessionRepo.Delete(ctx, session.ID); err != nil {
|
||||
return nil, fmt.Errorf("delete old session: %w", err)
|
||||
}
|
||||
|
||||
return s.issueTokens(ctx, claims.UserID)
|
||||
}
|
||||
|
||||
// Logout invalidates a refresh token by deleting its session.
|
||||
func (s *AuthService) Logout(ctx context.Context, refreshTokenStr string) error {
|
||||
tokenHash := auth.HashToken(refreshTokenStr)
|
||||
session, err := s.sessionRepo.FindByTokenHash(ctx, tokenHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("find session: %w", err)
|
||||
}
|
||||
|
||||
return s.sessionRepo.Delete(ctx, session.ID)
|
||||
}
|
||||
|
||||
// CreatePasskey creates a new app passkey for the authenticated user.
|
||||
func (s *AuthService) CreatePasskey(ctx context.Context, userID, label string) (*CreatedPasskey, error) {
|
||||
raw, hash, err := auth.GenerateToken()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate token: %w", err)
|
||||
}
|
||||
|
||||
cred := &model.Credential{
|
||||
ID: uuid.NewString(),
|
||||
UserID: userID,
|
||||
Type: "app_passkey",
|
||||
Label: label,
|
||||
SecretHash: hash,
|
||||
}
|
||||
|
||||
if err := s.credentialRepo.Create(ctx, cred); err != nil {
|
||||
return nil, fmt.Errorf("create credential: %w", err)
|
||||
}
|
||||
|
||||
return &CreatedPasskey{
|
||||
ID: cred.ID,
|
||||
Raw: raw,
|
||||
Label: label,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LoginWithPasskey authenticates a user using an app passkey token.
|
||||
func (s *AuthService) LoginWithPasskey(ctx context.Context, tokenStr string) (*TokenPair, error) {
|
||||
if !strings.HasPrefix(tokenStr, "mygo_") {
|
||||
return nil, fmt.Errorf("invalid passkey format")
|
||||
}
|
||||
|
||||
tokenHash := auth.HashToken(tokenStr)
|
||||
cred, err := s.credentialRepo.FindByHash(ctx, tokenHash)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
return nil, fmt.Errorf("invalid passkey")
|
||||
}
|
||||
return nil, fmt.Errorf("find credential: %w", err)
|
||||
}
|
||||
|
||||
if cred.Type != "app_passkey" {
|
||||
return nil, fmt.Errorf("invalid credential type")
|
||||
}
|
||||
|
||||
if err := s.credentialRepo.UpdateLastUsed(ctx, cred.ID); err != nil {
|
||||
return nil, fmt.Errorf("update last used: %w", err)
|
||||
}
|
||||
|
||||
return s.issueTokens(ctx, cred.UserID)
|
||||
}
|
||||
|
||||
// ListPasskeys returns all app passkeys for a user.
|
||||
func (s *AuthService) ListPasskeys(ctx context.Context, userID string) ([]model.Credential, error) {
|
||||
return s.credentialRepo.FindByUserIDAndType(ctx, userID, "app_passkey")
|
||||
}
|
||||
|
||||
// RevokePasskey deletes an app passkey owned by the user.
|
||||
func (s *AuthService) RevokePasskey(ctx context.Context, userID, credID string) error {
|
||||
cred, err := s.credentialRepo.FindByID(ctx, credID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("find credential: %w", err)
|
||||
}
|
||||
|
||||
if cred.UserID != userID {
|
||||
return model.ErrForbidden
|
||||
}
|
||||
|
||||
return s.credentialRepo.Delete(ctx, credID)
|
||||
}
|
||||
|
||||
func (s *AuthService) issueTokens(ctx context.Context, userID string) (*TokenPair, error) {
|
||||
accessToken, err := auth.GenerateAccessToken(userID, s.jwtSecret, s.accessTTL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate access token: %w", err)
|
||||
}
|
||||
|
||||
refreshToken, err := auth.GenerateRefreshToken(userID, s.jwtSecret, s.refreshTTL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate refresh token: %w", err)
|
||||
}
|
||||
|
||||
session := &model.Session{
|
||||
ID: uuid.NewString(),
|
||||
UserID: userID,
|
||||
TokenHash: auth.HashToken(refreshToken),
|
||||
ExpiresAt: time.Now().Add(s.refreshTTL),
|
||||
}
|
||||
|
||||
if err := s.sessionRepo.Create(ctx, session); err != nil {
|
||||
return nil, fmt.Errorf("create session: %w", err)
|
||||
}
|
||||
|
||||
return &TokenPair{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
}, nil
|
||||
}
|
||||
382
internal/service/auth_test.go
Normal file
382
internal/service/auth_test.go
Normal file
@@ -0,0 +1,382 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/auth"
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
"github.com/dhao2001/mygo/internal/repository"
|
||||
)
|
||||
|
||||
func setupAuthService(t *testing.T) *AuthService {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.Session{}, &model.Credential{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
sessionRepo := repository.NewSessionRepository(db)
|
||||
credentialRepo := repository.NewCredentialRepository(db)
|
||||
|
||||
return NewAuthService(
|
||||
userRepo, sessionRepo, credentialRepo,
|
||||
[]byte("test-secret"),
|
||||
15*time.Minute,
|
||||
7*24*time.Hour,
|
||||
)
|
||||
}
|
||||
|
||||
func TestAuthService_Register(t *testing.T) {
|
||||
svc := setupAuthService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Register = %v", err)
|
||||
}
|
||||
if user.ID == "" {
|
||||
t.Fatal("user ID is empty")
|
||||
}
|
||||
if user.Username != "alice" {
|
||||
t.Errorf("Username = %q, want %q", user.Username, "alice")
|
||||
}
|
||||
if user.PasswordHash == "password123" {
|
||||
t.Fatal("password should be hashed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthService_RegisterDuplicate(t *testing.T) {
|
||||
svc := setupAuthService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Register = %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.Register(ctx, "alice", "alice2@example.com", "password123")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate username, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthService_RegisterEmptyFields(t *testing.T) {
|
||||
svc := setupAuthService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.Register(ctx, "", "alice@example.com", "password")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty username, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthService_Login(t *testing.T) {
|
||||
svc := setupAuthService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Register = %v", err)
|
||||
}
|
||||
|
||||
pair, err := svc.Login(ctx, "alice@example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Login = %v", err)
|
||||
}
|
||||
if pair.AccessToken == "" {
|
||||
t.Fatal("access token is empty")
|
||||
}
|
||||
if pair.RefreshToken == "" {
|
||||
t.Fatal("refresh token is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthService_LoginWrongPassword(t *testing.T) {
|
||||
svc := setupAuthService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Register = %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.Login(ctx, "alice@example.com", "wrongpassword")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for wrong password, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthService_LoginNonexistentEmail(t *testing.T) {
|
||||
svc := setupAuthService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.Login(ctx, "nonexistent@example.com", "password")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent email, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthService_Refresh(t *testing.T) {
|
||||
svc := setupAuthService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Register = %v", err)
|
||||
}
|
||||
|
||||
pair, err := svc.Login(ctx, "alice@example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Login = %v", err)
|
||||
}
|
||||
|
||||
newPair, err := svc.Refresh(ctx, pair.RefreshToken)
|
||||
if err != nil {
|
||||
t.Fatalf("Refresh = %v", err)
|
||||
}
|
||||
if newPair.AccessToken == "" {
|
||||
t.Fatal("new access token is empty")
|
||||
}
|
||||
if newPair.RefreshToken == "" {
|
||||
t.Fatal("new refresh token is empty")
|
||||
}
|
||||
if newPair.RefreshToken == pair.RefreshToken {
|
||||
t.Fatal("refresh token should be rotated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthService_RefreshSingleUse(t *testing.T) {
|
||||
svc := setupAuthService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Register = %v", err)
|
||||
}
|
||||
|
||||
pair, err := svc.Login(ctx, "alice@example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Login = %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.Refresh(ctx, pair.RefreshToken)
|
||||
if err != nil {
|
||||
t.Fatalf("first Refresh = %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.Refresh(ctx, pair.RefreshToken)
|
||||
if err == nil {
|
||||
t.Fatal("second refresh with same token should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthService_Logout(t *testing.T) {
|
||||
svc := setupAuthService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Register = %v", err)
|
||||
}
|
||||
|
||||
pair, err := svc.Login(ctx, "alice@example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Login = %v", err)
|
||||
}
|
||||
|
||||
if err := svc.Logout(ctx, pair.RefreshToken); err != nil {
|
||||
t.Fatalf("Logout = %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.Refresh(ctx, pair.RefreshToken)
|
||||
if err == nil {
|
||||
t.Fatal("refresh should fail after logout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthService_CreatePasskey(t *testing.T) {
|
||||
svc := setupAuthService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Register = %v", err)
|
||||
}
|
||||
|
||||
pair, err := svc.Login(ctx, "alice@example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Login = %v", err)
|
||||
}
|
||||
|
||||
// Extract userID from access token
|
||||
claims, err := auth.ParseToken(pair.AccessToken, []byte("test-secret"))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseToken = %v", err)
|
||||
}
|
||||
// Import auth for claims access
|
||||
// Already using auth above
|
||||
|
||||
pk, err := svc.CreatePasskey(ctx, claims.UserID, "My Phone")
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePasskey = %v", err)
|
||||
}
|
||||
if pk.ID == "" {
|
||||
t.Fatal("passkey ID is empty")
|
||||
}
|
||||
if pk.Raw == "" {
|
||||
t.Fatal("raw token is empty")
|
||||
}
|
||||
if pk.Label != "My Phone" {
|
||||
t.Errorf("Label = %q, want %q", pk.Label, "My Phone")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthService_LoginWithPasskey(t *testing.T) {
|
||||
svc := setupAuthService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Register = %v", err)
|
||||
}
|
||||
|
||||
pair, err := svc.Login(ctx, "alice@example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Login = %v", err)
|
||||
}
|
||||
|
||||
claims, err := auth.ParseToken(pair.AccessToken, []byte("test-secret"))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseToken = %v", err)
|
||||
}
|
||||
|
||||
pk, err := svc.CreatePasskey(ctx, claims.UserID, "My Phone")
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePasskey = %v", err)
|
||||
}
|
||||
|
||||
loginPair, err := svc.LoginWithPasskey(ctx, pk.Raw)
|
||||
if err != nil {
|
||||
t.Fatalf("LoginWithPasskey = %v", err)
|
||||
}
|
||||
if loginPair.AccessToken == "" {
|
||||
t.Fatal("access token is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthService_LoginWithPasskeyInvalidFormat(t *testing.T) {
|
||||
svc := setupAuthService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.LoginWithPasskey(ctx, "not-a-mygo-token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid passkey format, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthService_RevokePasskey(t *testing.T) {
|
||||
svc := setupAuthService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Register = %v", err)
|
||||
}
|
||||
|
||||
pair, _ := svc.Login(ctx, "alice@example.com", "password123")
|
||||
claims, _ := auth.ParseToken(pair.AccessToken, []byte("test-secret"))
|
||||
|
||||
pk, err := svc.CreatePasskey(ctx, claims.UserID, "My Phone")
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePasskey = %v", err)
|
||||
}
|
||||
|
||||
if err := svc.RevokePasskey(ctx, claims.UserID, pk.ID); err != nil {
|
||||
t.Fatalf("RevokePasskey = %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.LoginWithPasskey(ctx, pk.Raw)
|
||||
if err == nil {
|
||||
t.Fatal("login with revoked passkey should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthService_RevokePasskeyNotOwner(t *testing.T) {
|
||||
svc := setupAuthService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Register = %v", err)
|
||||
}
|
||||
_, err = svc.Register(ctx, "bob", "bob@example.com", "password456")
|
||||
if err != nil {
|
||||
t.Fatalf("Register = %v", err)
|
||||
}
|
||||
|
||||
pair, _ := svc.Login(ctx, "alice@example.com", "password123")
|
||||
claims, _ := auth.ParseToken(pair.AccessToken, []byte("test-secret"))
|
||||
|
||||
pk, err := svc.CreatePasskey(ctx, claims.UserID, "My Phone")
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePasskey = %v", err)
|
||||
}
|
||||
|
||||
pairBob, _ := svc.Login(ctx, "bob@example.com", "password456")
|
||||
claimsBob, _ := auth.ParseToken(pairBob.AccessToken, []byte("test-secret"))
|
||||
|
||||
err = svc.RevokePasskey(ctx, claimsBob.UserID, pk.ID)
|
||||
if err != model.ErrForbidden {
|
||||
t.Fatalf("expected ErrForbidden, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthService_ListPasskeys(t *testing.T) {
|
||||
svc := setupAuthService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.Register(ctx, "alice", "alice@example.com", "password123")
|
||||
if err != nil {
|
||||
t.Fatalf("Register = %v", err)
|
||||
}
|
||||
|
||||
pair, _ := svc.Login(ctx, "alice@example.com", "password123")
|
||||
claims, _ := auth.ParseToken(pair.AccessToken, []byte("test-secret"))
|
||||
|
||||
_, err = svc.CreatePasskey(ctx, claims.UserID, "Phone")
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePasskey 1 = %v", err)
|
||||
}
|
||||
_, err = svc.CreatePasskey(ctx, claims.UserID, "Laptop")
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePasskey 2 = %v", err)
|
||||
}
|
||||
|
||||
passkeys, err := svc.ListPasskeys(ctx, claims.UserID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListPasskeys = %v", err)
|
||||
}
|
||||
if len(passkeys) != 2 {
|
||||
t.Errorf("len(passkeys) = %d, want 2", len(passkeys))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthService_RefreshWithInvalidToken(t *testing.T) {
|
||||
svc := setupAuthService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := svc.Refresh(ctx, "not-a-valid-token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid refresh token, got nil")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user