authcore

package module
v1.2.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 26, 2026 License: MIT Imports: 8 Imported by: 0

README ΒΆ

πŸ›‘οΈ authcore

Drop-in authentication for Go. Password hashing, JWT sessions, email + username validation β€” secure defaults, zero boilerplate.

Go Reference Go Report Card CI codecov CodeQL OpenSSF Scorecard Sponsor

Quick Start Β· Why AuthCore? Β· Modules Β· Examples Β· API Docs


What is AuthCore?

AuthCore is a Go library that handles the authentication plumbing most apps need β€” password hashing, login tokens, refresh/rotation, email + username validation β€” without forcing you to become a crypto expert. Import it, call three functions, and you have login that a security auditor would accept.

Written for Go 1.26+. No database. No HTTP framework. You plug those in.

go get github.com/Jaro-c/authcore

How it fits together

flowchart LR
    App["Your App"] -->|init once| Core["authcore.AuthCore"]
    Core -->|manages| Keys[("πŸ”‘ Ed25519 keys<br/>HMAC secret<br/>(auto-generated, on disk)")]
    Core -->|Provider| Mods
    subgraph Mods["Plug-in modules"]
        direction TB
        JWT["auth/jwt<br/>access + refresh tokens"]
        Pwd["auth/password<br/>Argon2id hashing"]
        Email["auth/email<br/>validate + DNS MX"]
        User["auth/username<br/>validate + reserved"]
    end
    JWT -->|sign / verify| Client["HTTP client"]
    Pwd -->|hash / verify| DB[("Your database")]
    Email -->|normalize| DB
    User -->|normalize| DB

Pick only the modules you need. Each one is independent, testable, and safe by default.


Table of Contents


Why AuthCore?

You could roll your own β€” but password storage, token signing, and timing-safe comparison are the kind of things you only get wrong once. You could also pull in a full identity platform β€” but that's a lot of surface area for a login form.

AuthCore sits in the middle: a small, opinionated library that does the dangerous parts for you and leaves the rest to your app.

Roll your own Full IdP (Ory, Keycloak) AuthCore
Time to first login Hours – days Hours (with ops) 5 minutes
Database included ❌ (you build it) βœ… (theirs) ❌ (bring your own β€” any DB works)
HTTP server included ❌ βœ… (theirs) ❌ (plug into any router)
Argon2id password hashing Manual βœ… βœ…
EdDSA access + refresh tokens Manual βœ… βœ…
Timing-safe comparisons Easy to forget βœ… βœ…
Automatic key management Manual βœ… βœ…
OAuth / OIDC provider ❌ βœ… Planned
You own the data model βœ… ❌ βœ…
Runs in-process, no extra service βœ… ❌ βœ…

Use AuthCore when you want security defaults without the infrastructure cost of a dedicated identity platform.


Modules at a Glance

Module Does Import
πŸ” auth/jwt Sign + verify access/refresh tokens. EdDSA / Ed25519. Generic custom claims. Rotation. github.com/Jaro-c/authcore/auth/jwt
πŸ”‘ auth/password Hash + verify passwords. Argon2id. Policy enforced. PHC format (self-describing). github.com/Jaro-c/authcore/auth/password
πŸ“§ auth/email Validate + normalize addresses. RFC 5321/5322. Optional DNS MX check (cached). github.com/Jaro-c/authcore/auth/email
πŸ‘€ auth/username Validate + normalize usernames. Reserved-name blocklist. Character rules. github.com/Jaro-c/authcore/auth/username

Each module works on its own β€” mix and match.


Features

  • EdDSA / Ed25519 token signing β€” fast, constant-time, no padding-oracle risk
  • Dual-token model β€” short-lived access tokens + long-lived refresh tokens
  • Argon2id password hashing β€” memory-hard, GPU/ASIC-resistant, PHC format
  • Email validation & normalization β€” RFC 5321/5322 compliance, optional DNS MX verification with cache
  • Username validation & normalization β€” character rules, consecutive-special detection, fixed reserved name blocklist
  • Automatic key management β€” generates, persists, and loads keys on first run
  • Generic custom claims β€” embed any struct in access tokens with full type safety
  • Timing-safe comparisons β€” subtle.ConstantTimeCompare throughout
  • Clock skew tolerance β€” configurable leeway for distributed deployments
  • Pluggable logger β€” bring slog, zap, zerolog, or any custom backend
  • Testable by design β€” injectable clock and Provider interface for unit tests
  • Minimal dependencies β€” golang-jwt/jwt/v5, golang.org/x/crypto, golang.org/x/sync

Quick Start

package main

import (
    "log"

    "github.com/Jaro-c/authcore"
    "github.com/Jaro-c/authcore/auth/jwt"
    "github.com/Jaro-c/authcore/auth/password"
)

type UserClaims struct {
    Name string `json:"name"`
    Role string `json:"role"`
}

func main() {
    // 1. One-time setup at startup.
    //    On first run, Ed25519 keys + HMAC secret are generated in ./.authcore/.
    auth, err := authcore.New(authcore.DefaultConfig())
    if err != nil {
        log.Fatal(err)
    }

    // 2. Password hashing β€” zero config, OWASP-recommended Argon2id defaults.
    pwdMod, err := password.New(auth)
    if err != nil {
        log.Fatal(err)
    }

    // 3. JWT tokens β€” set issuer + audience to your service URL in production.
    jwtMod, err := jwt.New[UserClaims](auth, jwt.DefaultConfig())
    if err != nil {
        log.Fatal(err)
    }

    // Registration: hash the plaintext, store only the hash.
    hash, err := pwdMod.Hash("Str0ng-P@ssword!")
    if err != nil {
        log.Fatal(err) // e.g. password.ErrWeakPassword β€” surface the reason to the user
    }
    // β†’ db.StorePasswordHash(userID, hash)

    // Login: verify the submitted password, then issue a token pair.
    ok, err := pwdMod.Verify("Str0ng-P@ssword!", hash)
    if err != nil || !ok {
        log.Fatal("wrong password")
    }

    // userID must be a UUID v7 β€” the rest of your app can generate this.
    userID := "019600ab-1234-7000-8000-000000000001"
    pair, err := jwtMod.CreateTokens(userID, UserClaims{Name: "Ana", Role: "admin"})
    if err != nil {
        log.Fatal(err)
    }

    // pair.AccessToken      β†’ send as `Authorization: Bearer <token>`
    // pair.RefreshToken     β†’ store client-side in a secure, httpOnly cookie
    // pair.RefreshTokenHash β†’ store server-side (never the raw refresh token)
    // pair.SessionID        β†’ UUID v7 shared by both tokens β€” use as your session row PK
    _ = pair
}

[!TIP] Want a runnable version? Every module has a full example under examples/ β€” just go run ./examples/jwt/ to see it work end-to-end.


JWT Authentication

πŸ” Full reference β€” setup, login, auth, rotation, clock skew Β· click to expand

Setup

cfg := jwt.DefaultConfig()
cfg.Issuer   = "https://auth.example.com"
cfg.Audience = []string{"https://api.example.com"}

// Optional: tolerate up to 30 s of clock drift between servers.
cfg.ClockSkewLeeway = 30 * time.Second

jwtMod, err := jwt.New[UserClaims](auth, cfg)

jwt.DefaultConfig() values:

Field Default Max
AccessTokenTTL 15 minutes 24 hours
RefreshTokenTTL 24 hours 365 days
Issuer "github.com/Jaro-c/authcore" β€”
Audience ["github.com/Jaro-c/authcore"] β€”
ClockSkewLeeway 0 (no leeway) β€”

[!NOTE] validateConfig rejects TTLs above the ceilings listed above. This prevents issuing effectively permanent bearer tokens by accident (e.g. typing 10 * time.Hour where 10 * time.Minute was intended).


Login β€” creating a token pair

// subject must be a UUID v7 (RFC 9562 Β§5.7).
pair, err := jwtMod.CreateTokens(userID, UserClaims{Name: "Ana", Role: "admin"})
if err != nil {
    // jwt.ErrInvalidSubject β€” subject is not a valid UUID v7
}

pair.AccessToken            // short-lived JWT for API requests
pair.AccessTokenExpiresAt   // time.Time β€” tell the client when to refresh
pair.RefreshToken           // long-lived JWT for token rotation
pair.RefreshTokenExpiresAt  // time.Time β€” when the user must log in again
pair.RefreshTokenHash       // HMAC-SHA256 hex digest β€” store this in your DB
pair.SessionID              // UUID v7 jti shared by both tokens β€” use as session PK

Never store the raw refresh token. Store only RefreshTokenHash.


Authenticating requests

claims, err := jwtMod.VerifyAccessToken(tokenFromHeader)
switch {
case errors.Is(err, jwt.ErrTokenExpired):
    // 401 β€” client should refresh
case errors.Is(err, jwt.ErrTokenInvalid):
    // 401 β€” tampered, wrong key, or issuer/audience mismatch
case errors.Is(err, jwt.ErrTokenMalformed):
    // 400 β€” not a JWT at all
case err != nil:
    // 401 β€” catch-all
}

fmt.Println(claims.Subject)    // UUID v7 user ID
fmt.Println(claims.Extra.Role) // "admin" β€” your custom claims
fmt.Println(claims.ExpiresAt)  // time.Time

[!NOTE] Verification enforces both iss (issuer) and aud (audience) match the values in jwt.Config. A token signed by a trusted key but minted for a different service is rejected with ErrTokenInvalid β€” this is the defense against accidental key reuse across services.


Rotating tokens

The recommended pattern β€” verify the hash before calling RotateTokens to prevent token-reuse attacks even if your database is compromised:

// 1. Compute the hash of the token the client presented.
incoming := jwtMod.HashRefreshToken(clientToken)

// 2. Look it up in your database.
session, err := db.FindSessionByHash(incoming)
if err != nil {
    return http.StatusUnauthorized
}

// 3. Use timing-safe comparison to verify the hash matches.
//    This prevents timing attacks on the lookup result.
if !jwtMod.VerifyRefreshTokenHash(clientToken, session.RefreshTokenHash) {
    return http.StatusUnauthorized
}

// 4. Rotate β€” authcore verifies the token's signature and expiry.
freshClaims := UserClaims{Name: session.UserName, Role: session.UserRole}
newPair, err := jwtMod.RotateTokens(clientToken, freshClaims)
if err != nil {
    return http.StatusUnauthorized
}

// 5. Atomically replace the old hash in your database.
db.ReplaceRefreshHash(session.ID, newPair.RefreshTokenHash)

// 6. Send the new tokens to the client.

Clock skew tolerance

In distributed systems, server clocks may drift by a few seconds. Set ClockSkewLeeway to accept tokens that expired within that window:

cfg.ClockSkewLeeway = 30 * time.Second

The leeway applies to both access and refresh token verification. Keep it small β€” large values reduce the security margin of short-lived tokens.


Password Hashing

πŸ”‘ Full reference β€” hashing, verifying, policy, tuning Β· click to expand

No boilerplate. No algorithm choices. Just secure password hashing that works.

Setup

auth, err := authcore.New(authcore.DefaultConfig())

// Zero-config β€” OWASP-recommended Argon2id defaults applied automatically.
pwdMod, err := password.New(auth)

That's it. No config required.

Why Argon2id? It's memory-hard: an attacker must allocate ~64 MiB of RAM per attempt, making GPU and ASIC brute-force attacks prohibitively expensive. bcrypt does not have this property.


Hashing a password

hash, err := pwdMod.Hash(userPassword)
switch {
case errors.Is(err, password.ErrWeakPassword):
    // 400 β€” tell the user exactly what's missing (message is descriptive)
case err != nil:
    // 500 β€” unexpected error
}
// Store hash in your database. Never store the plaintext.
db.StorePasswordHash(userID, hash)

Hash validates the password before spending CPU on hashing:

Rule Requirement
Length 12 – 64 characters
Uppercase At least one (A–Z, Unicode-aware)
Lowercase At least one (a–z, Unicode-aware)
Digit At least one (0–9)
Special At least one (anything that is not a letter or digit)

Each call also generates a fresh random salt, so two hashes of the same password are always different strings β€” but both verify correctly.

The stored string is fully self-describing (PHC format):

$argon2id$v=19$m=65536,t=3,p=2$<base64-salt>$<base64-hash>

Verifying a password

ok, err := pwdMod.Verify(submittedPassword, storedHash)
switch {
case errors.Is(err, password.ErrInvalidHash):
    // 500 β€” hash in the database is malformed
case !ok:
    // 401 β€” wrong password
}

Comparison is constant-time (crypto/subtle) β€” timing attacks are not possible. Parameters are always read from the stored hash, never from the current module config, and bounded to the same safe range (Memory 8 MiB – 4 GiB, Iterations ≀ 20, Parallelism β‰₯ 1) so a corrupted or malicious stored hash cannot force argon2.IDKey into an unbounded memory allocation.

[!NOTE] Both Hash and Verify normalise plaintext to Unicode NFC before processing. A password like cafΓ© hashes the same whether the user typed it on macOS (precomposed Γ©) or Linux (decomposed e + combining acute), so cross-platform account access works out of the box.


Tuning work parameters (optional)

The defaults are sized for 2 vCPUs / 4 GiB RAM. On more powerful hardware, crank them up β€” a hash should take roughly 200–500 ms:

pwdMod, err := password.New(auth, password.Config{
    Memory:      128 * 1024, // 128 MiB
    Iterations:  4,
    Parallelism: 4,          // match your guaranteed CPU core count
})
Field Default Minimum
Memory 65536 (64 MiB) 8192 (8 MiB)
Iterations 3 1
Parallelism 2 1

Old hashes stay valid. All parameters live inside the hash string itself. Changing the config only affects new hashes β€” existing users keep working.


Email Validation

πŸ“§ Full reference β€” validate, normalize, DNS MX Β· click to expand

Setup

emailMod, err := email.New(auth)
if err != nil {
    log.Fatal(err)
}
defer emailMod.Close() // stops the background cache eviction goroutine

Validating and normalizing

Always call ValidateAndNormalize instead of validating and normalizing separately. It lowercases, trims whitespace, and validates in a single call β€” ensuring the value you store is always in canonical form:

normalized, err := emailMod.ValidateAndNormalize(req.Email)
switch {
case errors.Is(err, email.ErrInvalidEmail):
    // 400 β€” tell the user exactly what failed (message is descriptive)
    c.JSON(400, map[string]string{"error": errors.Unwrap(err).Error()})
    return
case err != nil:
    // 500 β€” unexpected error
}
// Store normalized β€” always lowercase, trimmed.
db.StoreUser(normalized, ...)

Validation rules (RFC 5321 / RFC 5322):

Rule Requirement
Total length 1 – 254 characters
Format One @ separating a non-empty local part and domain
Local part ≀ 64 characters
Domain At least one dot; no leading, trailing, or consecutive dots
Domain labels 1 – 63 characters each

Always normalize before storing and before querying. This ensures consistent lookup β€” User@EXAMPLE.COM and user@example.com are the same address.

[!NOTE] Internationalised domains (IDN) are supported. Unicode domains are converted to ASCII punycode during normalisation:

user@mΓΌnchen.de  β†’  user@xn--mnchen-3ya.de
user@δΎ‹γˆ.jp     β†’  user@xn--r8jz45g.jp

Store the ASCII form so DNS lookups and database comparisons are deterministic.


Verifying a domain can receive email

VerifyDomain performs an optional DNS MX lookup to confirm the domain is configured to receive email. Call it after ValidateAndNormalize when you want to reject obviously fake domains before sending a verification email.

Results are cached per domain (default 5 minutes) and DNS lookups for the same domain are deduplicated via singleflight β€” safe for high-concurrency workloads.

ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()

err = emailMod.VerifyDomain(ctx, normalized)
switch {
case errors.Is(err, email.ErrDomainNoMX):
    // 400 β€” domain exists but cannot receive email
    c.JSON(400, map[string]string{"error": "email domain cannot receive messages"})
    return
case errors.Is(err, email.ErrDomainUnresolvable):
    // DNS lookup failed β€” do NOT block the user; log and continue
    log.Warn("DNS check unavailable: %v", err)
}

ErrDomainUnresolvable is a soft failure. DNS can be temporarily unavailable due to network issues unrelated to the user's input. Never block a registration on this error β€” log it and proceed.


Username Validation

πŸ‘€ Full reference β€” validate, normalize, reserved names Β· click to expand

Setup

userMod, err := username.New(auth)
if err != nil {
    log.Fatal(err)
}

Validating and normalizing

Always call ValidateAndNormalize β€” it lowercases, trims whitespace, and validates in a single call, ensuring the value you store is always in canonical form:

normalized, err := userMod.ValidateAndNormalize(req.Username)
if err != nil {
    // errors.Unwrap(err).Error() contains the specific rule that failed.
    c.JSON(400, map[string]string{"error": errors.Unwrap(err).Error()})
    return
}
db.StoreUser(normalized, ...) // always lowercase, trimmed, validated

Validation rules:

Rule Requirement
Length 3 – 32 characters (fixed)
Allowed characters [a-z0-9_-] only
First character Letter or digit (not _ or -)
Last character Letter or digit (not _ or -)
Consecutive specials __, --, _-, -_ are rejected
Reserved names Built-in blocklist (fixed)

Always normalize before storing and before querying. Alice123 and alice123 are the same username β€” store only the canonical (normalized) form.


Key Management

πŸ—οΈ Full reference β€” files, persistence, kid header, containers Β· click to expand

On first run authcore creates KeysDir (default .authcore) and generates:

File Format Mode Purpose
ed25519_private.pem PKCS#8 PEM 0600 Signing key
ed25519_public.pem PKIX PEM 0644 Verification key
refresh_secret.key 32-byte hex 0600 HMAC-SHA256 key for refresh token hashing
.gitignore * 0600 Prevents accidental commits

On subsequent starts the files are loaded and the key pair is validated for consistency. If only one PEM file is present, New() returns ErrKeyManager β€” delete both to regenerate.

In containers or CI, point KeysDir at a mounted secrets volume:

cfg := authcore.DefaultConfig()
cfg.KeysDir = os.Getenv("AUTHCORE_KEYS_DIR") // e.g. /run/secrets/authcore
auth, err := authcore.New(cfg)

The KeyID() accessor returns a 16-character hex digest derived from the public key. It is embedded in every token's kid JOSE header, enabling zero-downtime key rotation. Verification rejects tokens whose kid does not match the module's current key id, so a future multi-key deployment only ever accepts tokens minted under an authorised key.

[!NOTE] Key-file loaders enforce a 4 KiB size cap. A healthy Ed25519 PEM is ~200 bytes; anything larger is refused before it reaches pem.Decode, protecting startup from a corrupted or attacker-replaced key file that would otherwise be loaded whole into memory.


Configuration

βš™οΈ Full reference β€” EnableLogs, Timezone, Logger, KeysDir Β· click to expand
type Config struct {
    EnableLogs bool             // emit log output; default true via DefaultConfig()
    Timezone   *time.Location   // time zone for all operations; default time.UTC
    Logger     authcore.Logger  // custom logger (slog, zap, zerolog, …); overrides EnableLogs
    KeysDir    string           // key storage directory; default ".authcore"
}

Always start from DefaultConfig() and override only what you need:

cfg := authcore.DefaultConfig()
cfg.EnableLogs = false                    // silence output in tests
cfg.Logger     = slog.Default()           // use your application logger
cfg.KeysDir    = "/run/secrets/authcore"  // absolute path in containers

Note on EnableLogs: Go cannot distinguish EnableLogs = false from a zero-value Config{}. Start from DefaultConfig() to get EnableLogs = true, then set it to false to explicitly opt out.


Custom Logger

πŸ“ Full reference β€” Logger interface, slog / zap / zerolog adapters Β· click to expand

Implement the Logger interface to route authcore output through your existing log pipeline:

type Logger interface {
    Debug(msg string, args ...any)
    Info(msg string, args ...any)
    Warn(msg string, args ...any)
    Error(msg string, args ...any)
}

*slog.Logger satisfies this interface directly:

cfg := authcore.DefaultConfig()
cfg.Logger = slog.Default() // or slog.New(yourHandler)

When Config.Logger is non-nil it takes precedence over EnableLogs.


Testing Your Auth Layer

πŸ§ͺ Full reference β€” Provider stub recipe, deterministic time Β· click to expand

Every AuthCore module accepts a Provider interface β€” not a concrete *AuthCore β€” which means you never need to generate real keys or touch the disk in tests. Pass in a stub.

package mypkg_test

import (
    "crypto/ed25519"
    "testing"

    "github.com/Jaro-c/authcore"
    "github.com/Jaro-c/authcore/auth/jwt"
)

// stubProvider implements authcore.Provider with fixed, in-memory dependencies.
type stubProvider struct {
    cfg    authcore.Config
    logger authcore.Logger
    keys   authcore.Keys
}

func (s *stubProvider) Config() authcore.Config { return s.cfg }
func (s *stubProvider) Logger() authcore.Logger { return s.logger }
func (s *stubProvider) Keys() authcore.Keys     { return s.keys }

// stubKeys satisfies authcore.Keys with values you control in the test.
type stubKeys struct {
    priv   ed25519.PrivateKey
    pub    ed25519.PublicKey
    secret []byte
    kid    string
}

func (k *stubKeys) PrivateKey() ed25519.PrivateKey { return k.priv }
func (k *stubKeys) PublicKey() ed25519.PublicKey   { return k.pub }
func (k *stubKeys) RefreshSecret() []byte          { return k.secret }
func (k *stubKeys) KeyID() string                  { return k.kid }

func TestMyHandler(t *testing.T) {
    pub, priv, _ := ed25519.GenerateKey(nil)
    p := &stubProvider{
        cfg:    authcore.DefaultConfig(),
        logger: &noopLogger{},
        keys:   &stubKeys{priv: priv, pub: pub, secret: []byte("test-secret-32-bytes-long-xxxxxx"), kid: "test"},
    }

    jwtMod, err := jwt.New[struct{}](p, jwt.DefaultConfig())
    if err != nil {
        t.Fatal(err)
    }
    // ... exercise your handler against jwtMod, with no file I/O.
}

[!TIP] For deterministic time in tests (e.g. to assert ExpiresAt), override Config.Timezone and use time.Now() equivalents through the same clock your production code reads from.


Migrating from bcrypt / other libraries

πŸ”„ Full reference β€” re-hash on next login pattern Β· click to expand

AuthCore can take over password verification from another library without forcing every user to reset their password. Use the "re-hash on next login" pattern:

func Login(email, submitted string) error {
    user := db.FindUser(email)

    // 1. Try authcore first. New users and already-migrated users land here.
    if ok, _ := pwdMod.Verify(submitted, user.PasswordHash); ok {
        return issueSession(user)
    }

    // 2. Fall back to the legacy hasher (e.g. bcrypt).
    if !legacyBcrypt.Compare(submitted, user.PasswordHash) {
        return ErrWrongPassword
    }

    // 3. Password is correct β€” upgrade the hash transparently.
    newHash, err := pwdMod.Hash(submitted)
    if err != nil {
        return err
    }
    db.UpdatePasswordHash(user.ID, newHash)
    return issueSession(user)
}

After a few weeks, most active users are migrated and you can delete the legacy path. Dormant accounts can be forced into password reset the next time they log in.

[!NOTE] If your existing hashes are already in PHC Argon2id format ($argon2id$v=19$…), no migration is needed β€” pwdMod.Verify reads all parameters from the stored hash, regardless of which library produced it.


Project Layout

πŸ“ Full tree β€” module organisation + import paths Β· click to expand
authcore/
β”œβ”€β”€ authcore.go          # New() Β· AuthCore struct Β· compile-time interface assertions
β”œβ”€β”€ config.go            # Config Β· DefaultConfig()
β”œβ”€β”€ logger.go            # Logger interface Β· stdlib and noop implementations
β”œβ”€β”€ module.go            # Keys Β· Provider Β· Module interfaces
β”œβ”€β”€ errors.go            # Sentinel errors
β”‚
β”œβ”€β”€ internal/
β”‚   β”œβ”€β”€ clock/           # Timezone-aware Clock β€” injected for deterministic tests
β”‚   └── keymanager/      # Ed25519 + HMAC key generation, persistence, validation
β”‚
β”œβ”€β”€ auth/
β”‚   β”œβ”€β”€ jwt/             # JSON Web Token authentication (EdDSA / Ed25519)
β”‚   β”œβ”€β”€ password/        # Argon2id password hashing
β”‚   β”œβ”€β”€ email/           # Email validation, normalization, DNS MX verification
β”‚   └── username/        # Username validation, normalization, reserved name blocklist
β”‚
└── examples/
    β”œβ”€β”€ basic/           # authcore initialisation strategies
    β”œβ”€β”€ jwt/             # JWT: create, verify, rotate
    β”œβ”€β”€ password/        # Password: policy, hash, verify
    β”œβ”€β”€ email/           # Email: validate, normalize, DNS MX verification
    β”œβ”€β”€ username/        # Username: validate, normalize, reserved names
    β”œβ”€β”€ fiber/           # Full auth API with Fiber v3 (separate module)
    └── gin/             # Full auth API with Gin (separate module)
Import path Visibility Purpose
github.com/Jaro-c/authcore public Core types and entry point
…/auth/jwt public JWT module
…/auth/password public Argon2id password hashing module
…/auth/email public Email validation, normalization, MX verification
…/auth/username public Username validation, normalization, reserved names
…/internal/clock internal Shared time abstraction
…/internal/keymanager internal Key generation and persistence

Writing a Module

🧩 Full guide β€” Provider contract + minimal module skeleton Β· click to expand

Modules depend on authcore.Provider β€” not the concrete *AuthCore β€” so they remain independently testable without touching the filesystem or generating real keys.

// Provider is the narrow interface that *AuthCore satisfies.
type Provider interface {
    Config() Config  // shared configuration
    Logger() Logger  // shared logger sink
    Keys()   Keys    // Ed25519 keys + HMAC secret
}

// Module is the marker interface every sub-module must implement.
type Module interface {
    Name() string // stable, lowercase identifier e.g. "jwt"
}

Minimal module skeleton:

package mypkg

import "github.com/Jaro-c/authcore"

type MyModule struct {
    log authcore.Logger
    // ...
}

func New(p authcore.Provider, cfg Config) (*MyModule, error) {
    return &MyModule{log: p.Logger()}, nil
}

func (m *MyModule) Name() string { return "mypkg" }

In tests, inject a stub Provider that returns fixed keys β€” no disk I/O required.


Error Handling

🚨 Full reference β€” every sentinel error, per package Β· click to expand

authcore package

Error When
authcore.ErrInvalidConfig Config validation failed
authcore.ErrInvalidTimezone Config.Timezone is nil
authcore.ErrKeyManager key generation or loading failed

auth/jwt package

Error When
jwt.ErrInvalidConfig jwt.Config validation failed
jwt.ErrTokenExpired exp claim is in the past (beyond leeway)
jwt.ErrTokenInvalid signature invalid, unsupported algorithm, or iss / aud claim does not match Config
jwt.ErrTokenMalformed not a valid three-part JWT string
jwt.ErrWrongTokenType access token passed where refresh expected, or vice-versa
jwt.ErrInvalidSubject subject passed to CreateTokens is not a UUID v7

auth/password package

Error When
password.ErrInvalidConfig password.Config validation failed
password.ErrInvalidHash stored hash is malformed or not Argon2id PHC format
password.ErrWeakPassword plaintext does not meet the built-in policy

auth/email package

Error Client-safe? When
email.ErrInvalidEmail βœ“ Yes Address fails RFC 5321/5322 validation; errors.Unwrap gives the specific rule
email.ErrDomainNoMX βœ“ Yes Domain exists but has no MX records (cannot receive email)
email.ErrDomainUnresolvable βœ— No DNS lookup failed; treat as soft failure, do not block the user

auth/username package

Error Client-safe? When
username.ErrInvalidUsername βœ“ Yes Username fails a validation rule; errors.Unwrap gives the specific rule
username.ErrInvalidConfig βœ— No username.Config validation failed (startup error, treat as 500)

Always use errors.Is for error inspection β€” errors may be wrapped:

claims, err := jwtMod.VerifyAccessToken(token)
if errors.Is(err, jwt.ErrTokenExpired) {
    // prompt the client to refresh
}

FAQ

Do I need a database to use AuthCore?

No β€” AuthCore never touches your database. It hashes passwords, signs tokens, and validates input. You store hashes, usernames, and refresh-token hashes wherever your app already keeps data (Postgres, Redis, SQLite, even an in-memory map for a toy project).

What is a UUID v7 and why does `CreateTokens` require one?

UUID v7 is a 128-bit identifier whose first 48 bits are a millisecond Unix timestamp (RFC 9562 Β§5.7). That means UUID v7 values sort naturally by creation time β€” ideal as a database primary key and as a stable session identifier. AuthCore requires v7 for the sub claim so your sessions always sort chronologically.

Libraries that generate UUID v7 in Go: github.com/google/uuid (β‰₯ v1.6), github.com/gofrs/uuid.

Do I really need refresh token rotation?

Short answer: yes, if your refresh token lives longer than a few minutes. Rotation limits the blast radius of a stolen refresh token β€” once the legitimate client rotates, the stolen copy is rejected. Combined with storing only the hash of refresh tokens on the server, an attacker who dumps your database still cannot forge new sessions.

My access token fails verification in a distributed system β€” is clock skew the issue?

Yes. Different servers may have clocks that drift a few seconds apart, causing ErrTokenExpired on a brand-new token. Set ClockSkewLeeway in your JWT config:

cfg := jwt.DefaultConfig()
cfg.ClockSkewLeeway = 5 * time.Second

Keep it small (5–30 s). Larger values erode the security margin of short-lived tokens.

I'm getting `ErrKeyManager` on startup. What went wrong?

AuthCore could not read or create its key files. Check that:

  1. KeysDir (default .authcore) is writable by the process.
  2. The directory is not a read-only filesystem (common in some container setups).
  3. Existing key files are not corrupted β€” delete .authcore and let AuthCore regenerate them. Warning: regenerating keys invalidates every token currently in circulation.
Can I verify tokens issued before I rotated my signing key?

Yes. AuthCore embeds the kid (key ID) in every token header. When you add a new key pair, keep the old public key in the key store under its original kid. The verifier will select the right key automatically. See the Key Management section for the rotation workflow.

My existing password hashes were created with a different library. Can I migrate?

Yes. See Migrating from bcrypt / other libraries for the re-hash-on-next-login pattern. If your hashes are already in PHC Argon2id format ($argon2id$v=19$…), no migration is needed at all β€” pwdMod.Verify reads parameters from the stored hash.

Can I run AuthCore in Docker / Kubernetes?

Yes. Point KeysDir at a mounted secrets volume so keys survive container restarts and are shared across replicas:

cfg := authcore.DefaultConfig()
cfg.KeysDir = os.Getenv("AUTHCORE_KEYS_DIR") // e.g. /run/secrets/authcore

Pre-generate the key files once (a one-shot job that runs AuthCore against the volume), then mount them read-only into your app.

The `Hash` call is slower than expected in tests. Is that normal?

Yes β€” Argon2id deliberately takes ~100–300 ms and allocates 64 MiB of RAM per call. In tests, use a low-cost config to avoid slow suites:

pwd, _ := password.New(auth, password.Config{
    Memory:      8 * 1024, // minimum allowed (8 MiB)
    Iterations:  1,
    Parallelism: 1,
})
Does AuthCore ship an HTTP server, middleware, or CSRF protection?

No β€” AuthCore gives you the primitives (hash, sign, verify, rotate) and stays framework-agnostic. See examples/fiber and examples/gin for wiring into a real HTTP stack, including protected-route middleware.


Coverage

πŸ“Š Sunburst coverage graph Β· click to expand

Sunburst

Each ring is a directory; each slice is a file. Greener wedges are better covered. Click through for the full per-line report on Codecov.


Roadmap

Shipped:

  • βœ… Core library β€” key management, logger, clock, Provider interface
  • βœ… auth/jwt β€” EdDSA token issuance, verification, rotation, timing-safe hash
  • βœ… auth/password β€” Argon2id password hashing with PHC format
  • βœ… auth/email β€” RFC 5321/5322 validation, normalization, DNS MX verification with cache
  • βœ… auth/username β€” validation, normalization, reserved name blocklist

Planned (no hard ETA β€” subject to community input):

  • 🚧 auth/apikey β€” opaque key generation with a pluggable store interface
  • 🚧 Key rotation helpers β€” zero-downtime rotation via the kid header
  • πŸ• auth/oauth β€” OAuth 2.0 / OIDC provider integration (larger scope, later)

Have an opinion on priority, or a use case we haven't thought about? Open a discussion β€” the roadmap bends toward real user needs.


API Stability

ℹ️ Versioning policy β€” v1.x public API is frozen Β· click to expand

authcore follows Semantic Versioning.

  • v1.x (current) β€” the public API is frozen under semver guarantees. Breaking changes only ship in a new major version (v2.0.0). Minor releases add features; patch releases fix bugs or harden existing code paths without changing public shapes.
  • v1.2.0 shipped defence-in-depth hardenings: JWT TTL caps, kid header matching, Unicode NFC password normalisation, IDN email support, and a PEM file size cap. See CHANGELOG.md for the full release history.

Internal packages (internal/…) carry no compatibility guarantees at any version and must not be imported from outside the module.


Get Started Now

Ready to add secure auth to your Go app? Here's the 2-minute path:

  1. πŸ“¦ Install β€” go get github.com/Jaro-c/authcore
  2. ⚑ Copy the Quick Start above into your main.go
  3. πŸ§ͺ Run a module example end-to-end β€” examples/jwt, examples/password, examples/fiber, examples/gin
  4. πŸ“– Reference the full API on pkg.go.dev

⭐ If AuthCore saved you from writing your own password hasher, please star the repo β€” it helps others find it.

❀️ Keeping it free and maintained takes time. If you or your company depend on AuthCore, consider sponsoring on GitHub β€” any amount funds continued security audits and new modules.


Contributing

Contributions are welcome! Please read the Contributing Guidelines and Code of Conduct before opening a pull request. Bug reports, feature ideas, and docs improvements are all valuable β€” open an issue or discussion any time.

Security

To report a vulnerability, please follow the Security Policy. Do not open a public issue for security bugs β€” coordinated disclosure keeps users safe while a fix is prepared.

License

Released under the MIT License.

Documentation ΒΆ

Overview ΒΆ

Package authcore provides a modular, production-ready authentication library for Go.

Quick start ΒΆ

cfg := authcore.DefaultConfig()
auth, err := authcore.New(cfg)
if err != nil {
    log.Fatal(err)
}

Modules ΒΆ

Authentication mechanisms live under the auth/ sub-tree. Each module is an independent package that accepts an authcore.Provider β€” the narrow interface that *AuthCore satisfies β€” so modules remain independently testable.

auth/jwt    β€” JSON Web Tokens
auth/apikey β€” opaque API key generation and validation
auth/oauth  β€” OAuth 2.0 / OIDC

Key management ΒΆ

authcore automatically generates and persists cryptographic keys on first run. Keys are stored in Config.KeysDir (default ".authcore") and are protected by a .gitignore so they are never accidentally committed.

Extending authcore ΒΆ

To write a new module, accept a Provider in your constructor and implement the Module interface:

type MyModule struct { ... }

func New(p authcore.Provider, cfg Config) (*MyModule, error) { ... }

func (m *MyModule) Name() string { return "mymodule" }

Index ΒΆ

Examples ΒΆ

Constants ΒΆ

This section is empty.

Variables ΒΆ

View Source
var (
	// ErrInvalidConfig is returned when the supplied Config fails validation.
	ErrInvalidConfig = errors.New("authcore: invalid configuration")

	// ErrInvalidTimezone is returned when Config.Timezone is nil.
	ErrInvalidTimezone = errors.New("authcore: timezone must not be nil")

	// ErrKeyManager is returned when the key management system fails to
	// initialise, generate, or load cryptographic material.
	ErrKeyManager = errors.New("authcore: key manager failure")
)

Sentinel errors returned by the authcore package. Use errors.Is to check for these in calling code.

Functions ΒΆ

This section is empty.

Types ΒΆ

type AuthCore ΒΆ

type AuthCore struct {
	// contains filtered or unexported fields
}

AuthCore is the central object of the library. It holds shared configuration, the logger, and the key manager, and is the entry point for all authentication sub-modules.

Create one instance per application; it is safe for concurrent use.

func New ΒΆ

func New(cfg Config) (*AuthCore, error)

New creates and returns a fully initialised *AuthCore.

It applies defaults to any zero-value fields in cfg, validates the result, selects or creates a logger, and initialises the key manager.

On first run the key manager creates Config.KeysDir and generates fresh Ed25519 keys and an HMAC refresh secret. On subsequent runs the existing files are loaded and validated.

New returns a wrapped ErrInvalidConfig on bad configuration, or a wrapped ErrKeyManager when key initialisation fails. Both are unwrappable with errors.Is.

// Minimal β€” all defaults apply.
auth, err := authcore.New(authcore.DefaultConfig())

// Custom keys directory (useful in containers or tests).
cfg := authcore.DefaultConfig()
cfg.KeysDir = "/run/secrets/authcore"
auth, err := authcore.New(cfg)
Example ΒΆ
package main

import (
	"fmt"
	"os"

	"github.com/Jaro-c/authcore"
)

func main() {
	dir, _ := os.MkdirTemp("", "authcore-example-")
	defer func() { _ = os.RemoveAll(dir) }()

	cfg := authcore.DefaultConfig()
	cfg.EnableLogs = false
	cfg.KeysDir = dir

	auth, err := authcore.New(cfg)
	if err != nil {
		panic(err)
	}
	fmt.Println(auth.Keys() != nil)
}
Output:
true

func (*AuthCore) Config ΒΆ

func (a *AuthCore) Config() Config

Config returns a copy of the active configuration. Sub-modules should call this to read shared settings.

func (*AuthCore) Keys ΒΆ

func (a *AuthCore) Keys() Keys

Keys returns the library's managed cryptographic material. Sub-modules use this to obtain the Ed25519 signing key and the HMAC refresh secret without needing direct file-system access.

func (*AuthCore) Logger ΒΆ

func (a *AuthCore) Logger() Logger

Logger returns the active Logger. Sub-modules must use this logger rather than creating their own so that all output flows through a single, user-configured sink.

type Config ΒΆ

type Config struct {
	// EnableLogs controls whether the library emits log output.
	// Defaults to true.
	EnableLogs bool

	// Timezone is used for any time-sensitive operations inside the library.
	// Defaults to time.UTC.
	Timezone *time.Location

	// Logger allows callers to inject a custom logging backend
	// (e.g. slog, zap, zerolog). When set, EnableLogs is ignored.
	// If nil and EnableLogs is true, a default stdlib logger is used.
	Logger Logger

	// KeysDir is the directory where authcore creates and stores cryptographic
	// key files (ed25519_private.pem, ed25519_public.pem, refresh_secret.key).
	//
	// Defaults to ".authcore" relative to the current working directory.
	// Use an absolute path in containerised or restricted environments.
	//
	// The directory is created automatically on first use. A .gitignore is
	// written inside it to prevent accidental commits of key material.
	KeysDir string
}

Config holds the top-level configuration for an AuthCore instance. Zero values are replaced by safe defaults via DefaultConfig or applyDefaults.

func DefaultConfig ΒΆ

func DefaultConfig() Config

DefaultConfig returns a Config populated with safe, production-ready defaults.

cfg := authcore.DefaultConfig()
cfg.EnableLogs = false          // disable logs for tests
auth, err := authcore.New(cfg)
Example ΒΆ
package main

import (
	"fmt"

	"github.com/Jaro-c/authcore"
)

func main() {
	cfg := authcore.DefaultConfig()
	fmt.Println(cfg.KeysDir)
}
Output:
.authcore

type Keys ΒΆ

type Keys interface {
	// PrivateKey returns the Ed25519 private key used for signing tokens.
	// The caller must not modify the returned slice.
	PrivateKey() ed25519.PrivateKey

	// PublicKey returns the Ed25519 public key used for signature verification.
	// The caller must not modify the returned slice.
	PublicKey() ed25519.PublicKey

	// RefreshSecret returns the 32-byte HMAC-SHA256 key used to hash refresh
	// tokens before they are stored in a database.
	// The caller must not modify the returned slice.
	RefreshSecret() []byte

	// KeyID returns the stable identifier for the current signing key.
	// It is derived from the public key and embedded in the "kid" JOSE header
	// of every issued token so that verifiers can select the correct key when
	// multiple keys are in circulation (e.g. during key rotation).
	KeyID() string
}

Keys provides read-only access to authcore's managed cryptographic material.

The concrete implementation is *keymanager.KeyManager (internal). Sub-modules receive Keys through the Provider interface and must not cache or copy the raw key bytes β€” always call the accessor methods.

type Logger ΒΆ

type Logger interface {
	Debug(msg string, args ...any)
	Info(msg string, args ...any)
	Warn(msg string, args ...any)
	Error(msg string, args ...any)
}

Logger is the logging interface used throughout authcore. Implement this interface to plug in any logging backend (slog, zap, zerolog, etc.).

type Module ΒΆ

type Module interface {
	// Name returns the unique, lowercase identifier of this module.
	// It must be stable across releases because callers may use it as a key.
	// Examples: "jwt", "apikey", "oauth"
	Name() string
}

Module is the minimal contract every authcore authentication sub-module must implement. It acts as a marker interface today and will grow as shared lifecycle requirements (e.g. Close, HealthCheck) are identified.

Available implementations and their concrete constructors:

auth/jwt      β€” JSON Web Token authentication (EdDSA / Ed25519)
                  jwt.New[T any](p authcore.Provider, cfg jwt.Config) (*jwt.JWT[T], error)
auth/password β€” Argon2id password hashing
                  password.New(p authcore.Provider, cfg ...password.Config) (*password.Password, error)
auth/email    β€” email validation, normalization, DNS MX verification
                  email.New(p authcore.Provider, cfg ...email.Config) (*email.Email, error)
auth/username β€” username validation, normalization, reserved name blocklist
                  username.New(p authcore.Provider) (*username.Username, error)

Conventions shared by every module:

  1. The first argument is always an authcore.Provider (never a concrete *AuthCore).
  2. Omitting cfg (where variadic) or passing a zero-value Config applies safe, production-ready defaults via each module's applyDefaults helper.
  3. Constructors return a pointer receiver; concrete types are safe for concurrent use across goroutines after construction completes.

type Provider ΒΆ

type Provider interface {
	// Config returns a copy of the active library configuration.
	Config() Config

	// Logger returns the active logger shared across the library.
	// Modules must use this logger rather than creating their own so that
	// all output flows through a single, user-configured sink.
	Logger() Logger

	// Keys returns the library's managed cryptographic material.
	// Sub-modules must call this to obtain signing keys and the refresh secret.
	Keys() Keys
}

Provider is the narrow interface that *AuthCore satisfies.

Sub-modules must accept a Provider rather than *AuthCore directly. This decouples each module from the concrete library type, which has two important consequences:

  1. Testability β€” unit tests can inject a stub Provider without constructing a real AuthCore or touching the file system / network.

  2. Stability β€” if AuthCore gains new methods in a future release, existing module code is unaffected because it only depends on the methods below.

Guaranteed implementation: *AuthCore.

Directories ΒΆ

Path Synopsis
auth
email
Package email provides email address validation and normalization for authcore.
Package email provides email address validation and normalization for authcore.
jwt
Package jwt provides JSON Web Token (JWT) authentication for authcore.
Package jwt provides JSON Web Token (JWT) authentication for authcore.
password
Package password provides Argon2id password hashing for authcore.
Package password provides Argon2id password hashing for authcore.
username
Package username provides username validation and normalization for authcore.
Package username provides username validation and normalization for authcore.
examples
basic command
Command basic demonstrates how to initialise authcore with different configuration strategies.
Command basic demonstrates how to initialise authcore with different configuration strategies.
email command
Command email demonstrates the auth/email module: validating, normalizing, and optionally verifying that an email domain can receive messages via DNS MX lookup.
Command email demonstrates the auth/email module: validating, normalizing, and optionally verifying that an email domain can receive messages via DNS MX lookup.
jwt command
Command jwt demonstrates the auth/jwt module: creating tokens, verifying access tokens, and rotating a refresh token.
Command jwt demonstrates the auth/jwt module: creating tokens, verifying access tokens, and rotating a refresh token.
password command
Command password demonstrates the auth/password module: hashing, verifying, and fail-fast policy validation.
Command password demonstrates the auth/password module: hashing, verifying, and fail-fast policy validation.
username command
Command username demonstrates the auth/username module: validating and normalizing usernames against the library's fixed rules.
Command username demonstrates the auth/username module: validating and normalizing usernames against the library's fixed rules.
internal
clock
Package clock provides a time-source abstraction used across all authcore modules.
Package clock provides a time-source abstraction used across all authcore modules.
keymanager
Package keymanager handles the creation, storage, and loading of authcore's cryptographic key material.
Package keymanager handles the creation, storage, and loading of authcore's cryptographic key material.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL