diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
deleted file mode 100644
index 3d2a330..0000000
--- a/.github/workflows/go.yml
+++ /dev/null
@@ -1,28 +0,0 @@
-# This workflow will build a golang project
-# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
-
-name: Go
-
-on:
- push:
- branches: [ "main" ]
- pull_request:
- branches: [ "main" ]
-
-jobs:
-
- build:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
-
- - name: Set up Go
- uses: actions/setup-go@v4
- with:
- go-version: '1.21'
-
- - name: Build
- run: go build -v ./...
-
- - name: Test
- run: go test -v ./internal/tests/...
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
deleted file mode 100644
index 635b9d7..0000000
--- a/.github/workflows/release.yml
+++ /dev/null
@@ -1,33 +0,0 @@
-name: Release
-
-on:
- push:
- tags:
- - 'v*'
-
-permissions:
- contents: write
-
-jobs:
- release:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: Set up Go
- uses: actions/setup-go@v5
- with:
- go-version: '1.21'
-
- - name: Run tests
- run: go test -v ./internal/tests/...
-
- - name: Create Release
- uses: softprops/action-gh-release@v2
- with:
- generate_release_notes: true
- name: Release ${{ github.ref_name }}
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/CHANGE-LOG.md b/CHANGE-LOG.md
deleted file mode 100644
index f8b64cf..0000000
--- a/CHANGE-LOG.md
+++ /dev/null
@@ -1,55 +0,0 @@
-# Changelog
-
-All notable changes to this project will be documented in this file.
-
-The format is based on [Keep a Changelog](https://keepachangelog.com/),
-and this project adheres to [Semantic Versioning](https://semver.org/).
-
-## [1.0.0] - 2026-03-08
-
-### π Initial Release
-
-#### Added
-- Core authentication engine
-- Email/password signup and login
-- JWT access token generation and verification
-- Opaque refresh tokens with database storage
-- Rate limiting with memory implementation
-- Audit logging with console output
-- Session management (create, revoke, list)
-- Multiple storage backends:
- - In-memory (testing)
- - SQLite (offline-first)
- - PostgreSQL (production)
- - MongoDB (document stores)
-- Complete test suite with 90%+ coverage
-- Examples for all features
-
-#### Features
-- `SignUp` - Create new user accounts
-- `Login` - Authenticate and get tokens
-- `Logout` - Revoke current session
-- `LogoutAll` - Revoke all user sessions
-- `ChangePassword` - Update with old password verification
-- `ChangeEmail` - Update user email
-- `DeleteAccount` - Remove user and all sessions
-- `RefreshToken` - Get new tokens with rotation
-- `VerifyToken` - Validate JWT and get user ID
-
-#### Developer Experience
-- Clean public API at `cryden/`
-- Interface-driven design for easy mocking
-- In-memory stores for fast tests
-- Comprehensive documentation
-- Working examples
-
-#### Documentation
-- Getting started guide
-- Philosophy and design decisions
-- Architecture overview
-- API reference
-- Testing guide
-- Contribution guidelines
-
-[1.0.0]: https://github.com/crydensync/cryden/releases/tag/v1.0.0
-
diff --git a/CODE-OF-CONDUCT.md b/CODE-OF-CONDUCT.md
deleted file mode 100644
index 3ace435..0000000
--- a/CODE-OF-CONDUCT.md
+++ /dev/null
@@ -1,55 +0,0 @@
-# Contributor Covenant Code of Conduct
-
-## Our Pledge
-
-We as members, contributors, and leaders pledge to make participation in our
-community a harassment-free experience for everyone, regardless of age, body
-size, visible or invisible disability, ethnicity, sex characteristics, gender
-identity and expression, level of experience, education, socio-economic status,
-nationality, personal appearance, race, religion, or sexual identity
-and orientation.
-
-We pledge to act and interact in ways that contribute to an open, welcoming,
-diverse, inclusive, and healthy community.
-
-## Our Standards
-
-Examples of behavior that contributes to a positive environment:
-
-* Demonstrating empathy and kindness toward other people
-* Being respectful of differing opinions, viewpoints, and experiences
-* Giving and gracefully accepting constructive feedback
-* Accepting responsibility and apologizing to those affected by our mistakes
-* Focusing on what is best for the overall community
-
-Examples of unacceptable behavior:
-
-* The use of sexualized language or imagery, and sexual attention or advances
-* Trolling, insulting or derogatory comments, and personal or political attacks
-* Public or private harassment
-* Publishing others' private information without explicit permission
-* Other conduct which could reasonably be considered inappropriate
-
-## Enforcement Responsibilities
-
-Project maintainers are responsible for clarifying and enforcing our standards of
-acceptable behavior and will take appropriate and fair corrective action in
-response to any behavior they deem inappropriate, threatening, offensive, or harmful.
-
-## Scope
-
-This Code of Conduct applies within all community spaces, and also applies when
-an individual is officially representing the community in public spaces.
-
-## Enforcement
-
-Instances of abusive, harassing, or otherwise unacceptable behavior may be
-reported to the project team at conduct@cryden.dev. All complaints will be
-reviewed and investigated promptly and fairly.
-
-## Attribution
-
-This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org),
-version 2.0, available at
-https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
-
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
deleted file mode 100644
index 59b0327..0000000
--- a/CONTRIBUTING.md
+++ /dev/null
@@ -1,232 +0,0 @@
-# Contributing to CrydenSync
-
-First off, thank you for considering contributing to Cryden! π
-
-## Code of Conduct
-
-This project and everyone participating in it is governed by our [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code.
-
-## π Our Philosophy
-
-Cryden is built for developers worldwide, with special consideration for:
-- **Offline-first development** β Works without internet
-- **Low-bandwidth environments** β Small binary size
-- **Developer ownership** β Your users belong to you
-- **Simplicity** β Easy to understand and extend
-
-## π Getting Started
-
-### Prerequisites
-- Go 1.21 or higher
-- Git
-- Make (optional)
-
-### Development Setup
-
-1. Fork the repository
-2. Clone your fork:
- ```bash
- git clone https://github.com/your-username/cryden.git
- cd cryden
-```
-
-1. Install dependencies:
- ```bash
- go mod download
- ```
-2. Run tests:
- ```bash
- go test ./internal/tests/... -v
- ```
-3. Run the example:
- ```bash
- cd examples/complete
- go run main.go
- ```
-
-π§ͺ Testing Guidelines
-
-Write Tests First
-
-We practice test-driven development where possible:
-
-```go
-func TestNewFeature(t *testing.T) {
- // 1. Setup
- engine := cryden.New()
-
- // 2. Test the feature
- result, err := engine.NewFeature()
-
- // 3. Assert
- if err != nil {
- t.Errorf("Expected no error, got %v", err)
- }
- if result == nil {
- t.Error("Expected result, got nil")
- }
-}
-```
-
-Test Coverage
-
-Β· Aim for 80%+ coverage
-Β· Use go test -cover to check
-Β· New features must include tests
-
-Mocking
-
-Use interfaces for mocking:
-
-```go
-type MockUserStore struct {
- users map[string]*User
-}
-
-func (m *MockUserStore) GetByEmail(email string) (*User, error) {
- if user, ok := m.users[email]; ok {
- return user, nil
- }
- return nil, ErrUserNotFound
-}
-```
-
-π
Coding Standards
-
-Go Style
-
-Β· Follow Go Code Review Comments
-Β· Run go fmt before committing
-Β· Use golangci-lint for additional checks
-
-Naming Conventions
-
-Β· Interfaces: UserStore, Hasher, AuditLogger
-Β· Methods: SignUp, Login, LogoutAll
-Β· Errors: ErrUserNotFound, ErrInvalidToken
-Β· Tests: TestLogin, TestChangePassword/success
-
-Documentation
-
-Β· All exported functions must have comments
-Β· Examples should be runnable
-Β· Update relevant docs when adding features
-
-π§ Pull Request Process
-
-1. Create an issue first for discussion
-2. Fork and branch (feature/your-feature or fix/your-fix)
-3. Write code with tests
-4. Run tests locally:
- ```bash
- make test
- ```
-5. Update documentation if needed
-6. Push and create PR
-7. Address review comments
-
-PR Checklist
-
-Β· Tests pass
-Β· Code is formatted
-Β· Documentation updated
-Β· Commit messages clear
-Β· No unrelated changes
-
-π Commit Messages
-
-Follow Conventional Commits:
-
-```
-feat: add logout all devices
-^ββ^ ^βββββββββββββββββ^
-| |
-| ββ Description in imperative mood
-|
-ββ Type: feat, fix, docs, style, refactor, test, chore
-```
-
-Examples:
-
-Β· feat: add rate limiting headers
-Β· fix: handle nil session in logout
-Β· docs: update getting started guide
-Β· test: add mfa test cases
-Β· refactor: extract token generation
-
-π Release Process
-
-Maintainers follow:
-
-1. Update version in go.mod
-2. Update CHANGELOG.md
-3. Create git tag: git tag v1.0.0
-4. Push tag: git push origin v1.0.0
-5. GitHub Actions creates release
-
-π Documentation Contributions
-
-Markdown Style
-
-Β· One sentence per line (easier diffs)
-Β· Use code blocks with language
-Β· Include working examples
-Β· Link to related docs
-
-Adding Examples
-
-Each example should:
-
-Β· Be in examples/ directory
-Β· Have a main.go that runs
-Β· Include comments
-Β· Be referenced in docs
-
-π Reporting Bugs
-
-Include:
-
-Β· Cryden version
-Β· Go version
-Β· Database being used
-Β· Minimal reproduction code
-Β· Expected vs actual behavior
-
-π‘ Feature Requests
-
-Tell us:
-
-Β· What problem you're solving
-Β· How it fits Cryden's philosophy
-Β· Example usage
-Β· Why existing solutions don't work
-
-π Recognition
-
-Contributors will be:
-
-Β· Listed in CONTRIBUTORS.md
-Β· Mentioned in release notes
-Β· Thanked in community spaces
-
-π Getting Help
-
-Β· Issues: GitHub issues
-Β· Discussions: GitHub Discussions
-Β· Twitter: @crydensync
-Β· Email: contributors@cryden.dev
-
-π Thank You!
-
-Your contributions make CrydenSync better for everyone, especially developers in:
-
-Β· π Africa (offline-first, low bandwidth)
-Β· π Startups (no vendor lock-in)
-Β· π§βπ» Indie developers (free, open source)
-
----
-
-
- Made with β€οΈ by the CrydenSync community
-
-```
diff --git a/README.md b/README.md
deleted file mode 100644
index e7e34f9..0000000
--- a/README.md
+++ /dev/null
@@ -1,558 +0,0 @@
-# CrydenSync π
-
-
-
-**Embeddable authentication engine for Go β offline-first, framework-agnostic.**
-
-[](https://pkg.go.dev/github.com/crydensync/cryden)
-[](https://goreportcard.com/report/github.com/crydensync/cryden)
-[](https://opensource.org/licenses/MIT)
-
-
-[](https://github.com/crydensync/cryden/stargazers)
-[](https://github.com/crydensync/cryden/network/members)
-[](https://github.com/crydensync/cryden/watchers)
-[](https://github.com/crydensync/cryden/releases)
-
-
-
-## π― The Problem
-
-Authentication is not business logic, yet every project rewrites it. Developers face three painful choices:
-
-1. **Rewrite auth logic** for every project β risky, inconsistent, time-consuming
-2. **Use hosted auth services** β vendor lock-in, users aren't yours, requires internet
-3. **Use framework-specific tools** β tied to Express, Django, Next.js β not reusable
-
-## π‘ The Solution
-
-CrydenSync is an **embeddable authentication engine** that gives you a standard, reusable auth system you control:
-
-```go
-package main
-
-import (
- "context"
- "fmt"
- "log"
-
- "github.com/crydensync/cryden"
-)
-
-func main() {
- // Create context
- ctx := context.Background()
-
- // 1. Create engine (in-memory storage - perfect for testing)
- engine := cryden.New()
- fmt.Println("β
Engine created")
-
- // 2. Sign up a new user
- email := "alice@example.com"
- password := "SecurePass123"
-
- user, err := cryden.SignUp(ctx, engine, email, password)
- if err != nil {
- log.Fatalf("β SignUp failed: %v", err)
- }
- fmt.Printf("β
User created: %s (%s)\n", user.ID, user.Email)
-
- // 3. Login
- tokens, rateLimit, err := cryden.Login(ctx, engine, email, password)
- if err != nil {
- log.Fatalf("β Login failed: %v", err)
- }
- fmt.Printf("β
Login successful!\n")
- fmt.Printf(" Access Token: %s...\n", tokens.AccessToken[:50])
- fmt.Printf(" Refresh Token: %s...\n", tokens.RefreshToken[:50])
- fmt.Printf(" Rate Limit Remaining: %d\n", rateLimit.Remaining)
-
- // 4. Verify token
- userID, err := cryden.VerifyToken(engine, tokens.AccessToken)
- if err != nil {
- log.Fatalf("β Token verification failed: %v", err)
- }
- fmt.Printf("β
Token verified for user: %s\n", userID)
-
- // 5. Logout
- err = cryden.Logout(ctx, engine, tokens.RefreshToken)
- if err != nil {
- log.Fatalf("β Logout failed: %v", err)
- }
- fmt.Println("β
Logout successful")
-
- // 6. Try to use logged out token (should fail)
- _, err = cryden.RefreshToken(ctx, engine, tokens.RefreshToken)
- if err != nil {
- fmt.Printf("β
Expected error after logout: %v\n", err)
- }
-
- fmt.Println("\nπ All tests passed!")
-}
-
-```
-[View full example β](examples/complete/main.go)
-
-β¨ Features
-
-β
v1.0.0 (Current)
-
-Β· Email/password authentication β Secure, bcrypt hashed
-Β· JWT access tokens β Short-lived, stateless
-Β· Opaque refresh tokens β Stored in DB for revocation
-Β· Rate limiting β Per IP with headers (X-RateLimit-*)
-Β· Audit logging β Track every auth event
-Β· Session management β Logout single device or all devices
-Β· Multiple storage backends β Memory, SQLite, PostgreSQL, MongoDB
-Β· Complete test suite β 90%+ coverage
-Β· Offline-first β Works without internet, SQLite by default
-
-π§ Coming Soon
-
-Feature Status Target
-gRPC API π§ Planned v1.1.0
-CLI tool (csax) π§ Planned v1.1.0
-Language SDKs (JS, Python, PHP) π§ Planned v1.2.0
-MFA/2FA (TOTP) π
Future v1.3.0
-Magic Links π
Future v1.3.0
-WebAuthn/Passkeys π
Future v2.0.0
-
-π¦ Installation
-
-```bash
-go get github.com/crydensync/cryden@v1.0.0
-```
-
-```markdown
-## π§ͺ Local Development
-
-Want to hack on CrydenSync itself? Use it locally in your own app:
-
-```bash
-git clone https://github.com/crydensync/cryden.git
-cd your-app
-go mod edit -replace github.com/crydensync/cryden=../cryden
-go run main.go # Uses your local version!
-```
-
-π Full Local Dev Guide β (CrydenSync web docs soon)
-
-
-π Documentation
-
-Section Description
-π Getting Started 60-second working auth
-π― Philosophy Why Cryden exists
-ποΈ Architecture How it works
-π Design Decisions Why we built it this way
-π§ Guide Installation, config, middleware, testing
-π Adapters Interface implementations
-π API Reference Complete API docs
-π‘ Examples Copy-paste working code
-
-π§ͺ Testing
-
-CrydenSync is designed for maximum testability:
-
-```go
-func TestLogin(t *testing.T) {
- engine := cryden.New() // In-memory storage
-
- // Optional: Use mock hasher for faster tests
- engine.WithHasher(&core.MockHasher{})
-
- // Optional: Disable rate limiting
- engine.WithRateLimiter(&core.NoopRateLimiter{})
-
- ctx := context.Background()
- cryden.SignUp(ctx, engine, "test@example.com", "pass")
- tokens, _, err := cryden.Login(ctx, engine, "test@example.com", "pass")
-
- assert.NoError(t, err)
- assert.NotEmpty(t, tokens.AccessToken)
-}
-```
-
-π Testing Guide β
-
-π§ Configuration
-
-```go
-// With SQLite persistence
-engine, err := cryden.WithSQLite("users.db")
-
-// With custom JWT secret (required in production)
-cryden.WithJWTSecret(engine, os.Getenv("JWT_SECRET"))
-
-// With custom rate limiter
-engine.WithRateLimiter(redis.NewRateLimiter())
-
-// With custom audit logger
-engine.WithAuditLogger(file.NewAuditLogger("auth.log"))
-```
-
-π Storage Backends
-
-Backend Status Use Case
-Memory β
Stable Testing
-SQLite β
Stable Offline-first, development
-PostgreSQL β
Stable Production
-MongoDB β
Stable Document stores
-MySQL π§ Planned v1.1.0
-Redis π§ Planned v1.1.0 (rate limiting)
-
-## π About the Name
-
-**CrydenSync** is the full name of the project, but the Go package is simply `cryden` for brevity.
-
-```go
-import "github.com/crydensync/cryden" // Notice: crydensync/cryden
-
-auth := cryden.New() // Short and sweet!
-``````
-
-β
Perfect! Let's add a "How It Works" section to your README.md
-
-Add this after Features:
-
-```markdown
-## π§ How CrydenSync Works (Under the Hood)
-
-### The Authentication Flow
-
-When a user logs in, here's what happens:
-
-```mermaid
-sequenceDiagram
- participant App as Your App
- participant Engine as Cryden Engine
- participant Hasher as Password Hasher
- participant Store as Database Store
- participant Logger as Audit Logger
- participant Limiter as Rate Limiter
-
- App->>Engine: Login(email, password)
- Engine->>Limiter: Check rate limit
- Limiter-->>Engine: β
Allowed (remaining: 4)
-
- Engine->>Store: GetUserByEmail(email)
- Store-->>Engine: User (with hashed password)
-
- Engine->>Hasher: Compare(password, hash)
- Hasher-->>Engine: β
Match
-
- Engine->>Store: CreateSession(userID)
- Store-->>Engine: Session (with refresh token)
-
- Engine->>Engine: Generate JWT access token
-
- Engine->>Logger: Log successful login
-
- Engine-->>App: TokenPair + RateLimit info
-```
-
-### The Dual-Token System
-
-```
-βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-β CLIENT SIDE β
-βββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
-β Access Token (JWT) β Refresh Token (Opaque) β
-β β’ Short-lived (15m) β β’ Long-lived (7d) β
-β β’ Stateless β β’ Stored in database β
-β β’ Contains user ID β β’ Can be revoked β
-β β’ No DB lookup β β’ Supports "logout all" β
-βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-```
-
-### Why This Design?
-
-#### JWT for Speed
-```go
-// API can verify without database lookup
-claims, _ := cryden.VerifyToken(token)
-userID := claims.UserID // Fast!
-```
-
-#### Opaque Tokens for Control
-```go
-// Logout all devices = delete all refresh tokens
-cryden.LogoutAll(ctx, engine, userID) // Instant revocation
-```
-
-### The Interface Architecture
-
-```
-βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-β YOUR APPLICATION β
-βββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
-β cryden.New() β
-βββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
-β βββββββββββββββββββββββββββββββββββββββββββββββββββ
-β β CRYDEN ENGINE β
-β β β’ SignUp, Login, Logout β
-β β β’ Token generation & validation β
-β β β’ Session management β
-β βββββββββββββββββββββββββββββββββββββββββββββββββββ
-βββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
-β INTERFACES β
-ββββββββββββββββ¬βββββββββββββββ¬βββββββββββββββββββββββ€
-β UserStore β SessionStore β Hasher β
-β β’ Create β β’ Create β β’ Compare β
-β β’ GetByEmailβ β’ GetByToken β β’ Hash β
-β β’ Update β β’ Revoke β β
-β β’ Delete β β’ RevokeAll β β
-ββββββββββββββββΌβββββββββββββββΌβββββββββββββββββββββββ€
-β RateLimiter β AuditLogger β (More adapters...) β
-β β’ Allow β β’ Log β β
-β β’ Reset β β β
-ββββββββββββββββ΄βββββββββββββββ΄βββββββββββββββββββββββ
-```
-
-### Storage Adapters in Action
-
-```go
-// Same code works with ANY database!
-type UserStore interface {
- GetByEmail(email string) (*User, error)
- Create(user *User) error
- // ...
-}
-
-// Memory adapter (testing)
-type MemoryUserStore struct {
- users map[string]*User
-}
-
-// SQLite adapter (offline)
-type SQLiteUserStore struct {
- db *sql.DB
-}
-
-// PostgreSQL adapter (production)
-type PostgresUserStore struct {
- db *sql.DB
-}
-
-// MongoDB adapter (NoSQL)
-type MongoUserStore struct {
- coll *mongo.Collection
-}
-```
-
-### The Audit Trail
-
-Every action is logged for security:
-
-```json
-{
- "timestamp": "2026-03-10T10:30:00Z",
- "user_id": "usr_123",
- "action": "SIGN_IN_SUCCESS",
- "ip_address": "192.168.1.100",
- "user_agent": "Mozilla/5.0...", // comming soon
- "status": "SUCCESS"
-}
-```
-
-### Rate Limiting with Headers
-
-```http
-HTTP/1.1 200 OK
-X-RateLimit-Limit: 5
-X-RateLimit-Remaining: 3
-X-RateLimit-Reset: 45
-```
-
-Frontend can show: "3 attempts remaining. Try again in 45 seconds."
-
-### Session Management
-# **Planed for v1.1.0
-
-```
-βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-β USER SESSIONS β
-βββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
-β Device: iPhone 15 β
-β Location: Lagos, Nigeria β
-β Last active: 2 minutes ago β
-β Status: β Active β
-βββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
-β Device: MacBook Pro β
-β Location: Lagos, Nigeria β
-β Last active: 2 hours ago β
-β Status: β Active β
-βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- [Logout All Devices]
-```
-
-### Security Layers
-
-```
-βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-β SECURITY LAYERS β
-βββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
-β Layer 1: Rate Limiting β
-β β Prevents brute force attacks β
-β β 5 attempts per minute per IP β
-βββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
-β Layer 2: Password Hashing β
-β β bcrypt with salt β
-β β Argon2id coming in v1.1 β
-βββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
-β Layer 3: JWT Signing β
-β β HMAC-SHA256 with secret β
-β β Short expiration (15m) β
-βββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
-β Layer 4: Refresh Token Rotation β
-β β New token on every refresh β
-β β Old tokens revoked immediately β
-βββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
-β Layer 5: Audit Logging β
-β β Every action tracked β
-β β Suspicious activity detection (future) β
-βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-```
-
-### The Complete Request Lifecycle
-
-```
-1. Request arrives
- β
-2. Rate Limiter checks IP
- β
-3. User credentials validated
- β
-4. Password compared (constant time)
- β
-5. Session created in database
- β
-6. JWT access token generated
- β
-7. Audit log entry created
- β
-8. Response with tokens + rate limit headers
- β
-9. Frontend stores tokens securely
-```
-
-### Why This Matters for Your Users
-
-```go
-// Your users get:
-// β
Security (bcrypt, rate limiting)
-// β
Control (logout all devices)
-// β
Visibility (audit logs, session list)
-// β
Flexibility (any database)
-// β
Freedom (no vendor lock-in)
-```
-
-## π― The Bottom Line
-
-CrydenSync isn't just an auth library β it's a **complete authentication infrastructure** that you control completely.
-
-- **You own the data**
-- **You choose the database**
-- **You control the security**
-- **You keep your users**
-
-No vendor lock-in. No hidden costs. Just auth that works everywhere.
-
-## π Security Notes v1.0.0
-
-### β
Implemented
-- Password hashing with bcrypt
-- JWT signing with HMAC-SHA256
-- Rate limiting to prevent brute force
-- Audit logging for all auth events
-
-### β οΈ Planned for v1.1.0
-- Refresh token hashing in database
-- Session token hashing
-- Device fingerprinting
-- Argon2id hasher option
-
-### Future Security Enhancements
-- Email verification (v1.1)
-- Password reset flow (v1.1)
-- MFA/2FA (v1.2)
-- Login notifications (v1.2)
-- Breached password detection (v1.2)
-
-### π Best Practices
-1. Always use HTTPS in production
-2. Set strong JWT secrets via environment variables
-3. Monitor audit logs for suspicious activity
-4. Add email verification before sensitive actions
-
-π€ Contributing
-
-We welcome contributions! See CONTRIBUTING.md for:
-
-Β· Code of Conduct
-Β· Development setup
-Β· Pull request process
-Β· Coding standards
-
-π License
-
-MIT Β© Crydensync
-
-β Support
-
-If you find Cryden useful, please star the repo!
-
-## π Project Stats
-
-| Metric | Value |
-|--------|-------|
-| β Stars | [](https://github.com/crydensync/cryden/stargazers) |
-| π΄ Forks | [](https://github.com/crydensync/cryden/network/members) |
-| π Watchers | [](https://github.com/crydensync/cryden/watchers) |
-| π₯ Downloads | [](https://github.com/crydensync/cryden/releases) |
-| π·οΈ Version | [](https://github.com/crydensync/cryden/releases) |
-| β
Build | [](https://github.com/crydensync/cryden/actions/workflows/test.yml) |
-| π Docs | [](https://pkg.go.dev/github.com/crydensync/cryden) |
-| π¦ Go Version | [](https://golang.org) |
-| π License | [](https://opensource.org/licenses/MIT) |
-
----
-
-## πΊοΈ Roadmap
-
-### Current: v1.0.0 (March 2026)
-β
Core authentication with email/password. β
JWT + refresh tokens. β
Rate limiting & audit logs. β
Multiple databases (SQLite, PostgreSQL, MongoDB)
-
-### Coming in v1.1.0 (Q2 2026)
-π CLI tool (`csax`)
-π± Device tracking (IP, user agent, last seen)
-π Argon2id hasher
-β‘ Redis rate limiter
-le audit logger
-π¬ MySQL support
-
-### Coming in v1.2.0 (Q3 2026)
-π gRPC API
-π Language SDKs (JS, Python, PHP)
-π Webhooks
-π Migration tools (Clerk, Auth0, Supabase)
-
-### Coming in v1.3.0 (Q4 2026)
-π Multi-Factor Authentication (TOTP)
-π§ Magic links & passwordless
-π WebAuthn / Passkeys
-π Social login (OAuth2)
-
-### Future (2027+)
-βοΈ Optional cloud sync
-π Enterprise features
-π More adapters
-π v2.0.0 (breaking changes if needed)
-
-[View full roadmap β](docs/roadmap.md)
-
----
-
-
- Built with β€οΈ in Africa Β· Own your users, not vendor lock-in
-
diff --git a/cryden.go b/cryden.go
deleted file mode 100644
index 1cb9bc9..0000000
--- a/cryden.go
+++ /dev/null
@@ -1,224 +0,0 @@
-// Package cryden is the main entry point for the CrydennSync authentication engine.
-package cryden
-
-import (
- "context"
- "database/sql"
- "fmt"
-
- "github.com/crydensync/cryden/internal/core"
- "github.com/crydensync/cryden/internal/stores/memory"
- "github.com/crydensync/cryden/internal/stores/sqlite"
- "github.com/crydensync/cryden/internal/stores/mongodb"
- "github.com/crydensync/cryden/internal/stores/postgres"
-)
-
-// Engine is the main authentication engine.
-type Engine = core.Engine
-
-// New creates an in-memory engine (perfect for testing)
-func New() *Engine {
- userStore := memory.NewUserStore()
- sessionStore := memory.NewSessionStore()
- return core.New(userStore, sessionStore)
-}
-
-// WithSQLite creates an engine with persistent SQLite storage
-func WithSQLite(dbPath string) (*Engine, error) {
- // Create user store (which creates/migrates DB)
- userStore, err := sqlite.NewUserStore(dbPath)
- if err != nil {
- return nil, err
- }
-
- // Get the DB connection from user store
- // You'll need to add this method to UserStore
- type dbGetter interface {
- GetDB() *sql.DB
- }
- getter, ok := userStore.(dbGetter)
- if !ok {
- return nil, fmt.Errorf("user store does not expose DB connection")
- }
- db := getter.GetDB()
-
- // Create session store with same DB
- sessionStore := sqlite.NewSessionStore(db)
-
- return core.New(userStore, sessionStore), nil
-}
-
-// WithMongoDB creates an engine with MongoDB storage
-func WithMongoDB(uri, dbName string) (*Engine, error) {
- userStore, err := mongodb.NewUserStore(uri, dbName)
- if err != nil {
- return nil, err
- }
-
- sessionStore, err := mongodb.NewSessionStore(uri, dbName)
- if err != nil {
- return nil, err
- }
-
- return core.New(userStore, sessionStore), nil
-}
-
-// WithPostgreSQL creates an engine with PostgreSQL storage
-func WithPostgreSQL(connStr string) (*Engine, error) {
- userStore, err := postgres.NewUserStore(connStr)
- if err != nil {
- return nil, err
- }
-
- // Get the DB connection from user store to reuse
- db := userStore.GetDB() // You'll need to add this method
-
- sessionStore := postgres.NewSessionStore(db)
-
- return core.New(userStore, sessionStore), nil
-}
-
-// WithFileAuditLogger sets a file-based audit logger
-func WithFileAuditLogger(engine *Engine, filePath string) (*Engine, error) {
- logger, err := core.NewFileAuditLogger(filePath)
- if err != nil {
- return nil, err
- }
- return engine.WithAuditLogger(logger), nil
-}
-
-// ==================== AUTHENTICATION FLOWS ====================
-
-// SignUp creates a new user account
-func SignUp(ctx context.Context, engine *Engine, email, password string) (*User, error) {
- return engine.SignUp(ctx, email, password)
-}
-
-// Login authenticates a user and returns tokens
-func Login(ctx context.Context, engine *Engine, email, password string, userAgent, ipAddress string) (*TokenPair, *LimitResult, error) {
- deviceInfo := core.ParseUserAgent(userAgent)
- return engine.Login(ctx, email, password, deviceInfo, ipAddress)
-}
-
-// Logout revokes the current session
-func Logout(ctx context.Context, engine *Engine, refreshToken string) error {
- return engine.Logout(ctx, refreshToken)
-}
-
-// LogoutAll revokes ALL sessions for a user
-func LogoutAll(ctx context.Context, engine *Engine, userID string) error {
- return engine.LogoutAll(ctx, userID)
-}
-
-// ChangePassword updates user's password and logs out all devices
-func ChangePassword(ctx context.Context, engine *Engine, userID, oldPassword, newPassword string) error {
- return engine.ChangePassword(ctx, userID, oldPassword, newPassword)
-}
-
-// ChangeEmail updates user's email
-func ChangeEmail(ctx context.Context, engine *Engine, userID, newEmail string) error {
- return engine.ChangeEmail(ctx, userID, newEmail)
-}
-
-// DeleteAccount removes user and all sessions
-func DeleteAccount(ctx context.Context, engine *Engine, userID string) error {
- return engine.DeleteAccount(ctx, userID)
-}
-
-// RefreshToken issues new tokens and rotates the refresh token
-func RefreshToken(ctx context.Context, engine *Engine, refreshToken string) (*TokenPair, error) {
- return engine.RefreshToken(ctx, refreshToken)
-}
-
-// VerifyToken validates a JWT access token and returns the user ID
-func VerifyToken(engine *Engine, tokenString string) (string, error) {
- claims, err := engine.VerifyToken(tokenString)
- if err != nil {
- return "", err
- }
- return claims.UserID, nil
-}
-
-// LoginWithDevice is a convenience method that extracts device from context
-func LoginWithDevice(ctx context.Context, engine *Engine, email, password string, userAgent, ipAddress string) (*TokenPair, *LimitResult, error) {
- return Login(ctx, engine, email, password, userAgent, ipAddress)
-}
-
-// ==================== USER MANAGEMENT ====================
-
-// GetUser retrieves a user by ID
-func GetUser(ctx context.Context, engine *Engine, userID string) (*User, error) {
- return engine.GetUser(ctx, userID)
-}
-
-// GetUserByEmail retrieves a user by email
-func GetUserByEmail(ctx context.Context, engine *Engine, email string) (*User, error) {
- return engine.GetUserByEmail(ctx, email)
-}
-
-// ==================== SESSION MANAGEMENT ====================
-
-// ListSessions returns all active sessions for a user
-func ListSessions(ctx context.Context, engine *Engine, userID string) ([]Session, error) {
- return engine.ListSessions(ctx, userID)
-}
-
-// RevokeSession manually revokes a specific session
-func RevokeSession(ctx context.Context, engine *Engine, sessionID string) error {
- return engine.RevokeSession(ctx, sessionID)
-}
-
-// ==================== CONFIGURATION ====================
-
-// WithJWTSecret sets a custom JWT secret
-func WithJWTSecret(engine *Engine, secret string) *Engine {
- return engine.WithJWTSecret(secret)
-}
-
-// WithRateLimiter sets a custom rate limiter
-func WithRateLimiter(engine *Engine, limiter RateLimiter) *Engine {
- return engine.WithRateLimiter(limiter)
-}
-
-// WithAuditLogger sets a custom audit logger
-func WithAuditLogger(engine *Engine, logger AuditLogger) *Engine {
- return engine.WithAuditLogger(logger)
-}
-
-// WithHasher sets a custom password hasher
-func WithHasher(engine *Engine, hasher Hasher) *Engine {
- return engine.WithHasher(hasher)
-}
-
-// ==================== RE-EXPORTED TYPES ====================
-
-type User = core.User
-type Session = core.Session
-type TokenPair = core.TokenPair
-type LimitResult = core.LimitResult
-type Claims = core.Claims
-
-// Interfaces
-type UserStore = core.UserStore
-type SessionStore = core.SessionStore
-type Hasher = core.Hasher
-type RateLimiter = core.RateLimiter
-type AuditLogger = core.AuditLogger
-type AuditEntry = core.AuditEntry
-
-// ==================== RE-EXPORTED ERRORS ====================
-
-var (
- ErrUserExists = core.ErrUserExists
- ErrUserNotFound = core.ErrUserNotFound
- ErrInvalidCredentials = core.ErrInvalidCredentials
- ErrInvalidEmail = core.ErrInvalidEmail
- ErrPasswordTooShort = core.ErrPasswordTooShort
- ErrPasswordTooLong = core.ErrPasswordTooLong
- ErrPasswordNoUpper = core.ErrPasswordNoUpper
- ErrPasswordNoLower = core.ErrPasswordNoLower
- ErrPasswordNoNumber = core.ErrPasswordNoNumber
- ErrTooManyAttempts = core.ErrTooManyAttempts
- ErrInvalidToken = core.ErrInvalidToken
- ErrSessionNotFound = core.ErrSessionNotFound
-)
diff --git a/examples/basic/main.go b/examples/basic/main.go
deleted file mode 100644
index 7441afc..0000000
--- a/examples/basic/main.go
+++ /dev/null
@@ -1,29 +0,0 @@
-package main
-
-import (
- "context"
- "fmt"
- "log"
-
- "github.com/crydensync/cryden"
-)
-
-func main() {
- // Create engine
- engine := cryden.New()
- ctx := context.Background()
-
- // Sign up
- user, err := engine.SignUp(ctx, "test@example.com", "Password123")
- if err != nil {
- log.Fatal(err)
- }
- fmt.Printf("User created: %s\n", user.ID)
-
- // Login
- tokens, _, err := engine.Login(ctx, "test@example.com", "Password123")
- if err != nil {
- log.Fatal(err)
- }
- fmt.Printf("Access token: %s\n", tokens.AccessToken)
-}
diff --git a/examples/complete/main.go b/examples/complete/main.go
deleted file mode 100644
index 7651ba9..0000000
--- a/examples/complete/main.go
+++ /dev/null
@@ -1,181 +0,0 @@
-package main
-
-import (
- "context"
- "fmt"
- "log"
- "time"
-
- "github.com/crydensync/cryden"
-)
-
-func main() {
- // ==================== SETUP ====================
- ctx := context.Background()
-
- fmt.Println("π Initializing Cryden Auth Engine...")
- engine := cryden.New()
-
- // Optional: Set JWT secret (in production, use environment variable)
- cryden.WithJWTSecret(engine, "your-super-secret-key-change-this")
-
- // ==================== SIGN UP ====================
- fmt.Println("\nπ Testing SignUp...")
- user, err := cryden.SignUp(ctx, engine, "john@example.com", "SecurePass123")
- if err != nil {
- log.Fatalf("β SignUp failed: %v", err)
- }
- fmt.Printf("β
User created: ID=%s, Email=%s\n", user.ID, user.Email)
-
- // ==================== LOGIN ====================
- fmt.Println("\nπ Testing Login...")
- tokens, limit, err := cryden.Login(ctx, engine, "john@example.com", "SecurePass123")
- if err != nil {
- log.Fatalf("β Login failed: %v", err)
- }
- fmt.Printf("β
Login successful!\n")
- fmt.Printf(" Access Token: %.40s...\n", tokens.AccessToken)
- fmt.Printf(" Refresh Token: %.40s...\n", tokens.RefreshToken)
- fmt.Printf(" Rate Limit Remaining: %d\n", limit.Remaining)
-
- // ==================== VERIFY TOKEN ====================
- fmt.Println("\nπ Testing Token Verification...")
- userID, err := cryden.VerifyToken(engine, tokens.AccessToken)
- if err != nil {
- log.Fatalf("β Token verification failed: %v", err)
- }
- fmt.Printf("β
Token verified! User ID: %s\n", userID)
-
- // ==================== LIST SESSIONS ====================
- fmt.Println("\nπ Listing Active Sessions...")
- sessions, err := cryden.ListSessions(ctx, engine, user.ID)
- if err != nil {
- log.Fatalf("β Failed to list sessions: %v", err)
- }
- fmt.Printf("β
Found %d active session(s):\n", len(sessions))
- for i, s := range sessions {
- fmt.Printf(" Session %d: %s (expires: %s)\n", i+1, s.ID, s.ExpiresAt.Format(time.RFC3339))
- }
-
- // ==================== REFRESH TOKEN ====================
- fmt.Println("\nπ Testing Token Refresh...")
- newTokens, err := cryden.RefreshToken(ctx, engine, tokens.RefreshToken)
- if err != nil {
- log.Fatalf("β Token refresh failed: %v", err)
- }
- fmt.Printf("β
Token refreshed!\n")
- fmt.Printf(" New Access Token: %.40s...\n", newTokens.AccessToken)
- fmt.Printf(" New Refresh Token: %.40s...\n", newTokens.RefreshToken)
-
- // ==================== CHANGE PASSWORD ====================
- fmt.Println("\nπ Testing Change Password...")
- err = cryden.ChangePassword(ctx, engine, user.ID, "SecurePass123", "NewSecurePass456")
- if err != nil {
- log.Fatalf("β Change password failed: %v", err)
- }
- fmt.Printf("β
Password changed successfully!\n")
-
- // Test login with new password
- fmt.Println(" Testing login with new password...")
- _, _, err = cryden.Login(ctx, engine, "john@example.com", "NewSecurePass456")
- if err != nil {
- log.Fatalf("β Login with new password failed: %v", err)
- }
- fmt.Printf("β
Login with new password successful!\n")
-
- // ==================== CHANGE EMAIL ====================
- fmt.Println("\nπ§ Testing Change Email...")
- err = cryden.ChangeEmail(ctx, engine, user.ID, "john.new@example.com")
- if err != nil {
- log.Fatalf("β Change email failed: %v", err)
- }
- fmt.Printf("β
Email changed to: john.new@example.com\n")
-
- // Verify email change
- updatedUser, err := cryden.GetUser(ctx, engine, user.ID)
- if err != nil {
- log.Fatalf("β Failed to get user: %v", err)
- }
- fmt.Printf(" User email is now: %s\n", updatedUser.Email)
-
- // ==================== LOGOUT ====================
- fmt.Println("\nπͺ Testing Logout...")
-
- // Login again to get new tokens after password change
- newTokens, _, err = cryden.Login(ctx, engine, "john.new@example.com", "NewSecurePass456")
- if err != nil {
- log.Fatalf("β Login failed: %v", err)
- }
-
- err = cryden.Logout(ctx, engine, newTokens.RefreshToken)
- if err != nil {
- log.Fatalf("β Logout failed: %v", err)
- }
- fmt.Printf("β
Logout successful!\n")
-
- // Try to refresh with logged out token - should fail
- _, err = cryden.RefreshToken(ctx, engine, newTokens.RefreshToken)
- if err != nil {
- fmt.Printf("β
Refresh with logged out token correctly failed: %v\n", err)
- }
-
- // ==================== LOGIN AGAIN FOR LOGOUT ALL TEST ====================
- fmt.Println("\nπ Logging in again for Logout All test...")
- tokens, _, err = cryden.Login(ctx, engine, "john.new@example.com", "NewSecurePass456")
- if err != nil {
- log.Fatalf("β Login failed: %v", err)
- }
-
- // Create another session by logging in again
- tokens2, _, err := cryden.Login(ctx, engine, "john.new@example.com", "NewSecurePass456")
- if err != nil {
- log.Fatalf("β Second login failed: %v", err)
- }
- fmt.Printf("β
Created 2 active sessions\n")
-
- // ==================== LOGOUT ALL ====================
- fmt.Println("\nπͺ Testing Logout All Devices...")
- err = cryden.LogoutAll(ctx, engine, user.ID)
- if err != nil {
- log.Fatalf("β LogoutAll failed: %v", err)
- }
- fmt.Printf("β
Logged out from all devices!\n")
-
- // Try to use old tokens - should fail
- _, err = cryden.RefreshToken(ctx, engine, tokens.RefreshToken)
- if err != nil {
- fmt.Printf("β
First session correctly invalidated: %v\n", err)
- }
- _, err = cryden.RefreshToken(ctx, engine, tokens2.RefreshToken)
- if err != nil {
- fmt.Printf("β
Second session correctly invalidated: %v\n", err)
- }
-
- // ==================== DELETE ACCOUNT ====================
- fmt.Println("\nποΈ Testing Delete Account...")
-
- // Rate limiter blocks after 5 attempts per minute
- // Wait for it to reset before final login
- fmt.Println("β±οΈ Rate limit active - waiting 61 seconds...")
- time.Sleep(61 * time.Second)
-
- // Login one last time
- _, _, err = cryden.Login(ctx, engine, "john.new@example.com", "NewSecurePass456")
- if err != nil {
- log.Fatalf("β Final login failed: %v", err)
- }
-
- err = cryden.DeleteAccount(ctx, engine, user.ID)
- if err != nil {
- log.Fatalf("β Delete account failed: %v", err)
- }
- fmt.Printf("β
Account deleted successfully!\n")
-
- // Try to login with deleted account - should fail
- _, _, err = cryden.Login(ctx, engine, "john.new@example.com", "NewSecurePass456")
- if err != nil {
- fmt.Printf("β
Login with deleted account correctly failed: %v\n", err)
- }
-
- fmt.Println("\nπ All Cryden features tested successfully!")
-}
diff --git a/go.mod b/go.mod
index d7a5598..fe20a0b 100644
--- a/go.mod
+++ b/go.mod
@@ -1,23 +1,9 @@
-module github.com/crydensync/cryden
+module github.com/crydensync/cryden/v2
-go 1.25.4
+go 1.25.0
require (
github.com/golang-jwt/jwt/v5 v5.3.1
- github.com/lib/pq v1.11.2
- github.com/mattn/go-sqlite3 v1.14.34
- go.mongodb.org/mongo-driver v1.17.9
- golang.org/x/crypto v0.48.0
-)
-
-require (
- github.com/golang/snappy v0.0.4 // indirect
- github.com/klauspost/compress v1.16.7 // indirect
- github.com/montanaflynn/stats v0.7.1 // indirect
- github.com/xdg-go/pbkdf2 v1.0.0 // indirect
- github.com/xdg-go/scram v1.1.2 // indirect
- github.com/xdg-go/stringprep v1.0.4 // indirect
- github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
- golang.org/x/sync v0.19.0 // indirect
- golang.org/x/text v0.34.0 // indirect
+ github.com/google/uuid v1.6.0
+ golang.org/x/crypto v0.54.0
)
diff --git a/go.sum b/go.sum
index 11a549a..cbca2de 100644
--- a/go.sum
+++ b/go.sum
@@ -1,56 +1,6 @@
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
-github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
-github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
-github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
-github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
-github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
-github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
-github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
-github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
-github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
-github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
-github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
-github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
-github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
-github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
-github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
-github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
-github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
-github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
-github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
-github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
-github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
-github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
-go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ=
-golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
-golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
-golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
-golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
-golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
-golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
-golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
-golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
-golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
-golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
-golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
-golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
-golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
-golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
-golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
+golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
diff --git a/internal/core/audit.go b/internal/core/audit.go
deleted file mode 100644
index 8b854c9..0000000
--- a/internal/core/audit.go
+++ /dev/null
@@ -1,174 +0,0 @@
-package core
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "os"
- "path/filepath"
- "sync"
- "time"
-)
-
-// AuditAction represents what happened
-type AuditAction string
-
-const (
- ActionSignUp AuditAction = "SIGN_UP"
- ActionSignInSuccess AuditAction = "SIGN_IN_SUCCESS"
- ActionSignInFailed AuditAction = "SIGN_IN_FAILED"
- ActionSignOut AuditAction = "SIGN_OUT"
- ActionSignOutAll AuditAction = "SIGN_OUT_ALL"
- ActionPasswordChange AuditAction = "PASSWORD_CHANGE"
- ActionEmailChange AuditAction = "EMAIL_CHANGE"
- ActionAccountDelete AuditAction = "ACCOUNT_DELETE"
- ActionTokenRefresh AuditAction = "TOKEN_REFRESH"
- ActionRateLimited AuditAction = "RATE_LIMITED"
-)
-
-// AuditEntry represents a single audit log entry
-type AuditEntry struct {
- Timestamp time.Time `json:"timestamp"`
- UserID string `json:"user_id,omitempty"`
- Action AuditAction `json:"action"`
- Status string `json:"status"`
- Error string `json:"error,omitempty"`
- IPAddress string `json:"ip_address,omitempty"`
- UserAgent string `json:"user_agent,omitempty"`
- Metadata map[string]interface{} `json:"metadata,omitempty"`
-}
-
-// AuditLogger defines how to log events
-type AuditLogger interface {
- Log(ctx context.Context, entry AuditEntry) error
- Close() error
-}
-
-// ConsoleAuditLogger prints to the console
-type ConsoleAuditLogger struct{}
-
-// NewConsoleAuditLogger creates a new console audit logger
-func NewConsoleAuditLogger() *ConsoleAuditLogger {
- return &ConsoleAuditLogger{}
-}
-
-// Log prints the audit entry to console
-func (l *ConsoleAuditLogger) Log(ctx context.Context, entry AuditEntry) error {
- timestamp := entry.Timestamp.Format("2006-01-02 15:04:05")
- fmt.Printf("[AUDIT] %s | User: %s | Action: %s | Status: %s\n",
- timestamp, entry.UserID, entry.Action, entry.Status)
-
- if entry.Error != "" {
- fmt.Printf(" Error: %s\n", entry.Error)
- }
- if entry.IPAddress != "" {
- fmt.Printf(" IP: %s\n", entry.IPAddress)
- }
- if len(entry.Metadata) > 0 {
- fmt.Printf(" Metadata: %v\n", entry.Metadata)
- }
-
- return nil
-}
-
-// Close closes the logger (no-op for console)
-func (l *ConsoleAuditLogger) Close() error {
- return nil
-}
-
-// NoopAuditLogger does nothing (for testing)
-type NoopAuditLogger struct{}
-
-// NewNoopAuditLogger creates a new no-op audit logger
-func NewNoopAuditLogger() *NoopAuditLogger {
- return &NoopAuditLogger{}
-}
-
-// Log does nothing
-func (l *NoopAuditLogger) Log(ctx context.Context, entry AuditEntry) error {
- return nil
-}
-
-// Close does nothing
-func (l *NoopAuditLogger) Close() error {
- return nil
-}
-
-// FileAuditLogger writes audit logs to a file in JSON format
-type FileAuditLogger struct {
- mu sync.Mutex
- file *os.File
- encoder *json.Encoder
- filePath string
-}
-
-// NewFileAuditLogger creates a new file audit logger
-func NewFileAuditLogger(filePath string) (*FileAuditLogger, error) {
- // Create directory if it doesn't exist
- dir := filepath.Dir(filePath)
- if err := os.MkdirAll(dir, 0755); err != nil {
- return nil, fmt.Errorf("failed to create log directory: %w", err)
- }
-
- // Open file for appending
- file, err := os.OpenFile(filePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
- if err != nil {
- return nil, fmt.Errorf("failed to open log file: %w", err)
- }
-
- return &FileAuditLogger{
- file: file,
- encoder: json.NewEncoder(file),
- filePath: filePath,
- }, nil
-}
-
-// Log writes an audit entry to the file
-func (l *FileAuditLogger) Log(ctx context.Context, entry AuditEntry) error {
- l.mu.Lock()
- defer l.mu.Unlock()
-
- // Add timestamp if not set
- if entry.Timestamp.IsZero() {
- entry.Timestamp = time.Now()
- }
-
- // Write JSON line
- if err := l.encoder.Encode(entry); err != nil {
- return fmt.Errorf("failed to write audit log: %w", err)
- }
-
- return nil
-}
-
-// Close closes the log file
-func (l *FileAuditLogger) Close() error {
- l.mu.Lock()
- defer l.mu.Unlock()
-
- if l.file != nil {
- return l.file.Close()
- }
- return nil
-}
-
-// Rotate closes and reopens the log file (for log rotation)
-func (l *FileAuditLogger) Rotate() error {
- l.mu.Lock()
- defer l.mu.Unlock()
-
- // Close current file
- if err := l.file.Close(); err != nil {
- return err
- }
-
- // Reopen file
- file, err := os.OpenFile(l.filePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
- if err != nil {
- return err
- }
-
- l.file = file
- l.encoder = json.NewEncoder(file)
- return nil
-}
diff --git a/internal/core/auth.go b/internal/core/auth.go
deleted file mode 100644
index 6b6b1db..0000000
--- a/internal/core/auth.go
+++ /dev/null
@@ -1,281 +0,0 @@
-package core
-
-import (
- "context"
- "crypto/rand"
- "crypto/sha256"
- "encoding/base64"
- "encoding/hex"
- "fmt"
- "time"
-
- "github.com/golang-jwt/jwt/v5"
-)
-
-// SignUp creates a new user account
-func (e *Engine) SignUp(ctx context.Context, email, password string) (*User, error) {
- // Validate email
- if err := ValidateEmail(email); err != nil {
- return nil, err
- }
-
- // Validate password
- if err := ValidatePassword(password, e.config.PasswordPolicy); err != nil {
- return nil, err
- }
-
- // Check if user already exists
- existing, err := e.users.GetByEmail(ctx, email)
- if err != nil && err != ErrUserNotFound {
- return nil, err
- }
- if existing != nil {
- e.auditLogger.Log(ctx, AuditEntry{
- Timestamp: time.Now(),
- Action: ActionSignUp,
- Status: "FAILED",
- Error: "email already exist",
- Metadata: map[string]interface{}{"email": email},
- })
- return nil, ErrUserExists
- }
-
- // Hash password
- hash, err := e.hasher.Hash(password)
- if err != nil {
- return nil, err
- }
-
- // Create user
- user, err := e.users.Create(ctx, email, hash)
- if err != nil {
- return nil, err
- }
- e.auditLogger.Log(ctx, AuditEntry{
- Timestamp: time.Now(),
- UserID: user.ID,
- Action: ActionSignUp,
- Status: "SUCCESS",
- IPAddress: getClientIP(ctx),
- })
-
- return user, nil
-}
-
-// Login authenticates a user and returns tokens
-func (e *Engine) Login(ctx context.Context, email, password string, deviceInfo *DeviceInfo, ipAddress string) (*TokenPair, *LimitResult, error) {
- key := "login:" + getClientIP(ctx)
-
- // Check rate limit
- result, err := e.rateLimiter.Allow(ctx, key)
- if err != nil {
- return nil, &result, err
- }
-
- if !result.Allowed {
- e.auditLogger.Log(ctx, AuditEntry{
- Timestamp: time.Now(),
- Action: ActionRateLimited,
- Status: "BLOCKED",
- IPAddress: getClientIP(ctx),
- Metadata: map[string]interface{}{
- "remaining": result.Remaining,
- "reset": result.Reset,
- },
- })
- return nil, &result, ErrTooManyAttempts
- }
-
- // Find user
- user, err := e.users.GetByEmail(ctx, email)
- if err != nil {
- if err == ErrUserNotFound {
- e.auditLogger.Log(ctx, AuditEntry{
- Timestamp: time.Now(),
- Action: ActionSignInFailed,
- Status: "FAILED",
- Error: "user not found",
- IPAddress: getClientIP(ctx),
- })
- return nil, &result, ErrInvalidCredentials
- }
- return nil, &result, err
- }
-
- // Check password
- if err := e.hasher.Compare(password, user.PasswordHash); err != nil {
- e.auditLogger.Log(ctx, AuditEntry{
- Timestamp: time.Now(),
- UserID: user.ID,
- Action: ActionSignInFailed,
- Status: "FAILED",
- Error: "wrong password",
- IPAddress: getClientIP(ctx),
- })
- return nil, &result, ErrInvalidCredentials
- }
-
- // Generate tokens
- tokens, err := e.generateTokens(ctx, user.ID, deviceInfo, ipAddress)
- if err != nil {
- return nil, &result, err
- }
-
- // Reset rate limit on success
- //e.rateLimiter.Reset(ctx, key)
-
- e.auditLogger.Log(ctx, AuditEntry{
- Timestamp: time.Now(),
- UserID: user.ID,
- Action: ActionSignInSuccess,
- Status: "SUCCESS",
- IPAddress: getClientIP(ctx),
- })
-
- return tokens, &result, nil
-}
-
-// RefreshToken issues new tokens and rotates the refresh token
-func (e *Engine) RefreshToken(ctx context.Context, plainToken string) (*TokenPair, error) {
- // Generate lookup hash from plain token
- sha := sha256.Sum256([]byte(plainToken))
- lookupHash := hex.EncodeToString(sha[:])
-
- // Find session by lookup hash
- session, err := e.sessions.GetByRefreshToken(ctx, lookupHash)
- if err != nil {
- return nil, ErrInvalidToken
- }
-
- // Verify the token matches the stored hash
- if err := e.hasher.Compare(plainToken, session.RefreshToken); err != nil {
- e.auditLogger.Log(ctx, AuditEntry{
- Timestamp: time.Now(),
- UserID: session.UserID,
- Action: "TOKEN_TAMPERING",
- Status: "BLOCKED",
- })
- e.sessions.Revoke(ctx, session.ID)
- return nil, ErrInvalidToken
- }
-
- // Check expiration
- if time.Now().After(session.ExpiresAt) {
- e.sessions.Revoke(ctx, session.ID)
- return nil, ErrInvalidToken
- }
-
- // Generate new tokens (this creates a new session)
- newTokens, err := e.generateTokens(ctx, session.UserID)
- if err != nil {
- return nil, err
- }
-
- // Revoke old session
- if err := e.sessions.Revoke(ctx, session.ID); err != nil {
- // Log but continue
- e.auditLogger.Log(ctx, AuditEntry{
- Timestamp: time.Now(),
- UserID: session.UserID,
- Action: "OLD_SESSION_REVOKE_FAILED",
- Status: "WARNING",
- Error: err.Error(),
- })
- }
-
- // Audit
- e.auditLogger.Log(ctx, AuditEntry{
- Timestamp: time.Now(),
- UserID: session.UserID,
- Action: ActionTokenRefresh,
- Status: "SUCCESS",
- })
-
- return newTokens, nil
-}
-
-// generateTokens creates JWT access token and refresh token
-func (e *Engine) generateTokens(ctx context.Context, userID string, deviceInfo *DeviceInfo, ipAddress string) (*TokenPair, error) {
- // Generate JWT access token
- now := time.Now()
- claims := Claims{
- UserID: userID,
- RegisteredClaims: jwt.RegisteredClaims{
- ExpiresAt: jwt.NewNumericDate(now.Add(e.config.AccessTokenTTL)),
- IssuedAt: jwt.NewNumericDate(now),
- NotBefore: jwt.NewNumericDate(now),
- Issuer: e.config.Issuer,
- Subject: userID,
- ID: generateSecureID("tok"),
- },
- }
-
- token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
- accessToken, err := token.SignedString([]byte(e.config.JWTSecret))
- if err != nil {
- return nil, fmt.Errorf("failed to sign access token: %w", err)
- }
-
- // Generate refresh token
- tokenBytes := make([]byte, 32)
- if _, err := rand.Read(tokenBytes); err != nil {
- return nil, fmt.Errorf("failed to generate refresh token: %w", err)
- }
- plainToken := base64.RawURLEncoding.EncodeToString(tokenBytes)
-
- // Generate SHA256 lookup hash
- sha := sha256.Sum256([]byte(plainToken))
- lookupHash := hex.EncodeToString(sha[:])
-
- // Generate bcrypt storage hash
- storageHash, err := e.hasher.Hash(plainToken)
- if err != nil {
- return nil, fmt.Errorf("failed to hash refresh token: %w", err)
- }
-
- // Create session
- session, err := e.sessions.Create(ctx, userID, storageHash, lookupHash, deviceInfo, ipAddress)
- if err != nil {
- return nil, fmt.Errorf("failed to create session: %w", err)
- }
-
- // Verify the session was created with the lookup hash
- if session.LookupHash != lookupHash {
- return nil, fmt.Errorf("session lookup hash mismatch")
- }
-
- return &TokenPair{
- AccessToken: accessToken,
- RefreshToken: plainToken,
- TokenType: "Bearer",
- ExpiresIn: int64(e.config.AccessTokenTTL.Seconds()),
- }, nil
-}
-
-// Authenticate extracts user ID from token
-func (e *Engine) Authenticate(tokenString string) (string, error) {
- claims, err := e.VerifyToken(tokenString)
- if err != nil {
- return "", err
- }
- return claims.UserID, nil
-}
-
-// VerifyToken validates a JWT access token
-func (e *Engine) VerifyToken(tokenString string) (*Claims, error) {
- token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
- if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
- return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
- }
- return []byte(e.config.JWTSecret), nil
- })
-
- if err != nil {
- return nil, fmt.Errorf("failed to parse token: %w", err)
- }
-
- if claims, ok := token.Claims.(*Claims); ok && token.Valid {
- return claims, nil
- }
- return nil, ErrInvalidToken
-}
diff --git a/internal/core/device.go b/internal/core/device.go
deleted file mode 100644
index 2563b0d..0000000
--- a/internal/core/device.go
+++ /dev/null
@@ -1,92 +0,0 @@
-package core
-
-import (
- "strings"
-)
-
-// DeviceInfo contains parsed device information
-type DeviceInfo struct {
- DeviceName string
- DeviceType string
- Browser string
- OS string
-}
-
-// ParseUserAgent parses a User-Agent string and returns device info
-func ParseUserAgent(userAgent string) *DeviceInfo {
- if userAgent == "" {
- return &DeviceInfo{
- DeviceName: "Unknown",
- DeviceType: "unknown",
- Browser: "Unknown",
- OS: "Unknown",
- }
- }
-
- ua := strings.ToLower(userAgent)
- info := &DeviceInfo{
- DeviceName: "Unknown",
- DeviceType: "desktop",
- Browser: "Unknown",
- OS: "Unknown",
- }
-
- // Detect Device Type
- switch {
- case strings.Contains(ua, "mobile"):
- info.DeviceType = "mobile"
- case strings.Contains(ua, "tablet") || strings.Contains(ua, "ipad"):
- info.DeviceType = "tablet"
- case strings.Contains(ua, "bot") || strings.Contains(ua, "crawler"):
- info.DeviceType = "bot"
- }
-
- // Detect OS
- switch {
- case strings.Contains(ua, "windows"):
- info.OS = "Windows"
- if strings.Contains(ua, "windows nt 10.0") {
- info.OS = "Windows 10"
- } else if strings.Contains(ua, "windows nt 6.1") {
- info.OS = "Windows 7"
- }
- case strings.Contains(ua, "mac os x") || strings.Contains(ua, "macintosh"):
- info.OS = "macOS"
- case strings.Contains(ua, "iphone") || strings.Contains(ua, "ipad"):
- info.OS = "iOS"
- case strings.Contains(ua, "android"):
- info.OS = "Android"
- case strings.Contains(ua, "linux"):
- info.OS = "Linux"
- }
-
- // Detect Browser
- switch {
- case strings.Contains(ua, "chrome") && !strings.Contains(ua, "edg"):
- info.Browser = "Chrome"
- case strings.Contains(ua, "safari") && !strings.Contains(ua, "chrome"):
- info.Browser = "Safari"
- case strings.Contains(ua, "firefox"):
- info.Browser = "Firefox"
- case strings.Contains(ua, "edg"):
- info.Browser = "Edge"
- case strings.Contains(ua, "opera") || strings.Contains(ua, "opr"):
- info.Browser = "Opera"
- }
-
- // Build device name
- deviceParts := []string{}
- if info.Browser != "Unknown" {
- deviceParts = append(deviceParts, info.Browser)
- }
- if info.OS != "Unknown" {
- deviceParts = append(deviceParts, info.OS)
- }
- if len(deviceParts) > 0 {
- info.DeviceName = strings.Join(deviceParts, " on ")
- } else {
- info.DeviceName = "Unknown Device"
- }
-
- return info
-}
diff --git a/internal/core/engine.go b/internal/core/engine.go
deleted file mode 100644
index ffee30b..0000000
--- a/internal/core/engine.go
+++ /dev/null
@@ -1,93 +0,0 @@
-package core
-
-import (
- "context"
- "crypto/sha256"
- "encoding/hex"
- "time"
-)
-
-// Engine is the main authentication engine
-type Engine struct {
- users UserStore
- sessions SessionStore
- hasher Hasher
- rateLimiter RateLimiter
- auditLogger AuditLogger
- config Config
-}
-
-// Config holds engine configuration
-type Config struct {
- PasswordPolicy PasswordPolicy
- JWTSecret string
- AccessTokenTTL time.Duration
- RefreshTokenTTL time.Duration
- Issuer string
- TokenExpiry time.Duration
-}
-
-// DefaultConfig returns sensible defaults
-func DefaultConfig() Config {
- return Config{
- PasswordPolicy: DefaultPasswordPolicy(),
- JWTSecret: "change-this-in-production",
- AccessTokenTTL: 15 * time.Minute,
- RefreshTokenTTL: 7 * 24 * time.Hour,
- Issuer: "cryden",
- }
-}
-
-// New creates a new authentication engine
-func New(users UserStore, sessions SessionStore) *Engine {
- return &Engine{
- users: users,
- sessions: sessions,
- hasher: NewBcryptHasher(10),
- rateLimiter: NewMemoryRateLimiter(5, time.Minute),
- auditLogger: NewConsoleAuditLogger(),
- config: DefaultConfig(),
- }
-}
-
-// Configuration setters
-func (e *Engine) WithJWTSecret(secret string) *Engine {
- e.config.JWTSecret = secret
- return e
-}
-
-func (e *Engine) WithHasher(hasher Hasher) *Engine {
- e.hasher = hasher
- return e
-}
-
-func (e *Engine) WithRateLimiter(limiter RateLimiter) *Engine {
- e.rateLimiter = limiter
- return e
-}
-
-func (e *Engine) WithAuditLogger(logger AuditLogger) *Engine {
- e.auditLogger = logger
- return e
-}
-
-// Getter methods for testing
-func (e *Engine) GetUserStore() UserStore {
- return e.users
-}
-
-func (e *Engine) GetSessionStore() SessionStore {
- return e.sessions
-}
-
-// GetHasher returns the hasher (for testing)
-func (e *Engine) GetHasher() Hasher {
- return e.hasher
-}
-
-// GetSessionByRefreshToken returns a session using the plain refresh token
-func (e *Engine) GetSessionByRefreshToken(ctx context.Context, plainToken string) (*Session, error) {
- sha := sha256.Sum256([]byte(plainToken))
- lookupHash := hex.EncodeToString(sha[:])
- return e.sessions.GetByRefreshToken(ctx, lookupHash)
-}
diff --git a/internal/core/errors.go b/internal/core/errors.go
deleted file mode 100644
index 6f12a90..0000000
--- a/internal/core/errors.go
+++ /dev/null
@@ -1,44 +0,0 @@
-package core
-
-import "errors"
-
-var (
- // user errors
- ErrUserNotFound = errors.New("user not found")
- ErrUserExists = errors.New("user alredy exists")
- ErrInvalidEmail = errors.New("incorrect email format")
-
- // password errors
- ErrPasswordTooShort = errors.New("password must be at least 8 characters")
- ErrPasswordTooLong = errors.New("password execceds maximum lenght")
- ErrPasswordNoUpper = errors.New("password must contain an uppercase letter")
- ErrPasswordNoLower = errors.New("password must contain a lowercase letter")
- ErrPasswordNoNumber = errors.New("password must contain a number")
-
- // Rate limit errors
- ErrTooManyAttempts = errors.New("too many attempts, please try again later")
-
- // auth errors
- ErrInvalidCredentials = errors.New("invalid email or password")
- ErrInvalidToken = errors.New("invalid or expired token")
- // token errors
- ErrInvalidSession = errors.New("invalid or expired token")
- ErrSessionNotFound = errors.New("session not found")
- // Audit errors
- ErrAuditLogFailed = errors.New("Failed to write audit logs")
-)
-
-// validation error provides field level error details
-type ValidationError struct {
- Field string
- Message string
- Err error
-}
-
-func (e *ValidationError) Error() string {
- return e.Field + ": " + e.Message
-}
-
-func (e *ValidationError) Unwrap() error {
- return e.Err
-}
diff --git a/internal/core/hasher.go b/internal/core/hasher.go
deleted file mode 100644
index 3d893a9..0000000
--- a/internal/core/hasher.go
+++ /dev/null
@@ -1,48 +0,0 @@
-package core
-
-import "golang.org/x/crypto/bcrypt"
-
-// Hasher defines password opperation
-type Hasher interface {
- Hash(password string) (string, error)
- Compare(password, hash string) error
-}
-
-// BcryptHasher implement Hasher using bcrypt
-type BcryptHasher struct {
- Cost int
-}
-
-func NewBcryptHasher(cost int) *BcryptHasher {
- if cost < 4 || cost > 31 {
- cost = 10
- }
- return &BcryptHasher{Cost: cost}
-}
-
-func (h *BcryptHasher) Hash(password string) (string, error) {
- hash, err := bcrypt.GenerateFromPassword([]byte(password), h.Cost)
- return string(hash), err
-}
-
-func (h *BcryptHasher) Compare(password, hash string) error {
- err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
- if err != nil {
- return ErrInvalidCredentials
- }
- return nil
-}
-
-// MockHasher for fast tests
-type MockHasher struct{}
-
-func (h *MockHasher) Hash(password string) (string, error) {
- return password, nil
-}
-
-func (h *MockHasher) Compare(password, hash string) error {
- if password == hash {
- return nil
- }
- return ErrInvalidCredentials
-}
diff --git a/internal/core/helpers.go b/internal/core/helpers.go
deleted file mode 100644
index fcd682e..0000000
--- a/internal/core/helpers.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package core
-
-import (
- "context"
- "crypto/rand"
- "encoding/base64"
- "fmt"
- "time"
-)
-
-// getClientIP returns client IP from context or default
-// In production, users will set this via middleware
-// For now, this works for all tests and examples
-func getClientIP(ctx context.Context) string {
- // Try to get from context (if set by middleware)
- if ip := ctx.Value("client_ip"); ip != nil {
- if ipStr, ok := ip.(string); ok && ipStr != "" {
- return ipStr
- }
- }
-
- // Default for testing/development
- return "127.0.0.1"
-}
-
-// generateSecureID creates a unique ID with randomness
-func generateSecureID(prefix string) string {
- b := make([]byte, 8)
- if _, err := rand.Read(b); err != nil {
- // Fallback if rand fails (very rare)
- return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano())
- }
- random := base64.RawURLEncoding.EncodeToString(b)
- return fmt.Sprintf("%s_%d_%s", prefix, time.Now().UnixNano(), random)
-}
-
-// generateID kept for backward compatibility
-func generateID() string {
- return generateSecureID("gen")
-}
diff --git a/internal/core/interfaces.go b/internal/core/interfaces.go
deleted file mode 100644
index 895fd0e..0000000
--- a/internal/core/interfaces.go
+++ /dev/null
@@ -1,37 +0,0 @@
-package core
-
-import "context"
-
-// UserStore defines how we store and retrieve users
-type UserStore interface {
- Create(ctx context.Context, email, passwordHash string) (*User, error)
- GetByEmail(ctx context.Context, email string) (*User, error)
- GetByID(ctx context.Context, id string) (*User, error)
- UpdateEmail(ctx context.Context, id, newEmail string) error
- UpdatePassword(ctx context.Context, id, newPasswordHash string) error
- Delete(ctx context.Context, id string) error
- Close() error
-}
-
-/*
-// SessionStore defines how we store and retrieve sessions
-type SessionStore interface {
- Create(ctx context.Context, userID, refreshTokenHash, lookupHash string) (*Session, error)
- GetByRefreshToken(ctx context.Context, lookupHash string) (*Session, error)
- Revoke(ctx context.Context, sessionID string) error
- RevokeAllForUser(ctx context.Context, userID string) error
- ListForUser(ctx context.Context, userID string) ([]Session, error)
- Close() error
-}
-*/
-
-// SessionStore defines how we store and retrieve sessions
-type SessionStore interface {
- Create(ctx context.Context, userID, refreshTokenHash, lookupHash string, device *DeviceInfo, ipAddress string) (*Session, error)
- UpdateLastSeen(ctx context.Context, sessionID string) error
- GetByRefreshToken(ctx context.Context, lookupHash string) (*Session, error)
- Revoke(ctx context.Context, sessionID string) error
- RevokeAllForUser(ctx context.Context, userID string) error
- ListForUser(ctx context.Context, userID string) ([]Session, error)
- Close() error
-}
diff --git a/internal/core/models.go b/internal/core/models.go
deleted file mode 100644
index b7428cc..0000000
--- a/internal/core/models.go
+++ /dev/null
@@ -1,58 +0,0 @@
-package core
-
-import (
- "time"
- "github.com/golang-jwt/jwt/v5"
-)
-
-// User represents a user in the system
-type User struct {
- ID string
- Email string
- PasswordHash string
- CreatedAt time.Time
- UpdatedAt time.Time
-}
-
-// Session represents a user session
-type Session struct {
- ID string `json:"id"`
- UserID string `json:"user_id"`
- RefreshToken string `json:"refresh_token"`
- LookupHash string `json:"-"`
- CreatedAt time.Time `json:"created_at"`
- ExpiresAt time.Time `json:"expires_at"`
- LastSeenAt time.Time `json:"last_seen_at"`
- IPAddress string `json:"ip_address,omitempty"`
- DeviceName string `json:"device_name,omitempty"`
- DeviceType string `json:"device_type,omitempty"`
- Browser string `json:"browser,omitempty"`
- OS string `json:"os,omitempty"`
- Location string `json:"location,omitempty"`
-}
-
-/*
-// Session represents a user session with hashed refresh token
-type Session struct {
- ID string
- UserID string
- RefreshToken string // bcrypt hash of refresh token (stored)
- LookupHash string // SHA256 hash for fast DB lookup (indexed)
- CreatedAt time.Time
- ExpiresAt time.Time
-}
-*/
-
-// TokenPair contains access and refresh tokens
-type TokenPair struct {
- AccessToken string `json:"access_token"`
- RefreshToken string `json:"refresh_token"` // Plain text token sent to client
- TokenType string `json:"token_type"`
- ExpiresIn int64 `json:"expires_in"`
-}
-
-// Claims represents JWT claims
-type Claims struct {
- UserID string `json:"user_id"`
- jwt.RegisteredClaims
-}
diff --git a/internal/core/rate_limiter.go b/internal/core/rate_limiter.go
deleted file mode 100644
index 3800715..0000000
--- a/internal/core/rate_limiter.go
+++ /dev/null
@@ -1,117 +0,0 @@
-package core
-
-import (
- "context"
- "sync"
- "time"
-)
-
-// LimitResult contains rate limit info for response headers
-type LimitResult struct {
- Allowed bool
- Limit int
- Remaining int
- Reset time.Duration
-}
-
-// RateLimiter defines how rate limiting works
-type RateLimiter interface {
- // Allow checks if request is permitted
- Allow(ctx context.Context, key string) (LimitResult, error)
-
- // Reset clears limit for a key
- Reset(ctx context.Context, key string) error
-}
-
-// MemoryRateLimiter implements RateLimiter in memory
-type MemoryRateLimiter struct {
- mu sync.RWMutex
- attempts map[string][]time.Time
- limit int
- window time.Duration
-}
-
-// NewMemoryRateLimiter creates a new memory rate limiter
-func NewMemoryRateLimiter(limit int, window time.Duration) *MemoryRateLimiter {
- return &MemoryRateLimiter{
- attempts: make(map[string][]time.Time),
- limit: limit,
- window: window,
- }
-}
-
-// Allow checks if a request is within rate limit
-func (r *MemoryRateLimiter) Allow(ctx context.Context, key string) (LimitResult, error) {
- r.mu.Lock()
- defer r.mu.Unlock()
-
- now := time.Now()
- cutoff := now.Add(-r.window)
-
- // Clean old attempts
- attempts := r.attempts[key]
- valid := make([]time.Time, 0)
- for _, t := range attempts {
- if t.After(cutoff) {
- valid = append(valid, t)
- }
- }
-
- // Check if under limit (strict: < limit means allowed)
- if len(valid) < r.limit {
- // Add current attempt
- valid = append(valid, now)
- r.attempts[key] = valid
-
- remaining := r.limit - len(valid)
- if remaining < 0 {
- remaining = 0
- }
-
- return LimitResult{
- Allowed: true,
- Limit: r.limit,
- Remaining: remaining,
- Reset: 0,
- }, nil
- }
-
- // Rate limited
- oldest := valid[0]
- resetTime := oldest.Add(r.window)
- resetDuration := time.Until(resetTime)
- if resetDuration < 0 {
- resetDuration = 0
- }
-
- return LimitResult{
- Allowed: false,
- Limit: r.limit,
- Remaining: 0,
- Reset: resetDuration,
- }, nil
-}
-
-// Reset clears rate limit for a key
-func (r *MemoryRateLimiter) Reset(ctx context.Context, key string) error {
- r.mu.Lock()
- defer r.mu.Unlock()
- delete(r.attempts, key)
- return nil
-}
-
-// NoopRateLimiter for testing - allows everything
-type NoopRateLimiter struct{}
-
-func (r *NoopRateLimiter) Allow(ctx context.Context, key string) (LimitResult, error) {
- return LimitResult{
- Allowed: true,
- Limit: 0,
- Remaining: 0,
- Reset: 0,
- }, nil
-}
-
-func (r *NoopRateLimiter) Reset(ctx context.Context, key string) error {
- return nil
-}
diff --git a/internal/core/session.go b/internal/core/session.go
deleted file mode 100644
index db791ba..0000000
--- a/internal/core/session.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package core
-
-import (
- "context"
- "crypto/sha256"
- "encoding/hex"
- "time"
-
-)
-
-// ListSessions returns all active sessions for a user
-func (e *Engine) ListSessions(ctx context.Context, userID string) ([]Session, error) {
- return e.sessions.ListForUser(ctx, userID)
-}
-
-// RevokeSession manually revokes a specific session
-func (e *Engine) RevokeSession(ctx context.Context, sessionID string) error {
- return e.sessions.Revoke(ctx, sessionID)
-}
-
-// Logout revokes the current session
-func (e *Engine) Logout(ctx context.Context, refreshToken string) error {
- // Generate lookup hash from plain token
- sha := sha256.Sum256([]byte(refreshToken))
- lookupHash := hex.EncodeToString(sha[:])
-
- session, err := e.sessions.GetByRefreshToken(ctx, lookupHash)
- if err != nil {
- return ErrInvalidToken
- }
-
- if err := e.sessions.Revoke(ctx, session.ID); err != nil {
- return err
- }
-
- e.auditLogger.Log(ctx, AuditEntry{
- Timestamp: time.Now(),
- UserID: session.UserID,
- Action: ActionSignOut,
- Status: "SUCCESS",
- IPAddress: getClientIP(ctx),
- })
-
- return nil
-}
-
-// LogoutAll revokes ALL sessions for a user
-func (e *Engine) LogoutAll(ctx context.Context, userID string) error {
- if err := e.sessions.RevokeAllForUser(ctx, userID); err != nil {
- return err
- }
-
- e.auditLogger.Log(ctx, AuditEntry{
- Timestamp: time.Now(),
- UserID: userID,
- Action: ActionSignOutAll,
- Status: "SUCCESS",
- IPAddress: getClientIP(ctx),
- })
-
- return nil
-}
diff --git a/internal/core/user.go b/internal/core/user.go
deleted file mode 100644
index 6a2f211..0000000
--- a/internal/core/user.go
+++ /dev/null
@@ -1,119 +0,0 @@
-package core
-
-import (
- "context"
- "time"
-)
-
-// GetUser retrieves a user by ID
-func (e *Engine) GetUser(ctx context.Context, userID string) (*User, error) {
- return e.users.GetByID(ctx, userID)
-}
-
-// GetUserByEmail retrieves a user by email
-func (e *Engine) GetUserByEmail(ctx context.Context, email string) (*User, error) {
- return e.users.GetByEmail(ctx, email)
-}
-
-// ChangePassword updates user's password and logs out all devices
-func (e *Engine) ChangePassword(ctx context.Context, userID, oldPassword, newPassword string) error {
- // Get user
- user, err := e.users.GetByID(ctx, userID)
- if err != nil {
- return err
- }
-
- // Verify old password
- if err := e.hasher.Compare(oldPassword, user.PasswordHash); err != nil {
- e.auditLogger.Log(ctx, AuditEntry{
- Timestamp: time.Now(),
- UserID: userID,
- Action: ActionPasswordChange,
- Status: "FAILED",
- Error: "wrong old password",
- })
- return ErrInvalidCredentials
- }
-
- // Validate new password
- if err := ValidatePassword(newPassword, e.config.PasswordPolicy); err != nil {
- return err
- }
-
- // Hash new password
- newHash, err := e.hasher.Hash(newPassword)
- if err != nil {
- return err
- }
-
- // Update in database
- if err := e.users.UpdatePassword(ctx, userID, newHash); err != nil {
- return err
- }
-
- // Logout all devices (security best practice)
- e.sessions.RevokeAllForUser(ctx, userID)
-
- e.auditLogger.Log(ctx, AuditEntry{
- Timestamp: time.Now(),
- UserID: userID,
- Action: ActionPasswordChange,
- Status: "SUCCESS",
- })
-
- return nil
-}
-
-// ChangeEmail updates user's email
-func (e *Engine) ChangeEmail(ctx context.Context, userID, newEmail string) error {
- // Validate email
- if err := ValidateEmail(newEmail); err != nil {
- return err
- }
-
- // Check if email already exists
- existing, err := e.users.GetByEmail(ctx, newEmail)
- if err != nil && err != ErrUserNotFound {
- return err
- }
- if existing != nil {
- return ErrUserExists
- }
-
- // Update email
- if err := e.users.UpdateEmail(ctx, userID, newEmail); err != nil {
- return err
- }
-
- e.auditLogger.Log(ctx, AuditEntry{
- Timestamp: time.Now(),
- UserID: userID,
- Action: ActionEmailChange,
- Status: "SUCCESS",
- Metadata: map[string]interface{}{
- "new_email": newEmail,
- },
- })
-
- return nil
-}
-
-// DeleteAccount removes user and all sessions
-func (e *Engine) DeleteAccount(ctx context.Context, userID string) error {
- // Delete all sessions first
- e.sessions.RevokeAllForUser(ctx, userID)
-
- // Delete user
- if err := e.users.Delete(ctx, userID); err != nil {
- return err
- }
-
- e.auditLogger.Log(ctx, AuditEntry{
- Timestamp: time.Now(),
- UserID: userID,
- Action: ActionAccountDelete,
- Status: "SUCCESS",
- })
-
- return nil
-}
diff --git a/internal/core/validations.go b/internal/core/validations.go
deleted file mode 100644
index e8cdd6f..0000000
--- a/internal/core/validations.go
+++ /dev/null
@@ -1,154 +0,0 @@
-package core
-
-import (
- "strings"
- "unicode"
-)
-
-// ValidateEmail checks if email is valid
-func ValidateEmail(email string) error {
- // Trim spaces
- email = strings.TrimSpace(email)
- // Check if empty
- if email == "" {
- return &ValidationError{
- Field: "email",
- Message: "email cannot be empty",
- Err: ErrInvalidEmail,
- }
- }
- // Check lenght (RFC 5321)
- if len(email) > 254 {
- return &ValidationError{
- Field: "email",
- Message: "email too long",
- Err: ErrInvalidEmail,
- }
- }
- // Must contail "@"
- if !strings.Contains(email, "@") {
- return &ValidationError{
- Field: "email",
- Message: "email must contain @ symbol",
- Err: ErrInvalidEmail,
- }
- }
- // Split local and domain part
- parts := strings.Split(email, "@")
- if len(parts) != 2 {
- return &ValidationError{
- Field: "email",
- Message: "email must contain exactly on@ symbol",
- }
- }
- local, domain := parts[0], parts[1]
-
- // Check local part not empty
- if local == "" {
- return &ValidationError{
- Field: "email",
- Message: "email local part cannot be empty",
- Err: ErrInvalidEmail,
- }
- }
-
- // Check domain not empty
- if domain == "" {
- return &ValidationError{
- Field: "email",
- Message: "email domain connot be empty",
- Err: ErrInvalidEmail,
- }
- }
-
- // Domain must contain . dot
- if !strings.Contains(domain, ".") {
- return &ValidationError{
- Field: "email",
- Message: "email must contain a . dot",
- Err: ErrInvalidEmail,
- }
- }
-
- return nil
-}
-
-// PasswordPolicy defines rules for passwords
-type PasswordPolicy struct {
- MinLenght int
- MaxLenght int
- RequireUpper bool
- RequireLower bool
- RequireNumber bool
- RequireSpecial bool
-}
-
-// DefaultPasswordPolicy returns sensible defauls
-func DefaultPasswordPolicy() PasswordPolicy {
- return PasswordPolicy{
- MinLenght: 8,
- MaxLenght: 72, //bcrypt limit
- RequireUpper: true,
- RequireLower: true,
- RequireNumber: true,
- RequireSpecial: false, // for now test MVP
- }
-}
-
-func ValidatePassword(password string, policy PasswordPolicy) error {
- // Check length
- if len(password) < policy.MinLenght {
- return &ValidationError{
- Field: "password",
- Message: "password too short",
- Err: ErrPasswordTooShort,
- }
- }
-
- if len(password) > policy.MaxLenght {
- return &ValidationError{
- Field: "password",
- Message: "password too long",
- Err: ErrPasswordTooLong,
- }
- }
-
- // Check character requirements
- var hasUpper, hasLower, hasNumber bool
- for _, char := range password {
- switch {
- case unicode.IsUpper(char):
- hasUpper = true
- case unicode.IsLower(char):
- hasLower = true
- case unicode.IsDigit(char):
- hasNumber = true
- }
- }
-
- if policy.RequireUpper && !hasUpper {
- return &ValidationError{
- Field: "password",
- Message: "password must contain uppercase letter",
- Err: ErrPasswordNoUpper,
- }
- }
-
- if policy.RequireLower && !hasLower {
- return &ValidationError{
- Field: "password",
- Message: "password must contain lowercase letter",
- Err: ErrPasswordNoLower,
- }
- }
-
- if policy.RequireNumber && !hasNumber {
- return &ValidationError{
- Field: "password",
- Message: "password must contain number",
- Err: ErrPasswordNoNumber,
- }
- }
-
- return nil
-}
diff --git a/internal/stores/memory/session_store.go b/internal/stores/memory/session_store.go
deleted file mode 100644
index 47a8d89..0000000
--- a/internal/stores/memory/session_store.go
+++ /dev/null
@@ -1,180 +0,0 @@
-package memory
-
-import (
- "context"
- "fmt"
- "sync"
- "time"
-
- "github.com/crydensync/cryden/internal/core"
-)
-
-// SessionStore implements core.SessionStore with in-memory storage
-type SessionStore struct {
- mu sync.RWMutex
- byID map[string]*core.Session
- byUser map[string][]*core.Session
- byLookup map[string]string
-}
-
-// NewSessionStore creates a new in-memory session store
-func NewSessionStore() *SessionStore {
- return &SessionStore{
- byID: make(map[string]*core.Session),
- byUser: make(map[string][]*core.Session),
- byLookup: make(map[string]string),
- }
-}
-
-// Create stores a new session
-func (s *SessionStore) Create(ctx context.Context, userID, refreshTokenHash, lookupHash string, device *core.DeviceInfo, ipAddress string) (*core.Session, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- if _, exists := s.byLookup[lookupHash]; exists {
- return nil, fmt.Errorf("lookup hash collision")
- }
-
- now := time.Now()
- session := &core.Session{
- ID: generateID(),
- UserID: userID,
- RefreshToken: refreshTokenHash,
- LookupHash: lookupHash,
- CreatedAt: now,
- ExpiresAt: now.Add(7 * 24 * time.Hour),
- LastSeenAt: now,
- IPAddress: ipAddress,
- }
-
- if device != nil {
- session.DeviceName = device.DeviceName
- session.DeviceType = device.DeviceType
- session.Browser = device.Browser
- session.OS = device.OS
- }
-
- s.byID[session.ID] = session
- s.byLookup[lookupHash] = session.ID
- s.byUser[userID] = append(s.byUser[userID], session)
-
- return session, nil
-}
-
-// UpdateLastSeen updates the last seen time for a session
-func (s *SessionStore) UpdateLastSeen(ctx context.Context, sessionID string) error {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- session, exists := s.byID[sessionID]
- if !exists {
- return core.ErrSessionNotFound
- }
-
- session.LastSeenAt = time.Now()
- return nil
-}
-
-// ListForUser returns all sessions for a user
-func (s *SessionStore) ListForUser(ctx context.Context, userID string) ([]core.Session, error) {
- s.mu.RLock()
- defer s.mu.RUnlock()
-
- sessions, exists := s.byUser[userID]
- if !exists {
- return []core.Session{}, nil
- }
-
- now := time.Now()
- var active []core.Session
- for _, session := range sessions {
- if now.Before(session.ExpiresAt) {
- activeSession := *session
- activeSession.LookupHash = ""
- active = append(active, activeSession)
- }
- }
-
- return active, nil
-}
-
-// GetByRefreshToken finds session using lookup hash
-func (s *SessionStore) GetByRefreshToken(ctx context.Context, lookupHash string) (*core.Session, error) {
- s.mu.RLock()
- defer s.mu.RUnlock()
-
- sessionID, exists := s.byLookup[lookupHash]
- if !exists {
- return nil, core.ErrSessionNotFound
- }
-
- session, exists := s.byID[sessionID]
- if !exists {
- delete(s.byLookup, lookupHash)
- return nil, core.ErrSessionNotFound
- }
-
- // Return a copy to prevent modification
- sessionCopy := *session
- return &sessionCopy, nil
-}
-
-// Revoke removes a specific session
-func (s *SessionStore) Revoke(ctx context.Context, sessionID string) error {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- session, exists := s.byID[sessionID]
- if !exists {
- return core.ErrSessionNotFound
- }
-
- delete(s.byLookup, session.LookupHash)
-
- // Remove from ID map
- delete(s.byID, sessionID)
-
- // Remove from user's session list
- userSessions := s.byUser[session.UserID]
- for i, sess := range userSessions {
- if sess.ID == sessionID {
- // Remove by swapping with last element
- userSessions[i] = userSessions[len(userSessions)-1]
- s.byUser[session.UserID] = userSessions[:len(userSessions)-1]
- break
- }
- }
-
- return nil
-}
-
-// RevokeAllForUser removes all sessions for a user
-func (s *SessionStore) RevokeAllForUser(ctx context.Context, userID string) error {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- sessions, exists := s.byUser[userID]
- if !exists {
- return nil
- }
-
- // Remove each session
- for _, session := range sessions {
- delete(s.byLookup, session.LookupHash)
- delete(s.byID, session.ID)
- }
-
- delete(s.byUser, userID)
-
- return nil
-}
-
-// Helper function to generate IDs
-func generateID() string {
- return fmt.Sprintf("sess_%d", time.Now().UnixNano())
-}
-
-func (s *SessionStore) Close() error {
- // Memory store doesn't need cleanup
- return nil
-}
diff --git a/internal/stores/memory/user_store.go b/internal/stores/memory/user_store.go
deleted file mode 100644
index 0d3a85e..0000000
--- a/internal/stores/memory/user_store.go
+++ /dev/null
@@ -1,139 +0,0 @@
-package memory
-
-import (
- "context"
- "sync"
- "time"
-
- "github.com/crydensync/cryden/internal/core"
-)
-
-// UserStore implements core.userStore using in-memory map
-// Good for testing and development
-type UserStore struct {
- mu sync.RWMutex
- byEmail map[string]*core.User
- byID map[string]*core.User
-}
-
-// NewUserStore creates new in-memory user
-func NewUserStore() *UserStore {
- return &UserStore{
- byEmail: make(map[string]*core.User),
- byID: make(map[string]*core.User),
- }
-}
-
-// Create stores a user
-func (s *UserStore) Create(ctx context.Context, email, passwordHash string) (*core.User, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- //check if user already exits
- if _, exits := s.byEmail[email]; exits {
- return nil, core.ErrUserExists
- }
-
- // create user with generated byID
- user := &core.User{
- ID: generateID(),
- Email: email,
- PasswordHash: passwordHash,
- CreatedAt: time.Now(),
- UpdatedAt: time.Now(),
- }
-
- s.byEmail[email] = user
- s.byID[user.ID] = user
-
- return user, nil
-}
-
-// GetByEmail retrieves a user by email
-func (s *UserStore) GetByEmail(ctx context.Context, email string) (*core.User, error) {
- s.mu.RLock()
- defer s.mu.RUnlock()
-
- user, exits := s.byEmail[email]
- if !exits {
- return nil, core.ErrUserNotFound
- }
- return user, nil
-}
-
-// GetByID retrieve a user by ID
-func (s *UserStore) GetByID(ctx context.Context, id string) (*core.User, error) {
- s.mu.RLock()
- defer s.mu.RUnlock()
-
- user, exits := s.byID[id]
- if !exits {
- return nil, core.ErrUserNotFound
- }
- return user, nil
-}
-
-// UpdateEmail changes a user's email
-func (s *UserStore) UpdateEmail(ctx context.Context, id, newEmail string) error {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- user, exits := s.byID[id]
- if !exits {
- return core.ErrUserNotFound
- }
-
- // Check of new email is taken
- if _, exits := s.byEmail[newEmail]; exits {
- return core.ErrUserExists
- }
-
- // Remove old email
- delete(s.byEmail, user.Email)
-
- // Upadte email
- user.Email = newEmail
- user.UpdatedAt = time.Now()
-
- // Add new email mapping
- s.byEmail[newEmail] = user
-
- return nil
-}
-
-// UpdatePassword changes user's password
-func (s *UserStore) UpdatePassword(ctx context.Context, id, newPasswordHash string) error {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- user, exits := s.byID[id]
- if !exits {
- return core.ErrUserNotFound
- }
-
- user.PasswordHash = newPasswordHash
- user.UpdatedAt = time.Now()
-
- return nil
-}
-
-// Delete removes a user
-func (s *UserStore) Delete(ctx context.Context, id string) error {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- user, exits := s.byID[id]
- if !exits {
- return core.ErrUserNotFound
- }
-
- delete(s.byEmail, user.Email)
- delete(s.byID, user.ID)
-
- return nil
-}
-
-func (s *UserStore) Close() error {
- // Memory store doesn't need cleanup
- return nil
-}
diff --git a/internal/stores/mongodb/session_store.go b/internal/stores/mongodb/session_store.go
deleted file mode 100644
index 9c52b5b..0000000
--- a/internal/stores/mongodb/session_store.go
+++ /dev/null
@@ -1,236 +0,0 @@
-package mongodb
-
-import (
- "context"
- "fmt"
- "time"
-
- "github.com/crydensync/cryden/internal/core"
- "go.mongodb.org/mongo-driver/bson"
- "go.mongodb.org/mongo-driver/mongo"
- "go.mongodb.org/mongo-driver/mongo/options"
-)
-
-type SessionStore struct {
- collection *mongo.Collection
-}
-
-type mongoSession struct {
- ID string `bson:"_id"`
- UserID string `bson:"user_id"`
- RefreshToken string `bson:"refresh_token"`
- LookupHash string `bson:"lookup_hash"`
- CreatedAt time.Time `bson:"created_at"`
- ExpiresAt time.Time `bson:"expires_at"`
- LastSeenAt time.Time `bson:"last_seen_at"`
- IPAddress string `bson:"ip_address,omitempty"`
- DeviceName string `bson:"device_name,omitempty"`
- DeviceType string `bson:"device_type,omitempty"`
- Browser string `bson:"browser,omitempty"`
- OS string `bson:"os,omitempty"`
-}
-
-func NewSessionStore(uri, dbName string) (*SessionStore, error) {
- client, err := mongo.Connect(context.Background(), options.Client().ApplyURI(uri))
- if err != nil {
- return nil, fmt.Errorf("failed to connect to MongoDB: %w", err)
- }
-
- if err := client.Ping(context.Background(), nil); err != nil {
- return nil, fmt.Errorf("failed to ping MongoDB: %w", err)
- }
-
- collection := client.Database(dbName).Collection("sessions")
-
- // Create indexes
- lookupIndex := mongo.IndexModel{
- Keys: bson.D{{Key: "lookup_hash", Value: 1}},
- Options: options.Index().SetUnique(true),
- }
-
- userIndex := mongo.IndexModel{
- Keys: bson.D{{Key: "user_id", Value: 1}},
- }
-
- ttlIndex := mongo.IndexModel{
- Keys: bson.D{{Key: "expires_at", Value: 1}},
- Options: options.Index().SetExpireAfterSeconds(0),
- }
-
- lastSeenIndex := mongo.IndexModel{
- Keys: bson.D{{Key: "last_seen_at", Value: -1}},
- }
-
- _, err = collection.Indexes().CreateMany(context.Background(), []mongo.IndexModel{
- lookupIndex,
- userIndex,
- ttlIndex,
- lastSeenIndex,
- })
- if err != nil {
- return nil, fmt.Errorf("failed to create indexes: %w", err)
- }
-
- return &SessionStore{collection: collection}, nil
-}
-
-// Create stores a new session with device info
-func (s *SessionStore) Create(ctx context.Context, userID, refreshTokenHash, lookupHash string, device *core.DeviceInfo, ipAddress string) (*core.Session, error) {
- now := time.Now()
-
- session := mongoSession{
- ID: fmt.Sprintf("sess_%d", now.UnixNano()),
- UserID: userID,
- RefreshToken: refreshTokenHash,
- LookupHash: lookupHash,
- CreatedAt: now,
- ExpiresAt: now.Add(7 * 24 * time.Hour),
- LastSeenAt: now,
- IPAddress: ipAddress,
- }
-
- if device != nil {
- session.DeviceName = device.DeviceName
- session.DeviceType = device.DeviceType
- session.Browser = device.Browser
- session.OS = device.OS
- }
-
- _, err := s.collection.InsertOne(ctx, session)
- if err != nil {
- if mongo.IsDuplicateKeyError(err) {
- return nil, fmt.Errorf("lookup hash already exists: %w", err)
- }
- return nil, fmt.Errorf("failed to create session: %w", err)
- }
-
- return &core.Session{
- ID: session.ID,
- UserID: session.UserID,
- RefreshToken: session.RefreshToken,
- LookupHash: session.LookupHash,
- CreatedAt: session.CreatedAt,
- ExpiresAt: session.ExpiresAt,
- LastSeenAt: session.LastSeenAt,
- IPAddress: session.IPAddress,
- DeviceName: session.DeviceName,
- DeviceType: session.DeviceType,
- Browser: session.Browser,
- OS: session.OS,
- }, nil
-}
-
-// GetByRefreshToken finds session using lookup hash
-func (s *SessionStore) GetByRefreshToken(ctx context.Context, lookupHash string) (*core.Session, error) {
- var session mongoSession
- err := s.collection.FindOne(ctx, bson.M{
- "lookup_hash": lookupHash,
- "expires_at": bson.M{"$gt": time.Now()},
- }).Decode(&session)
-
- if err == mongo.ErrNoDocuments {
- return nil, core.ErrSessionNotFound
- }
- if err != nil {
- return nil, fmt.Errorf("failed to get session: %w", err)
- }
-
- return &core.Session{
- ID: session.ID,
- UserID: session.UserID,
- RefreshToken: session.RefreshToken,
- LookupHash: session.LookupHash,
- CreatedAt: session.CreatedAt,
- ExpiresAt: session.ExpiresAt,
- LastSeenAt: session.LastSeenAt,
- IPAddress: session.IPAddress,
- DeviceName: session.DeviceName,
- DeviceType: session.DeviceType,
- Browser: session.Browser,
- OS: session.OS,
- }, nil
-}
-
-// UpdateLastSeen updates the last seen time for a session
-func (s *SessionStore) UpdateLastSeen(ctx context.Context, sessionID string) error {
- result, err := s.collection.UpdateOne(
- ctx,
- bson.M{"_id": sessionID},
- bson.M{"$set": bson.M{"last_seen_at": time.Now()}},
- )
- if err != nil {
- return fmt.Errorf("failed to update last seen: %w", err)
- }
-
- if result.MatchedCount == 0 {
- return core.ErrSessionNotFound
- }
-
- return nil
-}
-
-// Revoke removes a specific session
-func (s *SessionStore) Revoke(ctx context.Context, sessionID string) error {
- result, err := s.collection.DeleteOne(ctx, bson.M{"_id": sessionID})
- if err != nil {
- return fmt.Errorf("failed to revoke session: %w", err)
- }
-
- if result.DeletedCount == 0 {
- return core.ErrSessionNotFound
- }
-
- return nil
-}
-
-// RevokeAllForUser removes all sessions for a user
-func (s *SessionStore) RevokeAllForUser(ctx context.Context, userID string) error {
- _, err := s.collection.DeleteMany(ctx, bson.M{"user_id": userID})
- if err != nil {
- return fmt.Errorf("failed to revoke all sessions: %w", err)
- }
-
- return nil
-}
-
-// ListForUser returns all active sessions for a user
-func (s *SessionStore) ListForUser(ctx context.Context, userID string) ([]core.Session, error) {
- cursor, err := s.collection.Find(ctx, bson.M{
- "user_id": userID,
- "expires_at": bson.M{"$gt": time.Now()},
- }, options.Find().SetSort(bson.M{"last_seen_at": -1}))
-
- if err != nil {
- return nil, fmt.Errorf("failed to list sessions: %w", err)
- }
- defer cursor.Close(ctx)
-
- var sessions []core.Session
- for cursor.Next(ctx) {
- var ms mongoSession
- if err := cursor.Decode(&ms); err != nil {
- return nil, fmt.Errorf("failed to decode session: %w", err)
- }
-
- sessions = append(sessions, core.Session{
- ID: ms.ID,
- UserID: ms.UserID,
- RefreshToken: ms.RefreshToken,
- LookupHash: "",
- CreatedAt: ms.CreatedAt,
- ExpiresAt: ms.ExpiresAt,
- LastSeenAt: ms.LastSeenAt,
- IPAddress: ms.IPAddress,
- DeviceName: ms.DeviceName,
- DeviceType: ms.DeviceType,
- Browser: ms.Browser,
- OS: ms.OS,
- })
- }
-
- return sessions, nil
-}
-
-func (s *SessionStore) Close() error {
- return nil
-}
diff --git a/internal/stores/mongodb/user_store.go b/internal/stores/mongodb/user_store.go
deleted file mode 100644
index 49f293d..0000000
--- a/internal/stores/mongodb/user_store.go
+++ /dev/null
@@ -1,175 +0,0 @@
-package mongodb
-
-import (
- "context"
- "fmt"
- "time"
-
- "github.com/crydensync/cryden/internal/core"
- "go.mongodb.org/mongo-driver/bson"
- "go.mongodb.org/mongo-driver/mongo"
- "go.mongodb.org/mongo-driver/mongo/options"
-)
-
-type UserStore struct {
- collection *mongo.Collection
-}
-
-type mongoUser struct {
- ID string `bson:"_id"`
- Email string `bson:"email"`
- PasswordHash string `bson:"password_hash"`
- CreatedAt time.Time `bson:"created_at"`
- UpdatedAt time.Time `bson:"updated_at"`
-}
-
-func NewUserStore(uri, dbName string) (*UserStore, error) {
- client, err := mongo.Connect(context.Background(), options.Client().ApplyURI(uri))
- if err != nil {
- return nil, fmt.Errorf("failed to connect to MongoDB: %w", err)
- }
-
- if err := client.Ping(context.Background(), nil); err != nil {
- return nil, fmt.Errorf("failed to ping MongoDB: %w", err)
- }
-
- collection := client.Database(dbName).Collection("users")
-
- // Create unique index on email
- indexModel := mongo.IndexModel{
- Keys: bson.D{{Key: "email", Value: 1}},
- Options: options.Index().SetUnique(true),
- }
-
- _, err = collection.Indexes().CreateOne(context.Background(), indexModel)
- if err != nil {
- return nil, fmt.Errorf("failed to create index: %w", err)
- }
-
- return &UserStore{collection: collection}, nil
-}
-
-func (s *UserStore) Create(ctx context.Context, email, passwordHash string) (*core.User, error) {
- now := time.Now()
- user := mongoUser{
- ID: fmt.Sprintf("usr_%d", time.Now().UnixNano()),
- Email: email,
- PasswordHash: passwordHash,
- CreatedAt: now,
- UpdatedAt: now,
- }
-
- _, err := s.collection.InsertOne(ctx, user)
- if err != nil {
- if mongo.IsDuplicateKeyError(err) {
- return nil, core.ErrUserExists
- }
- return nil, fmt.Errorf("failed to create user: %w", err)
- }
-
- return &core.User{
- ID: user.ID,
- Email: user.Email,
- PasswordHash: user.PasswordHash,
- CreatedAt: user.CreatedAt,
- UpdatedAt: user.UpdatedAt,
- }, nil
-}
-
-func (s *UserStore) GetByEmail(ctx context.Context, email string) (*core.User, error) {
- var user mongoUser
- err := s.collection.FindOne(ctx, bson.M{"email": email}).Decode(&user)
-
- if err == mongo.ErrNoDocuments {
- return nil, core.ErrUserNotFound
- }
- if err != nil {
- return nil, fmt.Errorf("failed to get user: %w", err)
- }
-
- return &core.User{
- ID: user.ID,
- Email: user.Email,
- PasswordHash: user.PasswordHash,
- CreatedAt: user.CreatedAt,
- UpdatedAt: user.UpdatedAt,
- }, nil
-}
-
-func (s *UserStore) GetByID(ctx context.Context, id string) (*core.User, error) {
- var user mongoUser
- err := s.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&user)
-
- if err == mongo.ErrNoDocuments {
- return nil, core.ErrUserNotFound
- }
- if err != nil {
- return nil, fmt.Errorf("failed to get user: %w", err)
- }
-
- return &core.User{
- ID: user.ID,
- Email: user.Email,
- PasswordHash: user.PasswordHash,
- CreatedAt: user.CreatedAt,
- UpdatedAt: user.UpdatedAt,
- }, nil
-}
-
-func (s *UserStore) UpdateEmail(ctx context.Context, id, newEmail string) error {
- result, err := s.collection.UpdateOne(
- ctx,
- bson.M{"_id": id},
- bson.M{"$set": bson.M{"email": newEmail, "updated_at": time.Now()}},
- )
-
- if err != nil {
- if mongo.IsDuplicateKeyError(err) {
- return core.ErrUserExists
- }
- return fmt.Errorf("failed to update email: %w", err)
- }
-
- if result.MatchedCount == 0 {
- return core.ErrUserNotFound
- }
-
- return nil
-}
-
-func (s *UserStore) UpdatePassword(ctx context.Context, id, newPasswordHash string) error {
- result, err := s.collection.UpdateOne(
- ctx,
- bson.M{"_id": id},
- bson.M{"$set": bson.M{"password_hash": newPasswordHash, "updated_at": time.Now()}},
- )
-
- if err != nil {
- return fmt.Errorf("failed to update password: %w", err)
- }
-
- if result.MatchedCount == 0 {
- return core.ErrUserNotFound
- }
-
- return nil
-}
-
-func (s *UserStore) Delete(ctx context.Context, id string) error {
- result, err := s.collection.DeleteOne(ctx, bson.M{"_id": id})
-
- if err != nil {
- return fmt.Errorf("failed to delete user: %w", err)
- }
-
- if result.DeletedCount == 0 {
- return core.ErrUserNotFound
- }
-
- return nil
-}
-
-func (s *UserStore) Close() error {
- // MongoDB client is managed elsewhere
- return nil
-}
diff --git a/internal/stores/postgres/session_store.go b/internal/stores/postgres/session_store.go
deleted file mode 100644
index 78a6c17..0000000
--- a/internal/stores/postgres/session_store.go
+++ /dev/null
@@ -1,190 +0,0 @@
-package postgres
-
-import (
- "context"
- "database/sql"
- "fmt"
- "time"
-
- "github.com/crydensync/cryden/internal/core"
- _ "github.com/lib/pq"
-)
-
-type SessionStore struct {
- db *sql.DB
-}
-
-func NewSessionStore(db *sql.DB) *SessionStore {
- return &SessionStore{db: db}
-}
-
-// Create stores a new session with device info
-func (s *SessionStore) Create(ctx context.Context, userID, refreshTokenHash, lookupHash string, device *core.DeviceInfo, ipAddress string) (*core.Session, error) {
- query := `
- INSERT INTO sessions (id, user_id, refresh_token, lookup_hash, created_at, expires_at, last_seen_at, ip_address, device_name, device_type, browser, os)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
- RETURNING id, user_id, refresh_token, lookup_hash, created_at, expires_at, last_seen_at, ip_address, device_name, device_type, browser, os
- `
-
- id := fmt.Sprintf("sess_%d", time.Now().UnixNano())
- now := time.Now()
- expiresAt := now.Add(7 * 24 * time.Hour)
-
- deviceName := ""
- deviceType := ""
- browser := ""
- os := ""
-
- if device != nil {
- deviceName = device.DeviceName
- deviceType = device.DeviceType
- browser = device.Browser
- os = device.OS
- }
-
- var session core.Session
- err := s.db.QueryRowContext(ctx, query,
- id, userID, refreshTokenHash, lookupHash, now, expiresAt, now, ipAddress,
- deviceName, deviceType, browser, os,
- ).Scan(
- &session.ID,
- &session.UserID,
- &session.RefreshToken,
- &session.LookupHash,
- &session.CreatedAt,
- &session.ExpiresAt,
- &session.LastSeenAt,
- &session.IPAddress,
- &session.DeviceName,
- &session.DeviceType,
- &session.Browser,
- &session.OS,
- )
-
- if err != nil {
- return nil, fmt.Errorf("failed to create session: %w", err)
- }
-
- return &session, nil
-}
-
-// GetByRefreshToken finds session using lookup hash
-func (s *SessionStore) GetByRefreshToken(ctx context.Context, lookupHash string) (*core.Session, error) {
- query := `
- SELECT id, user_id, refresh_token, lookup_hash, created_at, expires_at, last_seen_at, ip_address, device_name, device_type, browser, os
- FROM sessions
- WHERE lookup_hash = $1 AND expires_at > $2
- `
-
- var session core.Session
- err := s.db.QueryRowContext(ctx, query, lookupHash, time.Now()).Scan(
- &session.ID,
- &session.UserID,
- &session.RefreshToken,
- &session.LookupHash,
- &session.CreatedAt,
- &session.ExpiresAt,
- &session.LastSeenAt,
- &session.IPAddress,
- &session.DeviceName,
- &session.DeviceType,
- &session.Browser,
- &session.OS,
- )
-
- if err == sql.ErrNoRows {
- return nil, core.ErrSessionNotFound
- }
- if err != nil {
- return nil, fmt.Errorf("failed to get session: %w", err)
- }
-
- return &session, nil
-}
-
-// UpdateLastSeen updates the last seen time for a session
-func (s *SessionStore) UpdateLastSeen(ctx context.Context, sessionID string) error {
- query := `UPDATE sessions SET last_seen_at = $1 WHERE id = $2`
-
- result, err := s.db.ExecContext(ctx, query, time.Now(), sessionID)
- if err != nil {
- return fmt.Errorf("failed to update last seen: %w", err)
- }
-
- rows, _ := result.RowsAffected()
- if rows == 0 {
- return core.ErrSessionNotFound
- }
-
- return nil
-}
-
-// Revoke removes a specific session
-func (s *SessionStore) Revoke(ctx context.Context, sessionID string) error {
- query := `DELETE FROM sessions WHERE id = $1`
-
- result, err := s.db.ExecContext(ctx, query, sessionID)
- if err != nil {
- return fmt.Errorf("failed to revoke session: %w", err)
- }
-
- rows, _ := result.RowsAffected()
- if rows == 0 {
- return core.ErrSessionNotFound
- }
-
- return nil
-}
-
-// RevokeAllForUser removes all sessions for a user
-func (s *SessionStore) RevokeAllForUser(ctx context.Context, userID string) error {
- query := `DELETE FROM sessions WHERE user_id = $1`
-
- _, err := s.db.ExecContext(ctx, query, userID)
- if err != nil {
- return fmt.Errorf("failed to revoke all sessions: %w", err)
- }
-
- return nil
-}
-
-// ListForUser returns all active sessions for a user
-func (s *SessionStore) ListForUser(ctx context.Context, userID string) ([]core.Session, error) {
- query := `
- SELECT id, user_id, refresh_token, created_at, expires_at, last_seen_at, ip_address, device_name, device_type, browser, os
- FROM sessions
- WHERE user_id = $1 AND expires_at > $2
- ORDER BY last_seen_at DESC
- `
-
- rows, err := s.db.QueryContext(ctx, query, userID, time.Now())
- if err != nil {
- return nil, fmt.Errorf("failed to list sessions: %w", err)
- }
- defer rows.Close()
-
- var sessions []core.Session
- for rows.Next() {
- var session core.Session
- err := rows.Scan(
- &session.ID,
- &session.UserID,
- &session.RefreshToken,
- &session.CreatedAt,
- &session.ExpiresAt,
- &session.LastSeenAt,
- &session.IPAddress,
- &session.DeviceName,
- &session.DeviceType,
- &session.Browser,
- &session.OS,
- )
- if err != nil {
- return nil, fmt.Errorf("failed to scan session: %w", err)
- }
- session.LookupHash = ""
- sessions = append(sessions, session)
- }
-
- return sessions, nil
-}
diff --git a/internal/stores/postgres/user_store.go b/internal/stores/postgres/user_store.go
deleted file mode 100644
index d5122bb..0000000
--- a/internal/stores/postgres/user_store.go
+++ /dev/null
@@ -1,198 +0,0 @@
-package postgres
-
-import (
- "context"
- "database/sql"
- "fmt"
- "time"
-
- "github.com/crydensync/cryden/internal/core"
- _ "github.com/lib/pq"
-)
-
-type UserStore struct {
- db *sql.DB
-}
-
-func NewUserStore(connStr string) (*UserStore, error) {
- db, err := sql.Open("postgres", connStr)
- if err != nil {
- return nil, fmt.Errorf("failed to open database: %w", err)
- }
-
- if err := db.Ping(); err != nil {
- return nil, fmt.Errorf("failed to ping database: %w", err)
- }
-
- if err := autoMigrate(db); err != nil {
- return nil, fmt.Errorf("failed to migrate: %w", err)
- }
-
- return &UserStore{db: db}, nil
-}
-
-func autoMigrate(db *sql.DB) error {
- // Users table
- usersTable := `
- CREATE TABLE IF NOT EXISTS users (
- id TEXT PRIMARY KEY,
- email TEXT UNIQUE NOT NULL,
- password_hash TEXT NOT NULL,
- created_at TIMESTAMP NOT NULL,
- updated_at TIMESTAMP NOT NULL
- );
- CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
- `
-
- if _, err := db.Exec(usersTable); err != nil {
- return fmt.Errorf("failed to create users table: %w", err)
- }
-
- // Sessions table
- sessionsTable := `
- CREATE TABLE IF NOT EXISTS sessions (
- id TEXT PRIMARY KEY,
- user_id TEXT NOT NULL,
- refresh_token TEXT NOT NULL,
- lookup_hash TEXT UNIQUE NOT NULL,
- created_at TIMESTAMP NOT NULL,
- expires_at TIMESTAMP NOT NULL,
- last_seen_at TIMESTAMP NOT NULL,
- ip_address TEXT,
- device_name TEXT,
- device_type TEXT,
- browser TEXT,
- os TEXT,
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
- );
- CREATE INDEX IF NOT EXISTS idx_sessions_lookup ON sessions(lookup_hash);
- CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
- CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
- `
-
- if _, err := db.Exec(sessionsTable); err != nil {
- return fmt.Errorf("failed to create sessions table: %w", err)
- }
-
- return nil
-}
-
-func (s *UserStore) Create(ctx context.Context, email, passwordHash string) (*core.User, error) {
- query := `
- INSERT INTO users (id, email, password_hash, created_at, updated_at)
- VALUES ($1, $2, $3, $4, $5)
- RETURNING id, email, password_hash, created_at, updated_at
- `
-
- now := time.Now()
- id := fmt.Sprintf("usr_%d", time.Now().UnixNano())
-
- var user core.User
- err := s.db.QueryRowContext(ctx, query,
- id, email, passwordHash, now, now,
- ).Scan(&user.ID, &user.Email, &user.PasswordHash, &user.CreatedAt, &user.UpdatedAt)
-
- if err != nil {
- if err.Error() == `pq: duplicate key value violates unique constraint "users_email_key"` {
- return nil, core.ErrUserExists
- }
- return nil, fmt.Errorf("failed to create user: %w", err)
- }
-
- return &user, nil
-}
-
-func (s *UserStore) GetByEmail(ctx context.Context, email string) (*core.User, error) {
- query := `SELECT id, email, password_hash, created_at, updated_at FROM users WHERE email = $1`
-
- var user core.User
- err := s.db.QueryRowContext(ctx, query, email).Scan(
- &user.ID, &user.Email, &user.PasswordHash, &user.CreatedAt, &user.UpdatedAt,
- )
-
- if err == sql.ErrNoRows {
- return nil, core.ErrUserNotFound
- }
- if err != nil {
- return nil, fmt.Errorf("failed to get user: %w", err)
- }
-
- return &user, nil
-}
-
-func (s *UserStore) GetByID(ctx context.Context, id string) (*core.User, error) {
- query := `SELECT id, email, password_hash, created_at, updated_at FROM users WHERE id = $1`
-
- var user core.User
- err := s.db.QueryRowContext(ctx, query, id).Scan(
- &user.ID, &user.Email, &user.PasswordHash, &user.CreatedAt, &user.UpdatedAt,
- )
-
- if err == sql.ErrNoRows {
- return nil, core.ErrUserNotFound
- }
- if err != nil {
- return nil, fmt.Errorf("failed to get user: %w", err)
- }
-
- return &user, nil
-}
-
-func (s *UserStore) UpdateEmail(ctx context.Context, id, newEmail string) error {
- query := `UPDATE users SET email = $1, updated_at = $2 WHERE id = $3`
-
- result, err := s.db.ExecContext(ctx, query, newEmail, time.Now(), id)
- if err != nil {
- return fmt.Errorf("failed to update email: %w", err)
- }
-
- rows, _ := result.RowsAffected()
- if rows == 0 {
- return core.ErrUserNotFound
- }
-
- return nil
-}
-
-func (s *UserStore) UpdatePassword(ctx context.Context, id, newPasswordHash string) error {
- query := `UPDATE users SET password_hash = $1, updated_at = $2 WHERE id = $3`
-
- result, err := s.db.ExecContext(ctx, query, newPasswordHash, time.Now(), id)
- if err != nil {
- return fmt.Errorf("failed to update password: %w", err)
- }
-
- rows, _ := result.RowsAffected()
- if rows == 0 {
- return core.ErrUserNotFound
- }
-
- return nil
-}
-
-func (s *UserStore) Delete(ctx context.Context, id string) error {
- query := `DELETE FROM users WHERE id = $1`
-
- result, err := s.db.ExecContext(ctx, query, id)
- if err != nil {
- return fmt.Errorf("failed to delete user: %w", err)
- }
-
- rows, _ := result.RowsAffected()
- if rows == 0 {
- return core.ErrUserNotFound
- }
-
- return nil
-}
-
-func (s *UserStore) Close() error {
- if s.db != nil {
- return s.db.Close()
- }
- return nil
-}
-
-func (s *UserStore) GetDB() *sql.DB {
- return s.db
-}
diff --git a/internal/stores/sqlite/session_store.go b/internal/stores/sqlite/session_store.go
deleted file mode 100644
index b46d303..0000000
--- a/internal/stores/sqlite/session_store.go
+++ /dev/null
@@ -1,190 +0,0 @@
-package sqlite
-
-import (
- "context"
- "database/sql"
- "fmt"
- "time"
-
- "github.com/crydensync/cryden/internal/core"
- _ "github.com/mattn/go-sqlite3"
-)
-
-type SessionStore struct {
- db *sql.DB
-}
-
-func NewSessionStore(db *sql.DB) *SessionStore {
- return &SessionStore{db: db}
-}
-
-// Create stores a new session with device info
-func (s *SessionStore) Create(ctx context.Context, userID, refreshTokenHash, lookupHash string, device *core.DeviceInfo, ipAddress string) (*core.Session, error) {
- query := `
- INSERT INTO sessions (id, user_id, refresh_token, lookup_hash, created_at, expires_at, last_seen_at, ip_address, device_name, device_type, browser, os)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- RETURNING id, user_id, refresh_token, lookup_hash, created_at, expires_at, last_seen_at, ip_address, device_name, device_type, browser, os
- `
-
- id := fmt.Sprintf("sess_%d", time.Now().UnixNano())
- now := time.Now()
- expiresAt := now.Add(7 * 24 * time.Hour)
-
- deviceName := ""
- deviceType := ""
- browser := ""
- os := ""
-
- if device != nil {
- deviceName = device.DeviceName
- deviceType = device.DeviceType
- browser = device.Browser
- os = device.OS
- }
-
- var session core.Session
- err := s.db.QueryRowContext(ctx, query,
- id, userID, refreshTokenHash, lookupHash, now, expiresAt, now, ipAddress,
- deviceName, deviceType, browser, os,
- ).Scan(
- &session.ID,
- &session.UserID,
- &session.RefreshToken,
- &session.LookupHash,
- &session.CreatedAt,
- &session.ExpiresAt,
- &session.LastSeenAt,
- &session.IPAddress,
- &session.DeviceName,
- &session.DeviceType,
- &session.Browser,
- &session.OS,
- )
-
- if err != nil {
- return nil, fmt.Errorf("failed to create session: %w", err)
- }
-
- return &session, nil
-}
-
-// GetByRefreshToken finds session using lookup hash
-func (s *SessionStore) GetByRefreshToken(ctx context.Context, lookupHash string) (*core.Session, error) {
- query := `
- SELECT id, user_id, refresh_token, lookup_hash, created_at, expires_at, last_seen_at, ip_address, device_name, device_type, browser, os
- FROM sessions
- WHERE lookup_hash = ? AND expires_at > ?
- `
-
- var session core.Session
- err := s.db.QueryRowContext(ctx, query, lookupHash, time.Now()).Scan(
- &session.ID,
- &session.UserID,
- &session.RefreshToken,
- &session.LookupHash,
- &session.CreatedAt,
- &session.ExpiresAt,
- &session.LastSeenAt,
- &session.IPAddress,
- &session.DeviceName,
- &session.DeviceType,
- &session.Browser,
- &session.OS,
- )
-
- if err == sql.ErrNoRows {
- return nil, core.ErrSessionNotFound
- }
- if err != nil {
- return nil, fmt.Errorf("failed to get session: %w", err)
- }
-
- return &session, nil
-}
-
-// UpdateLastSeen updates the last seen time for a session
-func (s *SessionStore) UpdateLastSeen(ctx context.Context, sessionID string) error {
- query := `UPDATE sessions SET last_seen_at = ? WHERE id = ?`
-
- result, err := s.db.ExecContext(ctx, query, time.Now(), sessionID)
- if err != nil {
- return fmt.Errorf("failed to update last seen: %w", err)
- }
-
- rows, _ := result.RowsAffected()
- if rows == 0 {
- return core.ErrSessionNotFound
- }
-
- return nil
-}
-
-// Revoke removes a specific session
-func (s *SessionStore) Revoke(ctx context.Context, sessionID string) error {
- query := `DELETE FROM sessions WHERE id = ?`
-
- result, err := s.db.ExecContext(ctx, query, sessionID)
- if err != nil {
- return fmt.Errorf("failed to revoke session: %w", err)
- }
-
- rows, _ := result.RowsAffected()
- if rows == 0 {
- return core.ErrSessionNotFound
- }
-
- return nil
-}
-
-// RevokeAllForUser removes all sessions for a user
-func (s *SessionStore) RevokeAllForUser(ctx context.Context, userID string) error {
- query := `DELETE FROM sessions WHERE user_id = ?`
-
- _, err := s.db.ExecContext(ctx, query, userID)
- if err != nil {
- return fmt.Errorf("failed to revoke all sessions: %w", err)
- }
-
- return nil
-}
-
-// ListForUser returns all active sessions for a user
-func (s *SessionStore) ListForUser(ctx context.Context, userID string) ([]core.Session, error) {
- query := `
- SELECT id, user_id, refresh_token, created_at, expires_at, last_seen_at, ip_address, device_name, device_type, browser, os
- FROM sessions
- WHERE user_id = ? AND expires_at > ?
- ORDER BY last_seen_at DESC
- `
-
- rows, err := s.db.QueryContext(ctx, query, userID, time.Now())
- if err != nil {
- return nil, fmt.Errorf("failed to list sessions: %w", err)
- }
- defer rows.Close()
-
- var sessions []core.Session
- for rows.Next() {
- var session core.Session
- err := rows.Scan(
- &session.ID,
- &session.UserID,
- &session.RefreshToken,
- &session.CreatedAt,
- &session.ExpiresAt,
- &session.LastSeenAt,
- &session.IPAddress,
- &session.DeviceName,
- &session.DeviceType,
- &session.Browser,
- &session.OS,
- )
- if err != nil {
- return nil, fmt.Errorf("failed to scan session: %w", err)
- }
- session.LookupHash = "" // Don't expose
- sessions = append(sessions, session)
- }
-
- return sessions, nil
-}
diff --git a/internal/stores/sqlite/user_store.go b/internal/stores/sqlite/user_store.go
deleted file mode 100644
index a5e8a83..0000000
--- a/internal/stores/sqlite/user_store.go
+++ /dev/null
@@ -1,199 +0,0 @@
-package sqlite
-
-import (
- "context"
- "database/sql"
- "fmt"
- "time"
-
- "github.com/crydensync/cryden/internal/core"
- _ "github.com/mattn/go-sqlite3"
-)
-
-// UserStore implements core.UserStore with SQLite
-type UserStore struct {
- db *sql.DB
-}
-
-// NewUserStore creates a new SQLite user store
-func NewUserStore(dbPath string) (*UserStore, error) {
- db, err := sql.Open("sqlite3", dbPath)
- if err != nil {
- return nil, fmt.Errorf("failed to open database: %w", err)
- }
-
- if err := db.Ping(); err != nil {
- return nil, fmt.Errorf("failed to ping database: %w", err)
- }
-
- if err := autoMigrate(db); err != nil {
- return nil, fmt.Errorf("failed to migrate: %w", err)
- }
-
- return &UserStore{db: db}, nil
-}
-
-
-func autoMigrate(db *sql.DB) error {
- // Users table
- usersTable := `
- CREATE TABLE IF NOT EXISTS users (
- id TEXT PRIMARY KEY,
- email TEXT UNIQUE NOT NULL,
- password_hash TEXT NOT NULL,
- created_at TIMESTAMP NOT NULL,
- updated_at TIMESTAMP NOT NULL
- );
- CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
- `
-
- if _, err := db.Exec(usersTable); err != nil {
- return fmt.Errorf("failed to create users table: %w", err)
- }
-
- // Sessions table
- sessionsTable := `
- CREATE TABLE IF NOT EXISTS sessions (
- id TEXT PRIMARY KEY,
- user_id TEXT NOT NULL,
- refresh_token TEXT NOT NULL,
- lookup_hash TEXT UNIQUE NOT NULL,
- created_at TIMESTAMP NOT NULL,
- expires_at TIMESTAMP NOT NULL,
- last_seen_at TIMESTAMP NOT NULL,
- ip_address TEXT,
- device_name TEXT,
- device_type TEXT,
- browser TEXT,
- os TEXT,
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
- );
- CREATE INDEX IF NOT EXISTS idx_sessions_lookup ON sessions(lookup_hash);
- CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
- CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at);
- `
-
- if _, err := db.Exec(sessionsTable); err != nil {
- return fmt.Errorf("failed to create sessions table: %w", err)
- }
-
- return nil
-}
-
-func (s *UserStore) Create(ctx context.Context, email, passwordHash string) (*core.User, error) {
- query := `
- INSERT INTO users (id, email, password_hash, created_at, updated_at)
- VALUES (?, ?, ?, ?, ?)
- RETURNING id, email, password_hash, created_at, updated_at
- `
-
- now := time.Now()
- id := fmt.Sprintf("usr_%d", time.Now().UnixNano())
-
- var user core.User
- err := s.db.QueryRowContext(ctx, query,
- id, email, passwordHash, now, now,
- ).Scan(&user.ID, &user.Email, &user.PasswordHash, &user.CreatedAt, &user.UpdatedAt)
-
- if err != nil {
- if err.Error() == "UNIQUE constraint failed: users.email" {
- return nil, core.ErrUserExists
- }
- return nil, fmt.Errorf("failed to create user: %w", err)
- }
-
- return &user, nil
-}
-
-func (s *UserStore) GetByEmail(ctx context.Context, email string) (*core.User, error) {
- query := `SELECT id, email, password_hash, created_at, updated_at FROM users WHERE email = ?`
-
- var user core.User
- err := s.db.QueryRowContext(ctx, query, email).Scan(
- &user.ID, &user.Email, &user.PasswordHash, &user.CreatedAt, &user.UpdatedAt,
- )
-
- if err == sql.ErrNoRows {
- return nil, core.ErrUserNotFound
- }
- if err != nil {
- return nil, fmt.Errorf("failed to get user: %w", err)
- }
-
- return &user, nil
-}
-
-func (s *UserStore) GetByID(ctx context.Context, id string) (*core.User, error) {
- query := `SELECT id, email, password_hash, created_at, updated_at FROM users WHERE id = ?`
-
- var user core.User
- err := s.db.QueryRowContext(ctx, query, id).Scan(
- &user.ID, &user.Email, &user.PasswordHash, &user.CreatedAt, &user.UpdatedAt,
- )
-
- if err == sql.ErrNoRows {
- return nil, core.ErrUserNotFound
- }
- if err != nil {
- return nil, fmt.Errorf("failed to get user: %w", err)
- }
-
- return &user, nil
-}
-
-func (s *UserStore) UpdateEmail(ctx context.Context, id, newEmail string) error {
- query := `UPDATE users SET email = ?, updated_at = ? WHERE id = ?`
-
- result, err := s.db.ExecContext(ctx, query, newEmail, time.Now(), id)
- if err != nil {
- return fmt.Errorf("failed to update email: %w", err)
- }
-
- rows, _ := result.RowsAffected()
- if rows == 0 {
- return core.ErrUserNotFound
- }
-
- return nil
-}
-
-func (s *UserStore) UpdatePassword(ctx context.Context, id, newPasswordHash string) error {
- query := `UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?`
-
- result, err := s.db.ExecContext(ctx, query, newPasswordHash, time.Now(), id)
- if err != nil {
- return fmt.Errorf("failed to update password: %w", err)
- }
-
- rows, _ := result.RowsAffected()
- if rows == 0 {
- return core.ErrUserNotFound
- }
-
- return nil
-}
-
-func (s *UserStore) Delete(ctx context.Context, id string) error {
- query := `DELETE FROM users WHERE id = ?`
-
- result, err := s.db.ExecContext(ctx, query, id)
- if err != nil {
- return fmt.Errorf("failed to delete user: %w", err)
- }
-
- rows, _ := result.RowsAffected()
- if rows == 0 {
- return core.ErrUserNotFound
- }
-
- return nil
-}
-
-// GetDB returns the underlying database connection
-func (s *UserStore) GetDB() *sql.DB {
- return s.db
-}
-
-func (s *UserStore) Close() error {
- return s.db.Close()
-}
diff --git a/internal/tests/audit_file_test.go b/internal/tests/audit_file_test.go
deleted file mode 100644
index ab92802..0000000
--- a/internal/tests/audit_file_test.go
+++ /dev/null
@@ -1,232 +0,0 @@
-package tests
-
-import (
- "context"
- "encoding/json"
- "os"
- "path/filepath"
- "testing"
- "time"
-
- "github.com/crydensync/cryden/internal/core"
- "github.com/crydensync/cryden/internal/stores/memory"
-)
-
-func TestFileAuditLogger(t *testing.T) {
- // Create temp directory for test logs
- tempDir := t.TempDir()
- logPath := filepath.Join(tempDir, "audit.log")
-
- // Create file logger
- logger, err := core.NewFileAuditLogger(logPath)
- if err != nil {
- t.Fatalf("Failed to create file logger: %v", err)
- }
- defer logger.Close()
-
- ctx := context.Background()
-
- // Log an entry
- entry := core.AuditEntry{
- Timestamp: time.Now(),
- UserID: "user_123",
- Action: core.ActionSignInSuccess,
- Status: "SUCCESS",
- IPAddress: "192.168.1.1",
- Metadata: map[string]interface{}{
- "user_agent": "Mozilla/5.0",
- },
- }
-
- if err := logger.Log(ctx, entry); err != nil {
- t.Fatalf("Failed to log entry: %v", err)
- }
-
- // Read the log file
- data, err := os.ReadFile(logPath)
- if err != nil {
- t.Fatalf("Failed to read log file: %v", err)
- }
-
- // Parse JSON
- var loggedEntry core.AuditEntry
- if err := json.Unmarshal(data, &loggedEntry); err != nil {
- t.Fatalf("Failed to parse JSON: %v", err)
- }
-
- // Verify fields
- if loggedEntry.UserID != entry.UserID {
- t.Errorf("Expected UserID %s, got %s", entry.UserID, loggedEntry.UserID)
- }
- if loggedEntry.Action != entry.Action {
- t.Errorf("Expected Action %s, got %s", entry.Action, loggedEntry.Action)
- }
- if loggedEntry.IPAddress != entry.IPAddress {
- t.Errorf("Expected IPAddress %s, got %s", entry.IPAddress, loggedEntry.IPAddress)
- }
- if loggedEntry.Status != entry.Status {
- t.Errorf("Expected Status %s, got %s", entry.Status, loggedEntry.Status)
- }
-}
-
-func TestFileAuditLoggerWithEngine(t *testing.T) {
- // Create temp directory
- tempDir := t.TempDir()
- logPath := filepath.Join(tempDir, "auth.log")
-
- // Create file logger
- logger, err := core.NewFileAuditLogger(logPath)
- if err != nil {
- t.Fatalf("Failed to create logger: %v", err)
- }
- defer logger.Close()
-
- // Create engine with file logger
- userStore := memory.NewUserStore()
- sessionStore := memory.NewSessionStore()
- engine := core.New(userStore, sessionStore)
- engine.WithAuditLogger(logger)
-
- ctx := context.Background()
-
- // Perform signup (should log)
- _, err = engine.SignUp(ctx, "test@example.com", "Password123")
- if err != nil {
- t.Fatalf("SignUp failed: %v", err)
- }
-
- // Check log file was written
- data, err := os.ReadFile(logPath)
- if err != nil {
- t.Fatalf("Failed to read log file: %v", err)
- }
-
- if len(data) == 0 {
- t.Error("Log file is empty")
- }
-
- // Verify JSON format
- var entry core.AuditEntry
- if err := json.Unmarshal(data, &entry); err != nil {
- t.Errorf("Invalid JSON format: %v", err)
- }
-
- // Verify it's a signup event
- if entry.Action != core.ActionSignUp {
- t.Errorf("Expected Action SIGN_UP, got %s", entry.Action)
- }
-}
-
-func TestFileAuditLoggerRotation(t *testing.T) {
- tempDir := t.TempDir()
- logPath := filepath.Join(tempDir, "rotate.log")
-
- logger, err := core.NewFileAuditLogger(logPath)
- if err != nil {
- t.Fatalf("Failed to create logger: %v", err)
- }
- defer logger.Close()
-
- ctx := context.Background()
-
- // Write first entry
- err = logger.Log(ctx, core.AuditEntry{
- UserID: "user1",
- Action: "TEST_ENTRY_1",
- Status: "SUCCESS",
- })
- if err != nil {
- t.Fatalf("Failed to write first entry: %v", err)
- }
-
- // Rotate the log file
- if err := logger.Rotate(); err != nil {
- t.Fatalf("Rotation failed: %v", err)
- }
-
- // Write second entry after rotation
- err = logger.Log(ctx, core.AuditEntry{
- UserID: "user2",
- Action: "TEST_ENTRY_2",
- Status: "SUCCESS",
- })
- if err != nil {
- t.Fatalf("Failed to write second entry: %v", err)
- }
-
- // Read the log file
- data, err := os.ReadFile(logPath)
- if err != nil {
- t.Fatalf("Failed to read log file: %v", err)
- }
-
- // Should have two JSON lines (one per line)
- lines := 0
- for _, b := range data {
- if b == '\n' {
- lines++
- }
- }
-
- if lines < 2 {
- t.Errorf("Expected at least 2 log entries, got %d", lines)
- }
-}
-
-func TestFileAuditLoggerDirectoryCreation(t *testing.T) {
- tempDir := t.TempDir()
- // Create a nested path that doesn't exist yet
- logPath := filepath.Join(tempDir, "nested", "deep", "path", "audit.log")
-
- logger, err := core.NewFileAuditLogger(logPath)
- if err != nil {
- t.Fatalf("Failed to create logger with nested directory: %v", err)
- }
- defer logger.Close()
-
- // Check that directory was created
- if _, err := os.Stat(filepath.Dir(logPath)); os.IsNotExist(err) {
- t.Error("Directory was not created")
- }
-
- // Check that file exists
- if _, err := os.Stat(logPath); os.IsNotExist(err) {
- t.Error("Log file was not created")
- }
-}
-
-func TestMultipleFileLoggers(t *testing.T) {
- tempDir := t.TempDir()
- logPath1 := filepath.Join(tempDir, "log1.log")
- logPath2 := filepath.Join(tempDir, "log2.log")
-
- // Create two separate loggers
- logger1, err := core.NewFileAuditLogger(logPath1)
- if err != nil {
- t.Fatalf("Failed to create logger1: %v", err)
- }
- defer logger1.Close()
-
- logger2, err := core.NewFileAuditLogger(logPath2)
- if err != nil {
- t.Fatalf("Failed to create logger2: %v", err)
- }
- defer logger2.Close()
-
- ctx := context.Background()
-
- // Log to both
- logger1.Log(ctx, core.AuditEntry{UserID: "user1", Action: "LOG1"})
- logger2.Log(ctx, core.AuditEntry{UserID: "user2", Action: "LOG2"})
-
- // Verify both files have content
- data1, _ := os.ReadFile(logPath1)
- data2, _ := os.ReadFile(logPath2)
-
- if len(data1) == 0 {
- t.Error("Logger1 file is empty")
- }
- if len(data2) == 0 {
- t.Error("Logger2 file is empty")
- }
-}
diff --git a/internal/tests/audit_test.go b/internal/tests/audit_test.go
deleted file mode 100644
index ca7a14c..0000000
--- a/internal/tests/audit_test.go
+++ /dev/null
@@ -1,69 +0,0 @@
-package tests
-
-import (
- "context"
- "testing"
- "time"
-
- "github.com/crydensync/cryden/internal/core"
-)
-
-func TestConsoleAuditLogger(t *testing.T) {
- logger := core.NewConsoleAuditLogger()
- ctx := context.Background()
-
- t.Run("log entry", func(t *testing.T) {
- entry := core.AuditEntry{
- Timestamp: time.Now(),
- UserID: "user_123",
- Action: core.ActionSignInSuccess,
- Status: "SUCCESS",
- IPAddress: "192.168.1.1",
- UserAgent: "test-agent",
- }
-
- err := logger.Log(ctx, entry)
- if err != nil {
- t.Errorf("Expected no error, got %v", err)
- }
- })
-
- t.Run("log failed attempt", func(t *testing.T) {
- entry := core.AuditEntry{
- Timestamp: time.Now(),
- UserID: "user_123",
- Action: core.ActionSignInFailed,
- Status: "FAILED",
- Error: "wrong password",
- IPAddress: "192.168.1.1",
- }
-
- err := logger.Log(ctx, entry)
- if err != nil {
- t.Errorf("Expected no error, got %v", err)
- }
- })
-
- logger.Close()
-}
-
-func TestNoopAuditLogger(t *testing.T) {
- logger := core.NewNoopAuditLogger()
- ctx := context.Background()
-
- t.Run("log does nothing", func(t *testing.T) {
- entry := core.AuditEntry{
- Timestamp: time.Now(),
- UserID: "user_123",
- Action: core.ActionSignUp,
- Status: "SUCCESS",
- }
-
- err := logger.Log(ctx, entry)
- if err != nil {
- t.Errorf("Expected no error, got %v", err)
- }
- })
-
- logger.Close()
-}
diff --git a/internal/tests/device_test.go b/internal/tests/device_test.go
deleted file mode 100644
index 6c4f3a0..0000000
--- a/internal/tests/device_test.go
+++ /dev/null
@@ -1,122 +0,0 @@
-package tests
-
-import (
- "context"
- "testing"
-
- "github.com/crydensync/cryden/internal/core"
- "github.com/crydensync/cryden/internal/stores/memory"
-)
-
-func TestParseUserAgent(t *testing.T) {
- tests := []struct {
- name string
- userAgent string
- expected core.DeviceInfo
- }{
- {
- name: "Chrome on Windows",
- userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0",
- expected: core.DeviceInfo{
- DeviceName: "Chrome on Windows 10",
- DeviceType: "desktop",
- Browser: "Chrome",
- OS: "Windows 10",
- },
- },
- {
- name: "Safari on iPhone",
- userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1",
- expected: core.DeviceInfo{
- DeviceName: "Safari on iOS",
- DeviceType: "mobile",
- Browser: "Safari",
- OS: "iOS",
- },
- },
- {
- name: "Firefox on Mac",
- userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/119.0",
- expected: core.DeviceInfo{
- DeviceName: "Firefox on macOS",
- DeviceType: "desktop",
- Browser: "Firefox",
- OS: "macOS",
- },
- },
- {
- name: "Empty user agent",
- userAgent: "",
- expected: core.DeviceInfo{
- DeviceName: "Unknown",
- DeviceType: "unknown",
- Browser: "Unknown",
- OS: "Unknown",
- },
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := core.ParseUserAgent(tt.userAgent)
-
- if result.DeviceName != tt.expected.DeviceName {
- t.Errorf("DeviceName: expected %s, got %s", tt.expected.DeviceName, result.DeviceName)
- }
- if result.DeviceType != tt.expected.DeviceType {
- t.Errorf("DeviceType: expected %s, got %s", tt.expected.DeviceType, result.DeviceType)
- }
- if result.Browser != tt.expected.Browser {
- t.Errorf("Browser: expected %s, got %s", tt.expected.Browser, result.Browser)
- }
- if result.OS != tt.expected.OS {
- t.Errorf("OS: expected %s, got %s", tt.expected.OS, result.OS)
- }
- })
- }
-}
-
-func TestDeviceTrackingInSession(t *testing.T) {
- userStore := memory.NewUserStore()
- sessionStore := memory.NewSessionStore()
- engine := core.New(userStore, sessionStore)
-
- ctx := context.Background()
-
- // Sign up
- _, err := engine.SignUp(ctx, "device@example.com", "Password123")
- if err != nil {
- t.Fatalf("SignUp failed: %v", err)
- }
-
- // Login with device info
- userAgent := "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 Safari/604.1"
- deviceInfo := core.ParseUserAgent(userAgent)
- ipAddress := "192.168.1.100"
-
- tokens, _, err := engine.Login(ctx, "device@example.com", "Password123", deviceInfo, ipAddress)
- if err != nil {
- t.Fatalf("Login failed: %v", err)
- }
-
- // List sessions
- sessions, err := engine.ListSessions(ctx, "device@example.com")
- if err != nil {
- t.Fatalf("ListSessions failed: %v", err)
- }
-
- if len(sessions) == 0 {
- t.Fatal("No sessions found")
- }
-
- session := sessions[0]
- if session.DeviceName != "Safari on iOS" {
- t.Errorf("Expected device name 'Safari on iOS', got '%s'", session.DeviceName)
- }
- if session.DeviceType != "mobile" {
- t.Errorf("Expected device type 'mobile', got '%s'", session.DeviceType)
- }
- if session.IPAddress != ipAddress {
- t.Errorf("Expected IP %s, got %s", ipAddress, session.IPAddress)
- }
-}
diff --git a/internal/tests/engine_test.go b/internal/tests/engine_test.go
deleted file mode 100644
index 7f808a9..0000000
--- a/internal/tests/engine_test.go
+++ /dev/null
@@ -1,482 +0,0 @@
-// tests/engine_test.go
-package tests
-
-import (
- "context"
- "testing"
- "time"
-
- "github.com/crydensync/cryden/internal/core"
- "github.com/crydensync/cryden/internal/stores/memory"
-)
-
-func TestSignUp(t *testing.T) {
- // Setup
- userStore := memory.NewUserStore()
- sessionStore := memory.NewSessionStore()
- engine := core.New(userStore, sessionStore)
- ctx := context.Background()
-
- t.Run("valid signup", func(t *testing.T) {
- user, err := engine.SignUp(ctx, "test@example.com", "Password123")
-
- if err != nil {
- t.Errorf("Expected no error, got %v", err)
- }
-
- if user == nil {
- t.Fatal("Expected user, got nil")
- }
-
- if user.Email != "test@example.com" {
- t.Errorf("Expected email test@example.com, got %s", user.Email)
- }
-
- if user.ID == "" {
- t.Error("Expected user ID to be set")
- }
- })
-
- t.Run("duplicate email", func(t *testing.T) {
- // First signup
- _, err := engine.SignUp(ctx, "duplicate@example.com", "Password123")
- if err != nil {
- t.Fatalf("First signup failed: %v", err)
- }
-
- // Second signup with same email
- _, err = engine.SignUp(ctx, "duplicate@example.com", "Password123")
- if err != core.ErrUserExists {
- t.Errorf("Expected ErrUserExists, got %v", err)
- }
- })
-
- t.Run("invalid email", func(t *testing.T) {
- testCases := []struct {
- email string
- password string
- wantErr error
- }{
- {"", "Password123", core.ErrInvalidEmail},
- {"notanemail", "Password123", core.ErrInvalidEmail},
- {"@domain.com", "Password123", core.ErrInvalidEmail},
- {"user@", "Password123", core.ErrInvalidEmail},
- {"user@domain", "Password123", core.ErrInvalidEmail},
- }
-
- for _, tc := range testCases {
- _, err := engine.SignUp(ctx, tc.email, tc.password)
- if err == nil {
- t.Errorf("Expected error for email %q, got nil", tc.email)
- continue
- }
-
- // Check if it's a ValidationError
- if verr, ok := err.(*core.ValidationError); ok {
- if verr.Field != "email" {
- t.Errorf("Expected field 'email', got %q", verr.Field)
- }
- } else {
- t.Errorf("Expected ValidationError, got %T", err)
- }
- }
- })
-
- t.Run("invalid password", func(t *testing.T) {
- testCases := []struct {
- password string
- wantErr error
- }{
- {"short", core.ErrPasswordTooShort},
- {"onlylowercase", core.ErrPasswordNoUpper},
- {"ONLYUPPERCASE", core.ErrPasswordNoLower},
- {"NoNumbers", core.ErrPasswordNoNumber},
- }
-
- for _, tc := range testCases {
- _, err := engine.SignUp(ctx, "test@example.com", tc.password)
- if err == nil {
- t.Errorf("Expected error for password %q, got nil", tc.password)
- continue
- }
-
- if verr, ok := err.(*core.ValidationError); ok {
- if verr.Field != "password" {
- t.Errorf("Expected field 'password', got %q", verr.Field)
- }
- }
- }
- })
-}
-
-func TestLogin(t *testing.T) {
- // Setup
- userStore := memory.NewUserStore()
- sessionStore := memory.NewSessionStore()
- engine := core.New(userStore, sessionStore)
- ctx := context.Background()
-
- // Create a test user
- _, err := engine.SignUp(ctx, "login@example.com", "Password123")
- if err != nil {
- t.Fatalf("Failed to create test user: %v", err)
- }
-
- t.Run("valid login", func(t *testing.T) {
- tokens, _, err := engine.Login(ctx, "login@example.com", "Password123")
-
- if err != nil {
- t.Errorf("Expected no error, got %v", err)
- }
-
- if tokens == nil {
- t.Fatal("Expected tokens, got nil")
- }
-
- if tokens.AccessToken == "" {
- t.Error("Expected access token, got empty")
- }
-
- if tokens.RefreshToken == "" {
- t.Error("Expected refresh token, got empty")
- }
- })
-
- t.Run("wrong password", func(t *testing.T) {
- _, _, err := engine.Login(ctx, "login@example.com", "wrongpassword")
-
- if err != core.ErrInvalidCredentials {
- t.Errorf("Expected ErrInvalidCredentials, got %v", err)
- }
- })
-
- t.Run("nonexistent user", func(t *testing.T) {
- _, _, err := engine.Login(ctx, "nonexistent@example.com", "Password123")
-
- if err != core.ErrInvalidCredentials {
- t.Errorf("Expected ErrInvalidCredentials, got %v", err)
- }
- })
-}
-
-func TestLoginRateLimiting(t *testing.T) {
- // Setup
- userStore := memory.NewUserStore()
- sessionStore := memory.NewSessionStore()
- ctx := context.Background()
-
- // Create engine with strict rate limit (2 per minute)
- engine := core.New(userStore, sessionStore)
- limiter := core.NewMemoryRateLimiter(2, time.Minute)
- engine.WithRateLimiter(limiter)
-
- // Create a test user
- _, err := engine.SignUp(ctx, "ratelimit@example.com", "Password123")
- if err != nil {
- t.Fatalf("Failed to create user: %v", err)
- }
-
- t.Run("first two attempts allowed", func(t *testing.T) {
- // First attempt
- _, result, err := engine.Login(ctx, "ratelimit@example.com", "Password123")
- if err != nil {
- t.Errorf("First login failed: %v", err)
- }
- if result.Remaining != 1 {
- t.Errorf("Expected remaining 1, got %d", result.Remaining)
- }
-
- // Second attempt
- _, result, err = engine.Login(ctx, "ratelimit@example.com", "Password123")
- if err != nil {
- t.Errorf("Second login failed: %v", err)
- }
- if result.Remaining != 0 {
- t.Errorf("Expected remaining 0, got %d", result.Remaining)
- }
- })
-
- t.Run("third attempt blocked", func(t *testing.T) {
- _, result, err := engine.Login(ctx, "ratelimit@example.com", "Password123")
- if err != core.ErrTooManyAttempts {
- t.Errorf("Expected ErrTooManyAttempts, got %v", err)
- }
- if result.Allowed {
- t.Error("Expected not allowed")
- }
- })
-}
-
-func TestLogout(t *testing.T) {
- userStore := memory.NewUserStore()
- sessionStore := memory.NewSessionStore()
- engine := core.New(userStore, sessionStore)
- ctx := context.Background()
-
- engine.SignUp(ctx, "logout@example.com", "Password123")
- tokens, _, _ := engine.Login(ctx, "logout@example.com", "Password123")
-
- t.Run("valid logout", func(t *testing.T) {
- err := engine.Logout(ctx, tokens.RefreshToken)
- if err != nil {
- t.Errorf("Logout failed: %v", err)
- }
-
- _, err = engine.GetSessionStore().GetByRefreshToken(ctx, tokens.RefreshToken)
- if err != core.ErrSessionNotFound {
- t.Errorf("Expected session not found, got %v", err)
- }
- })
-
- t.Run("invalid token", func(t *testing.T) {
- err := engine.Logout(ctx, "invalid")
- if err != core.ErrInvalidToken {
- t.Errorf("Expected ErrInvalidToken, got %v", err)
- }
- })
-}
-
-func TestLogoutAll(t *testing.T) {
- userStore := memory.NewUserStore()
- sessionStore := memory.NewSessionStore()
- engine := core.New(userStore, sessionStore)
- ctx := context.Background()
-
- engine.SignUp(ctx, "logoutall@example.com", "Password123")
-
- // Create multiple sessions
- tokens1, _, _ := engine.Login(ctx, "logoutall@example.com", "Password123")
- tokens2, _, _ := engine.Login(ctx, "logoutall@example.com", "Password123")
- tokens3, _, _ := engine.Login(ctx, "logoutall@example.com", "Password123")
-
- t.Run("logout all devices", func(t *testing.T) {
- user, _ := engine.GetUserStore().GetByEmail(ctx, "logoutall@example.com")
- err := engine.LogoutAll(ctx, user.ID)
- if err != nil {
- t.Errorf("LogoutAll failed: %v", err)
- }
-
- // All sessions should be gone
- _, err = engine.GetSessionStore().GetByRefreshToken(ctx, tokens1.RefreshToken)
- if err != core.ErrSessionNotFound {
- t.Error("First session still exists")
- }
- _, err = engine.GetSessionStore().GetByRefreshToken(ctx, tokens2.RefreshToken)
- if err != core.ErrSessionNotFound {
- t.Error("Second session still exists")
- }
- _, err = engine.GetSessionStore().GetByRefreshToken(ctx, tokens3.RefreshToken)
- if err != core.ErrSessionNotFound {
- t.Error("Third session still exists")
- }
- })
-}
-
-func TestChangePassword(t *testing.T) {
- userStore := memory.NewUserStore()
- sessionStore := memory.NewSessionStore()
- engine := core.New(userStore, sessionStore)
- ctx := context.Background()
-
- // Create user and login
- engine.SignUp(ctx, "pass@example.com", "OldPass123")
- user, _ := engine.GetUserStore().GetByEmail(ctx, "pass@example.com")
- tokens, _, _ := engine.Login(ctx, "pass@example.com", "OldPass123")
-
- t.Run("successful password change", func(t *testing.T) {
- err := engine.ChangePassword(ctx, user.ID, "OldPass123", "NewPass456")
- if err != nil {
- t.Errorf("ChangePassword failed: %v", err)
- }
-
- // Old password should not work
- _, _, err = engine.Login(ctx, "pass@example.com", "OldPass123")
- if err != core.ErrInvalidCredentials {
- t.Error("Old password still works")
- }
-
- // New password should work
- _, _, err = engine.Login(ctx, "pass@example.com", "NewPass456")
- if err != nil {
- t.Error("New password doesn't work")
- }
-
- // Old session should be revoked
- _, err = engine.GetSessionStore().GetByRefreshToken(ctx, tokens.RefreshToken)
- if err != core.ErrSessionNotFound {
- t.Error("Old session still exists")
- }
- })
-
- t.Run("wrong old password", func(t *testing.T) {
- err := engine.ChangePassword(ctx, user.ID, "WrongPass", "NewPass456")
- if err != core.ErrInvalidCredentials {
- t.Errorf("Expected ErrInvalidCredentials, got %v", err)
- }
- })
-
- t.Run("invalid new password", func(t *testing.T) {
- err := engine.ChangePassword(ctx, user.ID, "NewPass456", "short")
- if err == nil {
- t.Error("Expected validation error, got nil")
- }
- })
-}
-
-func TestChangeEmail(t *testing.T) {
- userStore := memory.NewUserStore()
- sessionStore := memory.NewSessionStore()
- engine := core.New(userStore, sessionStore)
- ctx := context.Background()
-
- // Create user
- engine.SignUp(ctx, "old@example.com", "Password123")
- user, _ := engine.GetUserStore().GetByEmail(ctx, "old@example.com")
-
- t.Run("successful email change", func(t *testing.T) {
- err := engine.ChangeEmail(ctx, user.ID, "new@example.com")
- if err != nil {
- t.Errorf("ChangeEmail failed: %v", err)
- }
-
- // Old email should not work
- _, err = engine.GetUserStore().GetByEmail(ctx, "old@example.com")
- if err != core.ErrUserNotFound {
- t.Error("Old email still exists")
- }
-
- // New email should work
- found, err := engine.GetUserStore().GetByEmail(ctx, "new@example.com")
- if err != nil {
- t.Error("New email not found")
- }
- if found.ID != user.ID {
- t.Error("Wrong user returned")
- }
- })
-
- t.Run("duplicate email", func(t *testing.T) {
- engine.SignUp(ctx, "duplicate@example.com", "Password123")
- duplicate, _ := engine.GetUserStore().GetByEmail(ctx, "duplicate@example.com")
-
- err := engine.ChangeEmail(ctx, duplicate.ID, "new@example.com")
- if err != core.ErrUserExists {
- t.Errorf("Expected ErrUserExists, got %v", err)
- }
- })
-
- t.Run("invalid email", func(t *testing.T) {
- err := engine.ChangeEmail(ctx, user.ID, "notanemail")
- if err == nil {
- t.Error("Expected validation error, got nil")
- }
- })
-}
-
-func TestDeleteAccount(t *testing.T) {
- userStore := memory.NewUserStore()
- sessionStore := memory.NewSessionStore()
- engine := core.New(userStore, sessionStore)
- ctx := context.Background()
-
- // Create user and sessions
- engine.SignUp(ctx, "delete@example.com", "Password123")
- user, _ := engine.GetUserStore().GetByEmail(ctx, "delete@example.com")
-
- // Create multiple sessions
- tokens1, _, _ := engine.Login(ctx, "delete@example.com", "Password123")
- tokens2, _, _ := engine.Login(ctx, "delete@example.com", "Password123")
-
- t.Run("delete account", func(t *testing.T) {
- err := engine.DeleteAccount(ctx, user.ID)
- if err != nil {
- t.Errorf("DeleteAccount failed: %v", err)
- }
-
- // User should be gone
- _, err = engine.GetUserStore().GetByEmail(ctx, "delete@example.com")
- if err != core.ErrUserNotFound {
- t.Error("User still exists")
- }
-
- // Sessions should be gone
- _, err = engine.GetSessionStore().GetByRefreshToken(ctx, tokens1.RefreshToken)
- if err != core.ErrSessionNotFound {
- t.Error("First session still exists")
- }
- _, err = engine.GetSessionStore().GetByRefreshToken(ctx, tokens2.RefreshToken)
- if err != core.ErrSessionNotFound {
- t.Error("Second session still exists")
- }
- })
-
- t.Run("delete nonexistent user", func(t *testing.T) {
- err := engine.DeleteAccount(ctx, "nonexistent")
- if err != core.ErrUserNotFound {
- t.Errorf("Expected ErrUserNotFound, got %v", err)
- }
- })
-}
-
-func TestRefreshToken(t *testing.T) {
- userStore := memory.NewUserStore()
- sessionStore := memory.NewSessionStore()
- engine := core.New(userStore, sessionStore)
- ctx := context.Background()
-
- // Create user and login
- engine.SignUp(ctx, "refresh@example.com", "Password123")
- tokens, _, _ := engine.Login(ctx, "refresh@example.com", "Password123")
-
- t.Run("successful refresh", func(t *testing.T) {
- newTokens, err := engine.RefreshToken(ctx, tokens.RefreshToken)
- if err != nil {
- t.Errorf("Refresh failed: %v", err)
- }
-
- if newTokens.AccessToken == tokens.AccessToken {
- t.Error("Access token should be new")
- }
- if newTokens.RefreshToken == tokens.RefreshToken {
- t.Error("Refresh token should be new")
- }
-
- // Old refresh token should be revoked
- _, err = engine.GetSessionStore().GetByRefreshToken(ctx, tokens.RefreshToken)
- if err != core.ErrSessionNotFound {
- t.Error("Old session still exists")
- }
-
- // New refresh token should work
- _, err = engine.GetSessionByRefreshToken(ctx, newTokens.RefreshToken)
- if err != nil {
- t.Error("New session not found")
- }
- })
-
- t.Run("refresh with invalid token", func(t *testing.T) {
- _, err := engine.RefreshToken(ctx, "invalid")
- if err != core.ErrInvalidToken {
- t.Errorf("Expected ErrInvalidToken, got %v", err)
- }
- })
-
- t.Run("refresh with already used token", func(t *testing.T) {
- // First refresh - works
- newTokens, _ := engine.RefreshToken(ctx, tokens.RefreshToken)
-
- // Second refresh with same token - should fail (already revoked)
- _, err := engine.RefreshToken(ctx, tokens.RefreshToken)
- if err != core.ErrInvalidToken {
- t.Errorf("Expected ErrInvalidToken for used token, got %v", err)
- }
-
- if newTokens != nil {
- // New token should still work
- _, err = engine.RefreshToken(ctx, newTokens.RefreshToken)
- if err != nil {
- t.Error("New token should work")
- }
- }
- })
-}
diff --git a/internal/tests/hasher_test.go b/internal/tests/hasher_test.go
deleted file mode 100644
index 8f2ca73..0000000
--- a/internal/tests/hasher_test.go
+++ /dev/null
@@ -1,51 +0,0 @@
-package tests
-
-import (
- "github.com/crydensync/cryden/internal/core"
- "testing"
-)
-
-func TestBcryptHasher(t *testing.T) {
- hasher := core.NewBcryptHasher(4)
-
- t.Run("hash and compare", func(t *testing.T) {
- password := "Test123"
-
- hash, err := hasher.Hash(password)
- if err != nil {
- t.Fatalf("Hash failed: %v", err)
- }
-
- err = hasher.Compare(password, hash)
- if err != nil {
- t.Errorf("Compare failed: %v", err)
- }
- })
-
- t.Run("wrong password fails", func(t *testing.T) {
- hash, _ := hasher.Hash("correct")
-
- err := hasher.Compare("wrong", hash)
- if err == nil {
- t.Error("Expected error, got nil")
- }
- })
-}
-
-func TestMockHasher(t *testing.T) {
- hasher := &core.MockHasher{}
-
- t.Run("mock hash returns password", func(t *testing.T) {
- hash, _ := hasher.Hash("test")
- if hash != "test" {
- t.Errorf("Expected 'test', got '%s'", hash)
- }
- })
-
- t.Run("mock compare works", func(t *testing.T) {
- err := hasher.Compare("test", "test")
- if err != nil {
- t.Errorf("Compare failed: %v", err)
- }
- })
-}
diff --git a/internal/tests/jwt_test.go b/internal/tests/jwt_test.go
deleted file mode 100644
index 4966cfe..0000000
--- a/internal/tests/jwt_test.go
+++ /dev/null
@@ -1,43 +0,0 @@
-package tests
-
-import (
- "github.com/crydensync/cryden/internal/core"
- "github.com/golang-jwt/jwt/v5"
- "testing"
- "time"
-)
-
-func TestJWTTokens(t *testing.T) {
- engine := core.New(nil, nil)
- engine.WithJWTSecret("test-secret")
-
- t.Run("generate and verify token", func(t *testing.T) {
- // Since generateTokens needs a session store, we'll test the JWT part separately
- userID := "user_123"
-
- // Manually create claims
- claims := core.Claims{
- UserID: userID,
- RegisteredClaims: jwt.RegisteredClaims{
- ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
- Issuer: "cryden",
- },
- }
-
- token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
- tokenString, err := token.SignedString([]byte("test-secret"))
- if err != nil {
- t.Fatalf("Failed to sign token: %v", err)
- }
-
- // Verify
- verifiedClaims, err := engine.VerifyToken(tokenString)
- if err != nil {
- t.Fatalf("Failed to verify token: %v", err)
- }
-
- if verifiedClaims.UserID != userID {
- t.Errorf("Expected userID %s, got %s", userID, verifiedClaims.UserID)
- }
- })
-}
diff --git a/internal/tests/rate_limiter_test.go b/internal/tests/rate_limiter_test.go
deleted file mode 100644
index 8eaf832..0000000
--- a/internal/tests/rate_limiter_test.go
+++ /dev/null
@@ -1,102 +0,0 @@
-package tests
-
-import (
- "context"
- "testing"
- "time"
-
- "github.com/crydensync/cryden/internal/core"
-)
-
-func TestMemoryRateLimiter(t *testing.T) {
- limiter := core.NewMemoryRateLimiter(3, time.Second)
- ctx := context.Background()
- key := "test-ip-123"
-
- t.Run("allows within limit", func(t *testing.T) {
- // First 3 attempts should be allowed
- for i := 0; i < 3; i++ {
- result, err := limiter.Allow(ctx, key)
- if err != nil {
- t.Fatalf("Allow failed: %v", err)
- }
- if !result.Allowed {
- t.Errorf("Attempt %d should be allowed", i+1)
- }
- if result.Remaining != 3-(i+1) {
- t.Errorf("Expected remaining %d, got %d", 3-(i+1), result.Remaining)
- }
- }
- })
-
- t.Run("blocks after limit", func(t *testing.T) {
- // 4th attempt should be blocked
- result, err := limiter.Allow(ctx, key)
- if err != nil {
- t.Fatalf("Allow failed: %v", err)
- }
- if result.Allowed {
- t.Error("Attempt should be blocked")
- }
- if result.Remaining != 0 {
- t.Errorf("Expected remaining 0, got %d", result.Remaining)
- }
- if result.Reset <= 0 {
- t.Error("Expected positive reset time")
- }
- })
-
- t.Run("reset works", func(t *testing.T) {
- err := limiter.Reset(ctx, key)
- if err != nil {
- t.Fatalf("Reset failed: %v", err)
- }
-
- result, err := limiter.Allow(ctx, key)
- if err != nil {
- t.Fatalf("Allow failed: %v", err)
- }
- if !result.Allowed {
- t.Error("After reset, should be allowed")
- }
- })
-
- t.Run("different keys are independent", func(t *testing.T) {
- key1 := "ip-1"
- key2 := "ip-2"
-
- // Use up key1
- for i := 0; i < 3; i++ {
- limiter.Allow(ctx, key1)
- }
-
- // key1 should be blocked
- result1, _ := limiter.Allow(ctx, key1)
- if result1.Allowed {
- t.Error("key1 should be blocked")
- }
-
- // key2 should still work
- result2, _ := limiter.Allow(ctx, key2)
- if !result2.Allowed {
- t.Error("key2 should be allowed")
- }
- })
-}
-
-func TestNoopRateLimiter(t *testing.T) {
- limiter := &core.NoopRateLimiter{}
- ctx := context.Background()
-
- t.Run("always allows", func(t *testing.T) {
- for i := 0; i < 100; i++ {
- result, err := limiter.Allow(ctx, "any-key")
- if err != nil {
- t.Fatalf("Allow failed: %v", err)
- }
- if !result.Allowed {
- t.Error("Noop should always allow")
- }
- }
- })
-}
diff --git a/internal/tests/store_test.go b/internal/tests/store_test.go
deleted file mode 100644
index 3fc21cf..0000000
--- a/internal/tests/store_test.go
+++ /dev/null
@@ -1,204 +0,0 @@
-package tests
-
-import (
- "context"
- "os"
- "testing"
-
- "github.com/crydensync/cryden/internal/core"
- "github.com/crydensync/cryden/internal/stores/memory"
- "github.com/crydensync/cryden/internal/stores/mongodb"
- "github.com/crydensync/cryden/internal/stores/postgres"
- "github.com/crydensync/cryden/internal/stores/sqlite"
-)
-
-// TestUserStore runs the same tests for ALL implementations
-func TestUserStore(t *testing.T) {
- // Run tests for each store implementation
- t.Run("Memory", func(t *testing.T) {
- store := memory.NewUserStore()
- defer store.Close()
- testUserStore(t, store)
- })
-
- t.Run("SQLite", func(t *testing.T) {
- dbPath := "test_sqlite.db"
- defer os.Remove(dbPath)
-
- store, err := sqlite.NewUserStore(dbPath)
- if err != nil {
- t.Fatalf("Failed to create SQLite store: %v", err)
- }
- defer store.Close()
-
- testUserStore(t, store)
- })
-
- t.Run("PostgreSQL", func(t *testing.T) {
- // Use environment variable for connection string
- connStr := os.Getenv("TEST_POSTGRES_URI")
- if connStr == "" {
- t.Skip("Skipping PostgreSQL test: TEST_POSTGRES_URI not set")
- }
-
- store, err := postgres.NewUserStore(connStr)
- if err != nil {
- t.Fatalf("Failed to create PostgreSQL store: %v", err)
- }
- defer store.Close()
-
- testUserStore(t, store)
- })
-
- t.Run("MongoDB", func(t *testing.T) {
- // Use environment variable for connection string
- uri := os.Getenv("TEST_MONGODB_URI")
- if uri == "" {
- t.Skip("Skipping MongoDB test: TEST_MONGODB_URI not set")
- }
-
- store, err := mongodb.NewUserStore(uri, "test_db")
- if err != nil {
- t.Fatalf("Failed to create MongoDB store: %v", err)
- }
- defer store.Close()
-
- testUserStore(t, store)
- })
-}
-
-// testUserStore contains the actual tests
-func testUserStore(t *testing.T, store core.UserStore) {
- ctx := context.Background()
-
- t.Run("Create and Get user", func(t *testing.T) {
- // Create user
- user, err := store.Create(ctx, "test@example.com", "hash123")
- if err != nil {
- t.Fatalf("Failed to create user: %v", err)
- }
-
- if user.Email != "test@example.com" {
- t.Errorf("Expected email test@example.com, got %s", user.Email)
- }
-
- if user.ID == "" {
- t.Error("Expected user ID to be set")
- }
-
- // Get by email
- found, err := store.GetByEmail(ctx, "test@example.com")
- if err != nil {
- t.Fatalf("Failed to get by email: %v", err)
- }
-
- if found.ID != user.ID {
- t.Errorf("Expected ID %s, got %s", user.ID, found.ID)
- }
-
- // Get by ID
- found, err = store.GetByID(ctx, user.ID)
- if err != nil {
- t.Fatalf("Failed to get by ID: %v", err)
- }
-
- if found.Email != user.Email {
- t.Errorf("Expected email %s, got %s", user.Email, found.Email)
- }
- })
-
- t.Run("Duplicate email", func(t *testing.T) {
- // First creation
- _, err := store.Create(ctx, "duplicate@example.com", "hash1")
- if err != nil {
- t.Fatalf("First create failed: %v", err)
- }
-
- // Second creation with same email
- _, err = store.Create(ctx, "duplicate@example.com", "hash2")
- if err != core.ErrUserExists {
- t.Errorf("Expected ErrUserExists, got %v", err)
- }
- })
-
- t.Run("Get nonexistent user", func(t *testing.T) {
- _, err := store.GetByEmail(ctx, "nonexistent@example.com")
- if err != core.ErrUserNotFound {
- t.Errorf("Expected ErrUserNotFound, got %v", err)
- }
- })
-
- t.Run("Update email", func(t *testing.T) {
- // Create user
- user, err := store.Create(ctx, "old@example.com", "hash")
- if err != nil {
- t.Fatalf("Failed to create user: %v", err)
- }
-
- // Update email
- err = store.UpdateEmail(ctx, user.ID, "new@example.com")
- if err != nil {
- t.Fatalf("Failed to update email: %v", err)
- }
-
- // Try old email
- _, err = store.GetByEmail(ctx, "old@example.com")
- if err != core.ErrUserNotFound {
- t.Errorf("Expected ErrUserNotFound for old email, got %v", err)
- }
-
- // Try new email
- found, err := store.GetByEmail(ctx, "new@example.com")
- if err != nil {
- t.Fatalf("Failed to get by new email: %v", err)
- }
-
- if found.ID != user.ID {
- t.Errorf("Expected user ID %s, got %s", user.ID, found.ID)
- }
- })
-
- t.Run("Update password", func(t *testing.T) {
- // Create user
- user, err := store.Create(ctx, "pass@example.com", "oldhash")
- if err != nil {
- t.Fatalf("Failed to create user: %v", err)
- }
-
- // Update password
- err = store.UpdatePassword(ctx, user.ID, "newhash")
- if err != nil {
- t.Fatalf("Failed to update password: %v", err)
- }
-
- // Get and verify
- found, err := store.GetByID(ctx, user.ID)
- if err != nil {
- t.Fatalf("Failed to get user: %v", err)
- }
-
- if found.PasswordHash != "newhash" {
- t.Errorf("Expected newhash, got %s", found.PasswordHash)
- }
- })
-
- t.Run("Delete user", func(t *testing.T) {
- // Create user
- user, err := store.Create(ctx, "delete@example.com", "hash")
- if err != nil {
- t.Fatalf("Failed to create user: %v", err)
- }
-
- // Delete user
- err = store.Delete(ctx, user.ID)
- if err != nil {
- t.Fatalf("Failed to delete user: %v", err)
- }
-
- // Try to get by email
- _, err = store.GetByEmail(ctx, "delete@example.com")
- if err != core.ErrUserNotFound {
- t.Errorf("Expected ErrUserNotFound after delete, got %v", err)
- }
- })
-}
diff --git a/internal/tests/token_hashing_test.go b/internal/tests/token_hashing_test.go
deleted file mode 100644
index 102a0eb..0000000
--- a/internal/tests/token_hashing_test.go
+++ /dev/null
@@ -1,218 +0,0 @@
-package tests
-
-import (
- "context"
- "crypto/sha256"
- "encoding/hex"
- "testing"
-
- "github.com/crydensync/cryden/internal/core"
- "github.com/crydensync/cryden/internal/stores/memory"
- "github.com/crydensync/cryden/internal/token"
-)
-
-func TestTokenHashing(t *testing.T) {
- t.Run("generate and verify refresh token", func(t *testing.T) {
- hasher := core.NewBcryptHasher(10)
- svc := token.NewService(hasher)
-
- // Generate token
- bundle, err := svc.GenerateRefreshToken()
- if err != nil {
- t.Fatalf("Failed to generate token: %v", err)
- }
-
- // Check all parts present
- if bundle.PlainText == "" {
- t.Error("Plain text token empty")
- }
- if bundle.LookupHash == "" {
- t.Error("Lookup hash empty")
- }
- if bundle.StorageHash == "" {
- t.Error("Storage hash empty")
- }
-
- // Verify lookup hash is correct SHA256
- expectedLookup := sha256.Sum256([]byte(bundle.PlainText))
- if bundle.LookupHash != hex.EncodeToString(expectedLookup[:]) {
- t.Error("Lookup hash doesn't match plain token")
- }
-
- // Verify storage hash works
- if err := svc.VerifyRefreshToken(bundle.PlainText, bundle.StorageHash); err != nil {
- t.Error("Storage hash failed to verify plain token")
- }
-
- // Wrong token should fail
- if err := svc.VerifyRefreshToken("wrong-token", bundle.StorageHash); err == nil {
- t.Error("Verification should fail for wrong token")
- }
- })
-}
-
-func TestSessionStoreWithHashedTokens(t *testing.T) {
- userStore := memory.NewUserStore()
- sessionStore := memory.NewSessionStore()
- hasher := core.NewBcryptHasher(10)
- tokenSvc := token.NewService(hasher)
-
- ctx := context.Background()
-
- // Create a test user
- user, err := userStore.Create(ctx, "test@example.com", "password-hash")
- if err != nil {
- t.Fatalf("Failed to create user: %v", err)
- }
-
- t.Run("create and retrieve session", func(t *testing.T) {
- // Generate token
- bundle, err := tokenSvc.GenerateRefreshToken()
- if err != nil {
- t.Fatalf("Failed to generate token: %v", err)
- }
-
- // Create session
- _, err = sessionStore.Create(ctx, user.ID, bundle.StorageHash, bundle.LookupHash)
- if err != nil {
- t.Fatalf("Failed to create session: %v", err)
- }
-
- // Retrieve by lookup hash
- found, err := sessionStore.GetByRefreshToken(ctx, bundle.LookupHash)
- if err != nil {
- t.Fatalf("Failed to get session: %v", err)
- }
-
- // Verify token
- if err := tokenSvc.VerifyRefreshToken(bundle.PlainText, found.RefreshToken); err != nil {
- t.Error("Failed to verify token")
- }
-
- // Wrong lookup hash should fail
- _, err = sessionStore.GetByRefreshToken(ctx, "wrong-hash")
- if err != core.ErrSessionNotFound {
- t.Error("Should not find session with wrong lookup hash")
- }
- })
-
- t.Run("list sessions doesn't expose lookup hash", func(t *testing.T) {
- // Create a session
- bundle, _ := tokenSvc.GenerateRefreshToken()
- sessionStore.Create(ctx, user.ID, bundle.StorageHash, bundle.LookupHash)
-
- // List sessions
- sessions, err := sessionStore.ListForUser(ctx, user.ID)
- if err != nil {
- t.Fatalf("Failed to list sessions: %v", err)
- }
-
- // Check lookup hash is empty in list response
- for _, s := range sessions {
- if s.LookupHash != "" {
- t.Error("Lookup hash should not be exposed in ListForUser")
- }
- }
- })
-
- t.Run("revoke session", func(t *testing.T) {
- // Create session
- bundle, _ := tokenSvc.GenerateRefreshToken()
- session, err := sessionStore.Create(ctx, user.ID, bundle.StorageHash, bundle.LookupHash)
- if err != nil {
- t.Fatalf("Failed to create session: %v", err)
- }
-
- // Revoke it
- err = sessionStore.Revoke(ctx, session.ID)
- if err != nil {
- t.Fatalf("Failed to revoke session: %v", err)
- }
-
- // Should not be findable
- _, err = sessionStore.GetByRefreshToken(ctx, bundle.LookupHash)
- if err != core.ErrSessionNotFound {
- t.Error("Session still exists after revoke")
- }
- })
-}
-
-func TestFullLoginFlowWithHashedTokens(t *testing.T) {
- userStore := memory.NewUserStore()
- sessionStore := memory.NewSessionStore()
-
- // Create engine
- engine := core.New(userStore, sessionStore)
- ctx := context.Background()
-
- // Sign up
- user, err := engine.SignUp(ctx, "flow@example.com", "Password123")
- if err != nil {
- t.Fatalf("SignUp failed: %v", err)
- }
-
- t.Run("login stores hashed token", func(t *testing.T) {
- // Login
- tokens, _, err := engine.Login(ctx, "flow@example.com", "Password123")
- if err != nil {
- t.Fatalf("Login failed: %v", err)
- }
-
- // Try to find session by looking up with SHA256
- sha := sha256.Sum256([]byte(tokens.RefreshToken))
- lookupHash := hex.EncodeToString(sha[:])
-
- session, err := sessionStore.GetByRefreshToken(ctx, lookupHash)
- if err != nil {
- t.Fatalf("Failed to find session: %v", err)
- }
-
- // Verify user ID matches
- if session.UserID != user.ID {
- t.Errorf("Expected user ID %s, got %s", user.ID, session.UserID)
- }
-
- // Verify token in DB is not plain text
- if session.RefreshToken == tokens.RefreshToken {
- t.Error("Refresh token stored in plain text!")
- }
- })
-
- t.Run("refresh token rotation", func(t *testing.T) {
- // Login
- tokens, _, err := engine.Login(ctx, "flow@example.com", "Password123")
- if err != nil {
- t.Fatalf("Login failed: %v", err)
- }
-
- oldPlainToken := tokens.RefreshToken
- oldLookup := sha256.Sum256([]byte(oldPlainToken))
- oldLookupHash := hex.EncodeToString(oldLookup[:])
-
- // Refresh
- newTokens, err := engine.RefreshToken(ctx, oldPlainToken)
- if err != nil {
- t.Fatalf("Refresh failed: %v", err)
- }
-
- // Old token should be invalid
- _, err = sessionStore.GetByRefreshToken(ctx, oldLookupHash)
- if err != core.ErrSessionNotFound {
- t.Error("Old token still valid after refresh")
- }
-
- // New token should work
- newLookup := sha256.Sum256([]byte(newTokens.RefreshToken))
- newLookupHash := hex.EncodeToString(newLookup[:])
-
- session, err := sessionStore.GetByRefreshToken(ctx, newLookupHash)
- if err != nil {
- t.Error("New token not working")
- }
-
- // Verify new token hash matches
- if err := engine.GetHasher().Compare(newTokens.RefreshToken, session.RefreshToken); err != nil {
- t.Error("New token verification failed")
- }
- })
-}
diff --git a/internal/tests/user_store_test.go b/internal/tests/user_store_test.go
deleted file mode 100644
index e339ee3..0000000
--- a/internal/tests/user_store_test.go
+++ /dev/null
@@ -1,152 +0,0 @@
-package tests
-
-import (
- "context"
- "testing"
-
- "github.com/crydensync/cryden/internal/core"
- "github.com/crydensync/cryden/internal/stores/memory"
-)
-
-func TestMemoryUserStore(t *testing.T) {
- // Create store
- store := memory.NewUserStore()
- ctx := context.Background()
-
- t.Run("Create and Get user", func(t *testing.T) {
- // Create user
- user, err := store.Create(ctx, "test@example.com", "hash123")
- if err != nil {
- t.Fatalf("Failed to create user: %v", err)
- }
-
- if user.Email != "test@example.com" {
- t.Errorf("Expected email test@example.com, got %s", user.Email)
- }
-
- if user.PasswordHash != "hash123" {
- t.Errorf("Expected hash hash123, got %s", user.PasswordHash)
- }
-
- // Get by email
- found, err := store.GetByEmail(ctx, "test@example.com")
- if err != nil {
- t.Fatalf("Failed to get user by email: %v", err)
- }
-
- if found.ID != user.ID {
- t.Errorf("Expected ID %s, got %s", user.ID, found.ID)
- }
-
- // Get by ID
- found, err = store.GetByID(ctx, user.ID)
- if err != nil {
- t.Fatalf("Failed to get user by ID: %v", err)
- }
-
- if found.Email != user.Email {
- t.Errorf("Expected email %s, got %s", user.Email, found.Email)
- }
- })
-
- t.Run("Create duplicate user", func(t *testing.T) {
- // First creation should work
- _, err := store.Create(ctx, "duplicate@example.com", "hash")
- if err != nil {
- t.Fatalf("First creation failed: %v", err)
- }
-
- // Second creation with same email should fail
- _, err = store.Create(ctx, "duplicate@example.com", "hash")
- if err != core.ErrUserExists {
- t.Errorf("Expected ErrUserExists, got %v", err)
- }
- })
-
- t.Run("Get nonexistent user", func(t *testing.T) {
- _, err := store.GetByEmail(ctx, "nonexistent@example.com")
- if err != core.ErrUserNotFound {
- t.Errorf("Expected ErrUserNotFound, got %v", err)
- }
- })
-
- t.Run("Update email", func(t *testing.T) {
- // Create user
- user, err := store.Create(ctx, "old@example.com", "hash")
- if err != nil {
- t.Fatalf("Failed to create user: %v", err)
- }
-
- // Update email
- err = store.UpdateEmail(ctx, user.ID, "new@example.com")
- if err != nil {
- t.Fatalf("Failed to update email: %v", err)
- }
-
- // Check old email doesn't work
- _, err = store.GetByEmail(ctx, "old@example.com")
- if err != core.ErrUserNotFound {
- t.Errorf("Expected ErrUserNotFound for old email, got %v", err)
- }
-
- // Check new email works
- found, err := store.GetByEmail(ctx, "new@example.com")
- if err != nil {
- t.Fatalf("Failed to get by new email: %v", err)
- }
-
- if found.ID != user.ID {
- t.Errorf("Expected user ID %s, got %s", user.ID, found.ID)
- }
- })
-
- t.Run("Update password", func(t *testing.T) {
- // Create user
- user, err := store.Create(ctx, "pass@example.com", "oldhash")
- if err != nil {
- t.Fatalf("Failed to create user: %v", err)
- }
-
- // Update password
- err = store.UpdatePassword(ctx, user.ID, "newhash")
- if err != nil {
- t.Fatalf("Failed to update password: %v", err)
- }
-
- // Get and verify
- found, err := store.GetByID(ctx, user.ID)
- if err != nil {
- t.Fatalf("Failed to get user: %v", err)
- }
-
- if found.PasswordHash != "newhash" {
- t.Errorf("Expected newhash, got %s", found.PasswordHash)
- }
- })
-
- t.Run("Delete user", func(t *testing.T) {
- // Create user
- user, err := store.Create(ctx, "delete@example.com", "hash")
- if err != nil {
- t.Fatalf("Failed to create user: %v", err)
- }
-
- // Delete user
- err = store.Delete(ctx, user.ID)
- if err != nil {
- t.Fatalf("Failed to delete user: %v", err)
- }
-
- // Try to get by email
- _, err = store.GetByEmail(ctx, "delete@example.com")
- if err != core.ErrUserNotFound {
- t.Errorf("Expected ErrUserNotFound after delete, got %v", err)
- }
-
- // Try to get by ID
- _, err = store.GetByID(ctx, user.ID)
- if err != core.ErrUserNotFound {
- t.Errorf("Expected ErrUserNotFound after delete, got %v", err)
- }
- })
-}
diff --git a/internal/token/service.go b/internal/token/service.go
deleted file mode 100644
index 11a7259..0000000
--- a/internal/token/service.go
+++ /dev/null
@@ -1,64 +0,0 @@
-package token
-
-import (
- "crypto/rand"
- "crypto/sha256"
- "encoding/base64"
- "encoding/hex"
- "fmt"
-
- "github.com/crydensync/cryden/internal/core"
-)
-
-// Service handles all token operations
-type Service struct {
- hasher core.Hasher
-}
-
-// NewService creates a new token service
-func NewService(hasher core.Hasher) *Service {
- return &Service{
- hasher: hasher,
- }
-}
-
-// RefreshTokenBundle contains all forms of a refresh token
-type RefreshTokenBundle struct {
- PlainText string // What client receives
- LookupHash string // SHA256 for DB lookup (indexed)
- StorageHash string // bcrypt for DB storage (salted)
-}
-
-// GenerateRefreshToken creates a secure random token and its hashes
-func (s *Service) GenerateRefreshToken() (*RefreshTokenBundle, error) {
- // 1. Generate cryptographically secure random token (32 bytes = 256 bits)
- token := make([]byte, 32)
- _, err := rand.Read(token)
- if err != nil {
- return nil, fmt.Errorf("failed to generate random token: %w", err)
- }
-
- // Encode as URL-safe base64 (no + or /, no padding)
- plainToken := base64.RawURLEncoding.EncodeToString(token)
-
- // 2. Generate SHA256 lookup hash (for fast DB indexing)
- sha := sha256.Sum256([]byte(plainToken))
- lookupHash := hex.EncodeToString(sha[:])
-
- // 3. Generate bcrypt storage hash (for secure verification)
- storageHash, err := s.hasher.Hash(plainToken)
- if err != nil {
- return nil, fmt.Errorf("failed to hash token: %w", err)
- }
-
- return &RefreshTokenBundle{
- PlainText: plainToken,
- LookupHash: lookupHash,
- StorageHash: storageHash,
- }, nil
-}
-
-// VerifyRefreshToken checks if a plain token matches a stored hash
-func (s *Service) VerifyRefreshToken(plainToken, storageHash string) error {
- return s.hasher.Compare(plainToken, storageHash)
-}
diff --git a/logger/console.go b/logger/console.go
new file mode 100644
index 0000000..85174b2
--- /dev/null
+++ b/logger/console.go
@@ -0,0 +1,59 @@
+package logger
+
+import (
+ "encoding/json"
+ "os"
+ "time"
+)
+
+// ConsoleJSONLogger is the v2 Logger implementation. It writes one
+// JSON object per line to stdout β the standard 12-factor pattern,
+// letting the consuming app's own infra (Docker, systemd, a log
+// agent/sidecar) route output to file/cloud as needed. This package
+// never writes to disk or calls a cloud logging API directly.
+type ConsoleJSONLogger struct{}
+
+func NewConsoleJSONLogger() *ConsoleJSONLogger {
+ return &ConsoleJSONLogger{}
+}
+
+type logLine struct {
+ Level string `json:"level"`
+ Message string `json:"message"`
+ Fields map[string]string `json:"fields,omitempty"`
+ Timestamp string `json:"timestamp"`
+}
+
+func (l *ConsoleJSONLogger) write(level, msg string, fields map[string]string) {
+ line := logLine{
+ Level: level,
+ Message: msg,
+ Fields: fields,
+ Timestamp: time.Now().UTC().Format(time.RFC3339),
+ }
+ b, err := json.Marshal(line)
+ if err != nil {
+ // Marshaling a small struct of strings should never fail; if it
+ // somehow does, fall back to a plain-text line rather than
+ // silently dropping the log.
+ os.Stdout.WriteString(level + ": " + msg + "\n")
+ return
+ }
+ os.Stdout.Write(append(b, '\n'))
+}
+
+func (l *ConsoleJSONLogger) Debug(msg string, fields map[string]string) {
+ l.write("debug", msg, fields)
+}
+
+func (l *ConsoleJSONLogger) Info(msg string, fields map[string]string) {
+ l.write("info", msg, fields)
+}
+
+func (l *ConsoleJSONLogger) Warn(msg string, fields map[string]string) {
+ l.write("warn", msg, fields)
+}
+
+func (l *ConsoleJSONLogger) Error(msg string, fields map[string]string) {
+ l.write("error", msg, fields)
+}
diff --git a/logger/logger.go b/logger/logger.go
new file mode 100644
index 0000000..47c9434
--- /dev/null
+++ b/logger/logger.go
@@ -0,0 +1,17 @@
+package logger
+
+// Logger defines operational logging β engine-internal debug/info/
+// error messages for developers debugging their own deployment. This
+// is distinct from store.AuditStore, which records queryable,
+// security-relevant domain events (login, logout, token reuse, etc.).
+//
+// v2 ships one implementation: ConsoleJSONLogger. The consuming app
+// wires whatever Logger it wants (file, cloud, console) β the engine
+// never ships logs anywhere on its own. See project note on the
+// zero-telemetry principle.
+type Logger interface {
+ Debug(msg string, fields map[string]string)
+ Info(msg string, fields map[string]string)
+ Warn(msg string, fields map[string]string)
+ Error(msg string, fields map[string]string)
+}
diff --git a/security/errors.go b/security/errors.go
new file mode 100644
index 0000000..b96eb90
--- /dev/null
+++ b/security/errors.go
@@ -0,0 +1,7 @@
+package security
+
+import "errors"
+
+var (
+ ErrInvalidBcryptCost = errors.New("security: bcrypt cost out of valid range")
+)
diff --git a/security/hasher.go b/security/hasher.go
new file mode 100644
index 0000000..715bb59
--- /dev/null
+++ b/security/hasher.go
@@ -0,0 +1,43 @@
+package security
+
+import "golang.org/x/crypto/bcrypt"
+
+// Hasher defines password hashing operations. v1 ships one implementation:
+// BcryptHasher. Compare must run in constant time relative to a correct
+// vs incorrect password β bcrypt's ComparePassword already guarantees this.
+type Hasher interface {
+ Hash(password string) (string, error)
+ Compare(hash, password string) error
+}
+
+// BcryptHasher is the v2 Hasher implementation.
+type BcryptHasher struct {
+ // Cost is the bcrypt work factor. Must be set explicitly by the
+ // caller via Config β no silent default to a weak cost.
+ Cost int
+}
+
+// NewBcryptHasher constructs a BcryptHasher. cost must be within
+// bcrypt's valid range (bcrypt.MinCost..bcrypt.MaxCost); callers should
+// use bcrypt.DefaultCost (10) as their own explicit choice, not rely on
+// this constructor picking one for them.
+func NewBcryptHasher(cost int) (*BcryptHasher, error) {
+ if cost < bcrypt.MinCost || cost > bcrypt.MaxCost {
+ return nil, ErrInvalidBcryptCost
+ }
+ return &BcryptHasher{Cost: cost}, nil
+}
+
+func (b *BcryptHasher) Hash(password string) (string, error) {
+ bytes, err := bcrypt.GenerateFromPassword([]byte(password), b.Cost)
+ if err != nil {
+ return "", nil
+ }
+ return string(bytes), nil
+}
+
+func (b *BcryptHasher) Compare(hash, password string) error {
+ // bcrypt.CompareHashAndPassword is constant-time with respect to
+ // the password comparison itself.
+ return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
+}
diff --git a/security/hasher_test.go b/security/hasher_test.go
new file mode 100644
index 0000000..0caa648
--- /dev/null
+++ b/security/hasher_test.go
@@ -0,0 +1,46 @@
+package security
+
+import "testing"
+
+func TestBcryptHasher_HashAndCompare(t *testing.T) {
+ h, err := NewBcryptHasher(4) // low cost for fast tests
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ hash, err := h.Hash("correct-password")
+ if err != nil {
+ t.Fatalf("hash failed: %v", err)
+ }
+ if hash == "correct-password" {
+ t.Fatal("hash must not equal the raw password")
+ }
+
+ if err := h.Compare(hash, "correct-password"); err != nil {
+ t.Errorf("expected correct password to compare successfully, got: %v", err)
+ }
+
+ if err := h.Compare(hash, "wrong-password"); err == nil {
+ t.Error("expected wrong password to fail comparison, got nil error")
+ }
+}
+
+func TestNewBcryptHasher_RejectsInvalidCost(t *testing.T) {
+ if _, err := NewBcryptHasher(1); err == nil {
+ t.Error("expected error for cost below bcrypt.MinCost, got nil")
+ }
+ if _, err := NewBcryptHasher(100); err == nil {
+ t.Error("expected error for cost above bcrypt.MaxCost, got nil")
+ }
+}
+
+func TestBcryptHasher_SameInputDifferentHashes(t *testing.T) {
+ // bcrypt salts automatically β hashing the same password twice
+ // must never produce the same hash.
+ h, _ := NewBcryptHasher(4)
+ h1, _ := h.Hash("same-password")
+ h2, _ := h.Hash("same-password")
+ if h1 == h2 {
+ t.Error("expected different hashes for the same password due to salting")
+ }
+}
diff --git a/security/idgen.go b/security/idgen.go
new file mode 100644
index 0000000..026ec85
--- /dev/null
+++ b/security/idgen.go
@@ -0,0 +1,26 @@
+package security
+
+import "github.com/google/uuid"
+
+// IDGenerator defines ID generation for users, sessions, audit events,
+// and session families. v2 ships one implementation: UUIDv7Generator.
+// UUIDv7 is time-ordered (sortable, index-friendly) without leaking
+// a predictable sequence the way time.Now().UnixNano() would.
+type IDGenerator interface {
+ New() (string, error)
+}
+
+// UUIDv7Generator is the v1 IDGenerator implementation.
+type UUIDv7Generator struct{}
+
+func NewUUIDv7Generator() *UUIDv7Generator {
+ return &UUIDv7Generator{}
+}
+
+func (g *UUIDv7Generator) New() (string, error) {
+ id, err := uuid.NewV7()
+ if err != nil {
+ return "", err
+ }
+ return id.String(), nil
+}
diff --git a/security/idgen_test.go b/security/idgen_test.go
new file mode 100644
index 0000000..98bb572
--- /dev/null
+++ b/security/idgen_test.go
@@ -0,0 +1,30 @@
+package security
+
+import "testing"
+
+func TestUUIDv7Generator_ProducesUniqueIDs(t *testing.T) {
+ g := NewUUIDv7Generator()
+ seen := make(map[string]bool)
+ for i := 0; i < 1000; i++ {
+ id, err := g.New()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if seen[id] {
+ t.Fatalf("duplicate ID generated: %s", id)
+ }
+ seen[id] = true
+ }
+}
+
+func TestUUIDv7Generator_IDsAreRoughlySortable(t *testing.T) {
+ // UUIDv7 embeds a timestamp prefix, so IDs generated in sequence
+ // should sort lexicographically in the order they were created β
+ // the property that motivated choosing it over UUIDv4.
+ g := NewUUIDv7Generator()
+ first, _ := g.New()
+ second, _ := g.New()
+ if first >= second {
+ t.Errorf("expected sequential UUIDv7s to sort in creation order, got %s then %s", first, second)
+ }
+}
diff --git a/security/ratelimiter.go b/security/ratelimiter.go
new file mode 100644
index 0000000..8e0e310
--- /dev/null
+++ b/security/ratelimiter.go
@@ -0,0 +1,59 @@
+package security
+
+import (
+ "context"
+ "sync"
+ "time"
+)
+
+// RateLimiter defines a generic allow/deny check keyed by an opaque
+// string. The limiter has no knowledge of what the key represents β
+// callers decide (e.g. ip+":"+email for login attempts). This keeps
+// the engine framework-agnostic: it never infers a caller's identity
+// or IP itself, it only receives what's passed in.
+type RateLimiter interface {
+ Allow(ctx context.Context, key string) (bool, error)
+}
+
+// InMemoryRateLimiter is the v2 RateLimiter implementation: a simple
+// fixed-window counter per key. Not distributed β fine for a single
+// process; a Redis-backed implementation is a later addition, not v2.
+type InMemoryRateLimiter struct {
+ mu sync.Mutex
+ limit int
+ window time.Duration
+ counters map[string]*windowCounter
+}
+
+type windowCounter struct {
+ count int
+ windowEnd time.Time
+}
+
+// NewInMemoryRateLimiter constructs a limiter allowing `limit` calls
+// per `window` duration, per key. Both must be set explicitly by the
+// caller via Config β no hidden default limits.
+func NewInMemoryRateLimiter(limit int, window time.Duration) *InMemoryRateLimiter {
+ return &InMemoryRateLimiter{
+ limit: limit,
+ window: window,
+ counters: make(map[string]*windowCounter),
+ }
+}
+
+func (r *InMemoryRateLimiter) Allow(ctx context.Context, key string) (bool, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ now := time.Now()
+ c, ok := r.counters[key]
+ if !ok || now.After(c.windowEnd) {
+ r.counters[key] = &windowCounter{count: 1, windowEnd: now.Add(r.window)}
+ return true, nil
+ }
+ if c.count >= r.limit {
+ return false, nil
+ }
+ c.count++
+ return true, nil
+}
diff --git a/security/ratelimiter_test.go b/security/ratelimiter_test.go
new file mode 100644
index 0000000..2ede2c5
--- /dev/null
+++ b/security/ratelimiter_test.go
@@ -0,0 +1,63 @@
+package security
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+func TestInMemoryRateLimiter_AllowsUpToLimit(t *testing.T) {
+ rl := NewInMemoryRateLimiter(3, time.Minute)
+ ctx := context.Background()
+
+ for i := 0; i < 3; i++ {
+ allowed, err := rl.Allow(ctx, "key1")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !allowed {
+ t.Fatalf("expected call %d to be allowed", i+1)
+ }
+ }
+
+ allowed, err := rl.Allow(ctx, "key1")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if allowed {
+ t.Error("expected 4th call within the window to be denied")
+ }
+}
+
+func TestInMemoryRateLimiter_KeysAreIndependent(t *testing.T) {
+ rl := NewInMemoryRateLimiter(1, time.Minute)
+ ctx := context.Background()
+
+ a1, _ := rl.Allow(ctx, "a")
+ b1, _ := rl.Allow(ctx, "b")
+ if !a1 || !b1 {
+ t.Fatal("expected first call for each independent key to be allowed")
+ }
+
+ a2, _ := rl.Allow(ctx, "a")
+ if a2 {
+ t.Error("expected second call for key 'a' to be denied")
+ }
+}
+
+func TestInMemoryRateLimiter_ResetsAfterWindow(t *testing.T) {
+ rl := NewInMemoryRateLimiter(1, 10*time.Millisecond)
+ ctx := context.Background()
+
+ first, _ := rl.Allow(ctx, "key1")
+ if !first {
+ t.Fatal("expected first call to be allowed")
+ }
+
+ time.Sleep(20 * time.Millisecond)
+
+ afterWindow, _ := rl.Allow(ctx, "key1")
+ if !afterWindow {
+ t.Error("expected call after window expiry to be allowed again")
+ }
+}
diff --git a/store/errors.go b/store/errors.go
new file mode 100644
index 0000000..d60f193
--- /dev/null
+++ b/store/errors.go
@@ -0,0 +1,14 @@
+package store
+
+import "errors"
+
+var (
+ // ErrNotFound is returned by any store implementation when a
+ // lookup (by ID, email, or token hash) finds no matching record.
+ ErrNotFound = errors.New("store: record not found")
+ // ErrSessionNotOwned is returned when a caller-supplied userID does
+ // not match a session's actual owner. Shared here (rather than in
+ // auth or session) so neither package needs to depend on the other
+ // just to reference this error.
+ ErrSessionNotOwned = errors.New("store: session does not belong to this user")
+)
diff --git a/store/interfaces.go b/store/interfaces.go
new file mode 100644
index 0000000..54ee532
--- /dev/null
+++ b/store/interfaces.go
@@ -0,0 +1,143 @@
+package store
+
+import (
+ "context"
+ "time"
+)
+
+// User is the domain representation of a user record.
+// Storage implementations map their own row/document types to/from this.
+type User struct {
+ ID string
+ Email string
+ PasswordHash string
+ FailedAttempts int
+ LockedUntil *time.Time
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+// UserStore defines persistence operations for users.
+type UserStore interface {
+ Create(ctx context.Context, user User) error
+ GetByEmail(ctx context.Context, email string) (User, error)
+ GetByID(ctx context.Context, id string) (User, error)
+ UpdateEmail(ctx context.Context, id string, newEmail string) error
+ UpdatePasswordHash(ctx context.Context, id string, newHash string) error
+ Delete(ctx context.Context, id string) error
+
+ // IncrementFailedAttempts records one failed login and returns the
+ // new total count, so callers can decide whether to lock the
+ // account without a separate read.
+ IncrementFailedAttempts(ctx context.Context, id string) (int, error)
+ // ResetFailedAttempts clears the counter β called on successful login.
+ ResetFailedAttempts(ctx context.Context, id string) error
+ // LockAccount sets LockedUntil. Persistent (DB-backed), not
+ // in-memory β must survive process restarts and work correctly
+ // across multiple instances, unlike the rate limiter.
+ LockAccount(ctx context.Context, id string, until time.Time) error
+}
+
+// Session is the domain representation of a refresh-token-backed session.
+// TokenHash is the SHA-256 hash of the raw refresh token β the raw token
+// is never persisted.
+type Session struct {
+ ID string
+ FamilyID string
+ UserID string
+ TokenHash string
+ IP string
+ UserAgent string
+ CreatedAt time.Time
+ RevokedAt *time.Time
+}
+
+// SessionStore defines persistence operations for sessions and refresh
+// token rotation chains. v1 ships one implementation:
+// store/postgres.PostgresSessionStore.
+type SessionStore interface {
+ Create(ctx context.Context, s Session) error
+ GetByID(ctx context.Context, sessionID string) (Session, error)
+ GetByTokenHash(ctx context.Context, tokenHash string) (Session, error)
+ ListByUser(ctx context.Context, userID string) ([]Session, error)
+ Revoke(ctx context.Context, sessionID string) error
+ RevokeFamily(ctx context.Context, familyID string) error
+ RevokeAllForUser(ctx context.Context, userID string) error
+
+ // RotateToken atomically revokes oldSessionID and creates newSession
+ // in a single storage operation (a DB transaction in the Postgres
+ // implementation). This prevents a crash between separate revoke
+ // and create calls from leaving a session family in an inconsistent
+ // state (old token dead, new token never created).
+ RotateToken(ctx context.Context, oldSessionID string, newSession Session) error
+}
+
+// AuditEventType identifies the kind of audit event recorded.
+type AuditEventType string
+
+const (
+ EventSignupSuccess AuditEventType = "signup_success"
+ EventLoginSuccess AuditEventType = "login_success"
+ EventLoginFailed AuditEventType = "login_failed"
+ EventLogout AuditEventType = "logout"
+ EventLogoutAll AuditEventType = "logout_all"
+ EventTokenRotated AuditEventType = "token_rotated"
+ EventTokenReuseDetected AuditEventType = "token_reuse_detected"
+ EventSessionRevoked AuditEventType = "session_revoked"
+ EventAccountLocked AuditEventType = "account_locked"
+ EventPasswordChanged AuditEventType = "password_changed"
+ EventEmailChangeRequested AuditEventType = "email_change_requested"
+ EventEmailChanged AuditEventType = "email_changed"
+ EventAccountDeleted AuditEventType = "account_deleted"
+)
+
+// AuditEvent is a single security-relevant, queryable record.
+// Distinct from operational logging (see logger.Logger) β this is
+// domain data written to the consuming app's own store.
+type AuditEvent struct {
+ ID string
+ Type AuditEventType
+ UserID string
+ IP string
+ Metadata map[string]string
+ CreatedAt time.Time
+}
+
+// AuditStore defines persistence for audit events.
+// v1 ships one implementation: store/postgres.PostgresAuditStore.
+type AuditStore interface {
+ Record(ctx context.Context, event AuditEvent) error
+ ListByUser(ctx context.Context, userID string, limit int) ([]AuditEvent, error)
+}
+
+// VerificationPurpose distinguishes what a verification token is for β
+// a single table/store serves both signup email verification and
+// email-change confirmation, since the lifecycle (issue, hash, expire,
+// consume once) is identical.
+type VerificationPurpose string
+
+const (
+ PurposeEmailVerify VerificationPurpose = "email_verify"
+ PurposeEmailChange VerificationPurpose = "email_change"
+)
+
+// VerificationToken represents a single-use, expiring token sent to an
+// email address. NewEmail is only set for PurposeEmailChange β it's
+// the address the user is trying to change TO, not their current one.
+type VerificationToken struct {
+ ID string
+ UserID string
+ Purpose VerificationPurpose
+ TokenHash string
+ NewEmail string // only used for PurposeEmailChange
+ ExpiresAt time.Time
+ UsedAt *time.Time
+ CreatedAt time.Time
+}
+
+// VerificationStore defines persistence for verification tokens.
+type VerificationStore interface {
+ Create(ctx context.Context, vt VerificationToken) error
+ GetByTokenHash(ctx context.Context, tokenHash string) (VerificationToken, error)
+ MarkUsed(ctx context.Context, id string) error
+}
diff --git a/token/errors.go b/token/errors.go
new file mode 100644
index 0000000..ccd4a04
--- /dev/null
+++ b/token/errors.go
@@ -0,0 +1,9 @@
+package token
+
+import "errors"
+
+var (
+ ErrTokenByteLengthTooShort = errors.New("token: byte length below minimum safe entropy (16 bytes / 128 bits)")
+ ErrMissingJWTSecret = errors.New("token: JWT secret must not be empty")
+ ErrInvalidTTL = errors.New("token: TTL must be greater than zero")
+)
diff --git a/token/generator.go b/token/generator.go
new file mode 100644
index 0000000..fea8d11
--- /dev/null
+++ b/token/generator.go
@@ -0,0 +1,43 @@
+package token
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+)
+
+// TokenGenerator defines generation of opaque, cryptographically random
+// refresh tokens. v2 ships one implementation: CryptoRandTokenGenerator.
+// The raw token returned here is what's given to the caller β it is
+// never persisted as-is; the engine hashes it (SHA-256) before storing
+// in SessionStore. See token/refresh.go.
+type TokenGenerator interface {
+ New() (string, error)
+}
+
+// CryptoRandTokenGenerator is the v2 TokenGenerator implementation.
+// Generates 32 raw random bytes (256 bits) via crypto/rand and
+// hex-encodes them for safe storage/transport as a string.
+type CryptoRandTokenGenerator struct {
+ // ByteLength is the number of random bytes generated per token.
+ // 32 bytes (256 bits) is the standard baseline for opaque tokens.
+ ByteLength int
+}
+
+// NewCryptoRandTokenGenerator constructs a generator. byteLength must
+// be set explicitly by the caller via Config; 32 is the recommended
+// value if the caller has no specific reason to deviate.
+func NewCryptoRandTokenGenerator(byteLength int) (*CryptoRandTokenGenerator, error) {
+ if byteLength < 16 {
+ // Reject anything below 128 bits β too weak for a session token.
+ return nil, ErrTokenByteLengthTooShort
+ }
+ return &CryptoRandTokenGenerator{ByteLength: byteLength}, nil
+}
+
+func (g *CryptoRandTokenGenerator) New() (string, error) {
+ buf := make([]byte, g.ByteLength)
+ if _, err := rand.Read(buf); err != nil {
+ return "", nil
+ }
+ return hex.EncodeToString(buf), nil
+}
diff --git a/token/generator_test.go b/token/generator_test.go
new file mode 100644
index 0000000..56592a0
--- /dev/null
+++ b/token/generator_test.go
@@ -0,0 +1,36 @@
+package token
+
+import "testing"
+
+func TestCryptoRandTokenGenerator_ProducesUniqueTokens(t *testing.T) {
+ g, err := NewCryptoRandTokenGenerator(32)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ seen := make(map[string]bool)
+ for i := 0; i < 1000; i++ {
+ tok, err := g.New()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if seen[tok] {
+ t.Fatalf("duplicate token generated: %s", tok)
+ }
+ seen[tok] = true
+ }
+}
+
+func TestNewCryptoRandTokenGenerator_RejectsTooShort(t *testing.T) {
+ if _, err := NewCryptoRandTokenGenerator(8); err == nil {
+ t.Error("expected error for byte length below 16, got nil")
+ }
+}
+
+func TestHashToken_Deterministic(t *testing.T) {
+ if HashToken("same-input") != HashToken("same-input") {
+ t.Error("expected HashToken to be deterministic for the same input")
+ }
+ if HashToken("input-a") == HashToken("input-b") {
+ t.Error("expected different inputs to produce different hashes")
+ }
+}
diff --git a/token/jwt.go b/token/jwt.go
new file mode 100644
index 0000000..9acab08
--- /dev/null
+++ b/token/jwt.go
@@ -0,0 +1,74 @@
+package token
+
+import (
+ "errors"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+)
+
+var (
+ ErrInvalidAccessToken = errors.New("token: access token invalid or expired")
+)
+
+// JWTIssuer issues and verifies short-lived, stateless access tokens.
+// Unlike refresh tokens, access tokens are never persisted or looked
+// up in the DB β validity is proven entirely by signature + expiry.
+//
+// Secret and TTL must be set explicitly at construction β no default
+// secret exists anywhere in this package (see NewJWTIssuer).
+type JWTIssuer struct {
+ secret []byte
+ ttl time.Duration
+}
+
+// NewJWTIssuer constructs a JWTIssuer. secret must be non-empty β the
+// engine fails construction entirely if no secret is configured
+// (see cryden.New / Config validation), so an empty secret reaching
+// here would already be a bug upstream, not something to default past.
+func NewJWTIssuer(secret string, ttl time.Duration) (*JWTIssuer, error) {
+ if secret == "" {
+ return nil, ErrMissingJWTSecret
+ }
+ if ttl <= 0 {
+ return nil, ErrInvalidTTL
+ }
+ return &JWTIssuer{secret: []byte(secret), ttl: ttl}, nil
+}
+
+type accessClaims struct {
+ jwt.RegisteredClaims
+}
+
+// Issue creates a signed access token for userID, expiring after the
+// issuer's configured TTL.
+func (j *JWTIssuer) Issue(userID string) (string, error) {
+ now := time.Now()
+ cliams := accessClaims{
+ RegisteredClaims: jwt.RegisteredClaims{
+ Subject: userID,
+ IssuedAt: jwt.NewNumericDate(now),
+ ExpiresAt: jwt.NewNumericDate(now.Add(j.ttl)),
+ },
+ }
+ t := jwt.NewWithClaims(jwt.SigningMethodHS256, cliams)
+ return t.SignedString(j.secret)
+}
+
+// Verify checks the token's signature and expiry, returning the
+// embedded user ID if valid.
+func (j *JWTIssuer) Verify(tokenStr string) (string, error) {
+ claims := &accessClaims{}
+ parsed, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
+ // Reject any token not signed with the algorithm we issue β
+ // prevents algorithm-confusion attacks (e.g. "alg: none").
+ if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
+ return nil, ErrInvalidAccessToken
+ }
+ return j.secret, nil
+ })
+ if err != nil || !parsed.Valid {
+ return "", ErrInvalidAccessToken
+ }
+ return claims.Subject, nil
+}
diff --git a/token/jwt_test.go b/token/jwt_test.go
new file mode 100644
index 0000000..c5af385
--- /dev/null
+++ b/token/jwt_test.go
@@ -0,0 +1,83 @@
+package token
+
+import (
+ "testing"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+)
+
+func TestJWTIssuer_IssueAndVerify(t *testing.T) {
+ iss, err := NewJWTIssuer("test-secret", time.Minute)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+
+ tok, err := iss.Issue("user-123")
+ if err != nil {
+ t.Fatalf("issue failed: %v", err)
+ }
+
+ userID, err := iss.Verify(tok)
+ if err != nil {
+ t.Fatalf("verify failed: %v", err)
+ }
+ if userID != "user-123" {
+ t.Errorf("expected user-123, got %s", userID)
+ }
+}
+
+func TestJWTIssuer_RejectsExpiredToken(t *testing.T) {
+ iss, _ := NewJWTIssuer("test-secret", 1*time.Millisecond)
+ tok, _ := iss.Issue("user-123")
+
+ time.Sleep(10 * time.Millisecond)
+
+ if _, err := iss.Verify(tok); err == nil {
+ t.Error("expected expired token to fail verification")
+ }
+}
+
+func TestJWTIssuer_RejectsWrongSecret(t *testing.T) {
+ iss1, _ := NewJWTIssuer("secret-one", time.Minute)
+ iss2, _ := NewJWTIssuer("secret-two", time.Minute)
+
+ tok, _ := iss1.Issue("user-123")
+ if _, err := iss2.Verify(tok); err == nil {
+ t.Error("expected token signed with a different secret to fail verification")
+ }
+}
+
+func TestJWTIssuer_RejectsAlgNone(t *testing.T) {
+ // Algorithm-confusion attack: a token claiming alg "none" must be
+ // rejected outright, never accepted as if unsigned tokens were valid.
+ iss, _ := NewJWTIssuer("test-secret", time.Minute)
+
+ claims := accessClaims{
+ RegisteredClaims: jwt.RegisteredClaims{Subject: "attacker"},
+ }
+ unsignedToken := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
+ tokStr, err := unsignedToken.SignedString(jwt.UnsafeAllowNoneSignatureType)
+ if err != nil {
+ t.Fatalf("failed to construct test token: %v", err)
+ }
+
+ if _, err := iss.Verify(tokStr); err == nil {
+ t.Error("expected alg:none token to be rejected, got nil error")
+ }
+}
+
+func TestNewJWTIssuer_RejectsEmptySecret(t *testing.T) {
+ if _, err := NewJWTIssuer("", time.Minute); err == nil {
+ t.Error("expected error for empty secret, got nil")
+ }
+}
+
+func TestNewJWTIssuer_RejectsInvalidTTL(t *testing.T) {
+ if _, err := NewJWTIssuer("secret", 0); err == nil {
+ t.Error("expected error for zero TTL, got nil")
+ }
+ if _, err := NewJWTIssuer("secret", -time.Minute); err == nil {
+ t.Error("expected error for negative TTL, got nil")
+ }
+}
diff --git a/token/refresh.go b/token/refresh.go
new file mode 100644
index 0000000..dec2a9d
--- /dev/null
+++ b/token/refresh.go
@@ -0,0 +1,99 @@
+package token
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+
+ "github.com/crydensync/cryden/v2/security"
+ "github.com/crydensync/cryden/v2/store"
+)
+
+var (
+ ErrInvalidToken = errors.New("token: refresh token not found or malformed")
+ // ErrTokenReused is returned when an already-rotated (revoked) refresh
+ // token is presented again β a signal of possible theft. The entire
+ // session family has already been revoked by the time this is returned.
+ ErrTokenReused = errors.New("token: refresh token reuse detected, session family revoked")
+)
+
+// HashToken returns the SHA-256 hex digest of a raw token. Only this
+// hash is ever persisted β the raw token exists solely in the value
+// returned to the caller at issue/rotation time.
+func HashToken(raw string) string {
+ sum := sha256.Sum256([]byte(raw))
+ return hex.EncodeToString(sum[:])
+}
+
+// RefreshResult carries the newly issued raw refresh token and its
+// backing session record.
+type RefreshResult struct {
+ RawToken string
+ Session store.Session
+}
+
+// Rotate validates a presented raw refresh token, detects reuse, and β
+// if valid β issues a new token in the same session family while
+// revoking the old one.
+//
+// Flow:
+// 1. Hash the incoming raw token, look it up.
+// 2. Not found -> ErrInvalidToken.
+// 3. Found but already revoked -> reuse detected: revoke the entire
+// family, return ErrTokenReused. Caller is responsible for
+// recording the token_reuse_detected audit event β this function
+// only enforces the security invariant, it does not log.
+// 4. Found and valid -> revoke the old row, issue + persist a new
+// token under the same family_id, return the new raw token.
+func Rotate(
+ ctx context.Context,
+ sessions store.SessionStore,
+ gen TokenGenerator,
+ ids security.IDGenerator,
+ rawToken string,
+) (RefreshResult, error) {
+ hash := HashToken(rawToken)
+
+ existing, err := sessions.GetByTokenHash(ctx, hash)
+ if err != nil {
+ return RefreshResult{}, ErrInvalidToken
+ }
+
+ if existing.RevokedAt != nil {
+ // Reuse of a token that was already rotated away β treat the
+ // whole family as compromised. Return existing (not a zero
+ // value) so the caller can attribute the audit event to the
+ // correct user/family β losing that context here would make
+ // the token_reuse_detected event useless for investigation.
+ if revokeErr := sessions.RevokeFamily(ctx, existing.FamilyID); revokeErr != nil {
+ return RefreshResult{Session: existing}, revokeErr
+ }
+ return RefreshResult{Session: existing}, ErrTokenReused
+ }
+
+ newRaw, err := gen.New()
+ if err != nil {
+ return RefreshResult{}, err
+ }
+
+ newID, err := ids.New()
+ if err != nil {
+ return RefreshResult{}, err
+ }
+
+ newSession := store.Session{
+ ID: newID,
+ FamilyID: existing.FamilyID,
+ UserID: existing.UserID,
+ TokenHash: HashToken(newRaw),
+ IP: existing.IP,
+ UserAgent: existing.UserAgent,
+ }
+
+ if err := sessions.RotateToken(ctx, existing.ID, newSession); err != nil {
+ return RefreshResult{}, err
+ }
+
+ return RefreshResult{RawToken: newRaw, Session: newSession}, nil
+}