authkit initial

This commit is contained in:
juancwu 2026-04-26 01:36:53 +00:00
commit 134393fbca
43 changed files with 5188 additions and 1 deletions

154
service_session.go Normal file
View file

@ -0,0 +1,154 @@
package authkit
import (
"context"
"net/http"
"net/netip"
"time"
"git.juancwu.dev/juancwu/errx"
"github.com/google/uuid"
)
// IssueSession mints an opaque session ID, persists the session record, and
// returns the plaintext (for the cookie) plus the stored Session.
func (a *Auth) IssueSession(ctx context.Context, userID uuid.UUID, userAgent string, ip netip.Addr) (string, *Session, error) {
const op = "authkit.Auth.IssueSession"
plaintext, hash, err := mintSecret(prefixSession, a.cfg.Random)
if err != nil {
return "", nil, errx.Wrap(op, err)
}
now := a.now()
expires := now.Add(a.cfg.SessionIdleTTL)
if cap := now.Add(a.cfg.SessionAbsoluteTTL); expires.After(cap) {
expires = cap
}
s := &Session{
IDHash: hash,
UserID: userID,
UserAgent: userAgent,
IP: ip,
CreatedAt: now,
LastSeenAt: now,
ExpiresAt: expires,
}
if err := a.deps.Sessions.CreateSession(ctx, s); err != nil {
return "", nil, errx.Wrap(op, err)
}
return plaintext, s, nil
}
// AuthenticateSession validates an opaque session string, slides the TTL,
// resolves the user's roles+permissions, and returns a Principal. Expired or
// unknown sessions return ErrSessionInvalid.
func (a *Auth) AuthenticateSession(ctx context.Context, plaintext string) (*Principal, error) {
const op = "authkit.Auth.AuthenticateSession"
hash, ok := parseSecret(prefixSession, plaintext)
if !ok {
return nil, errx.Wrap(op, ErrSessionInvalid)
}
s, err := a.deps.Sessions.GetSession(ctx, hash)
if err != nil {
return nil, errx.Wrap(op, err)
}
now := a.now()
if !s.ExpiresAt.After(now) {
_ = a.deps.Sessions.DeleteSession(ctx, hash)
return nil, errx.Wrap(op, ErrSessionInvalid)
}
// Slide the idle TTL, capped at created_at + AbsoluteTTL so an active
// session still expires at the absolute boundary.
newExpires := now.Add(a.cfg.SessionIdleTTL)
if cap := s.CreatedAt.Add(a.cfg.SessionAbsoluteTTL); newExpires.After(cap) {
newExpires = cap
}
if err := a.deps.Sessions.TouchSession(ctx, hash, now, newExpires); err != nil {
return nil, errx.Wrap(op, err)
}
roles, perms, err := a.resolveRolesAndPermissions(ctx, s.UserID)
if err != nil {
return nil, errx.Wrap(op, err)
}
return &Principal{
UserID: s.UserID,
Method: AuthMethodSession,
SessionID: hash,
Roles: roles,
Permissions: perms,
IssuedAt: s.CreatedAt,
ExpiresAt: newExpires,
}, nil
}
// RevokeSession deletes a single session by its plaintext id. Idempotent:
// missing sessions are not an error (logout twice should not 500).
func (a *Auth) RevokeSession(ctx context.Context, plaintext string) error {
const op = "authkit.Auth.RevokeSession"
hash, ok := parseSecret(prefixSession, plaintext)
if !ok {
return nil
}
if err := a.deps.Sessions.DeleteSession(ctx, hash); err != nil {
return errx.Wrap(op, err)
}
return nil
}
// RevokeAllUserSessions kills every active session for the user and bumps
// the user's session_version (invalidating outstanding JWT access tokens).
func (a *Auth) RevokeAllUserSessions(ctx context.Context, userID uuid.UUID) error {
const op = "authkit.Auth.RevokeAllUserSessions"
if err := a.deps.Sessions.DeleteUserSessions(ctx, userID); err != nil {
return errx.Wrap(op, err)
}
if _, err := a.deps.Users.BumpSessionVersion(ctx, userID); err != nil {
return errx.Wrap(op, err)
}
return nil
}
// SessionCookie builds an *http.Cookie pre-configured from Config. Pass the
// plaintext returned by IssueSession; pass the matching ExpiresAt from the
// returned *Session as `expires`. To clear a cookie at logout, pass an empty
// plaintext and a past expiry.
func (a *Auth) SessionCookie(plaintext string, expires time.Time) *http.Cookie {
c := &http.Cookie{
Name: a.cfg.SessionCookieName,
Value: plaintext,
Path: a.cfg.SessionCookiePath,
Domain: a.cfg.SessionCookieDomain,
Secure: a.cfg.SessionCookieSecure,
HttpOnly: a.cfg.SessionCookieHTTPOnly,
SameSite: a.cfg.SessionCookieSameSite,
Expires: expires,
}
if plaintext == "" {
c.MaxAge = -1
}
return c
}
// resolveRolesAndPermissions fetches the user's role names and the union of
// their permission names. Both are returned as flat string slices for cheap
// containment checks on the Principal.
func (a *Auth) resolveRolesAndPermissions(ctx context.Context, userID uuid.UUID) ([]string, []string, error) {
roles, err := a.deps.Roles.GetUserRoles(ctx, userID)
if err != nil {
return nil, nil, err
}
perms, err := a.deps.Permissions.GetUserPermissions(ctx, userID)
if err != nil {
return nil, nil, err
}
rNames := make([]string, len(roles))
for i, r := range roles {
rNames[i] = r.Name
}
pNames := make([]string, len(perms))
for i, p := range perms {
pNames[i] = p.Name
}
return rNames, pNames, nil
}