package auth import ( "context" "database/sql" "fmt" "net/http" "net/url" "strings" "time" "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" ) const ( SessionCookieName = "cairnquire_session" sessionTTL = 24 * time.Hour challengeTTL = 5 * time.Minute deviceCodeTTL = 15 * time.Minute devicePollSeconds = 5 ) type Service struct { repo *Repository webauthn *webauthn.WebAuthn publicOrigin string } func NewService(repo *Repository, publicOrigin string) (*Service, error) { if publicOrigin == "" { publicOrigin = "http://localhost:8080" } parsed, err := url.Parse(publicOrigin) if err != nil { return nil, fmt.Errorf("parse public origin: %w", err) } rpID := parsed.Hostname() if rpID == "" { return nil, fmt.Errorf("public origin must include host") } web, err := webauthn.New(&webauthn.Config{ RPID: rpID, RPDisplayName: "Cairnquire", RPOrigins: []string{publicOrigin}, AuthenticatorSelection: protocol.AuthenticatorSelection{ ResidentKey: protocol.ResidentKeyRequirementPreferred, UserVerification: protocol.VerificationPreferred, }, AttestationPreference: protocol.PreferNoAttestation, }) if err != nil { return nil, err } return &Service{repo: repo, webauthn: web, publicOrigin: publicOrigin}, nil } func (s *Service) GetInstanceSettings(ctx context.Context) (InstanceSettings, error) { return s.repo.GetInstanceSettings(ctx) } func (s *Service) CompleteInitialSetup(ctx context.Context, email, displayName, password string, signupsEnabled bool) (User, error) { email = normalizeEmail(email) if email == "" { return User{}, fmt.Errorf("email is required") } if len(password) < 12 { return User{}, fmt.Errorf("password must be at least 12 characters") } passwordHash, err := hashPassword(password) if err != nil { return User{}, err } return s.repo.CompleteInitialSetup(ctx, User{ Email: email, DisplayName: strings.TrimSpace(displayName), PasswordHash: passwordHash, Role: RoleAdmin, }, signupsEnabled) } func (s *Service) RegisterPasswordUser(ctx context.Context, email, displayName, password, role string) (User, error) { if err := s.requirePublicSignups(ctx); err != nil { return User{}, err } email = normalizeEmail(email) if email == "" { return User{}, fmt.Errorf("email is required") } if len(password) < 12 { return User{}, fmt.Errorf("password must be at least 12 characters") } if _, err := s.repo.GetUserByEmail(ctx, email); err == nil { return User{}, fmt.Errorf("user already exists") } else if err != nil && !isNoRows(err) { return User{}, err } passwordHash, err := hashPassword(password) if err != nil { return User{}, err } return s.repo.UpsertUser(ctx, User{ Email: email, DisplayName: strings.TrimSpace(displayName), PasswordHash: passwordHash, Role: RoleViewer, }) } func (s *Service) LoginPassword(ctx context.Context, email, password, ip, userAgent string) (Principal, string, error) { user, err := s.repo.GetUserByEmail(ctx, normalizeEmail(email)) if err != nil { if isNoRows(err) { _, _ = verifyPassword(password, mustDummyPasswordHash()) } return Principal{}, "", fmt.Errorf("invalid credentials") } if user.PasswordHash == "" { return Principal{}, "", fmt.Errorf("invalid credentials") } ok, err := verifyPassword(password, user.PasswordHash) if err != nil || !ok { return Principal{}, "", fmt.Errorf("invalid credentials") } principal, token, err := s.createSession(ctx, user, ip, userAgent) if err == nil { s.repo.Audit(ctx, user.ID, "auth.password.login", "user", user.ID, ip, userAgent, nil) } return principal, token, err } func (s *Service) BeginPasskeyRegistration(ctx context.Context, email, displayName, role string) (any, string, error) { if err := s.requirePublicSignups(ctx); err != nil { return nil, "", err } user, err := s.repo.GetUserByEmail(ctx, normalizeEmail(email)) if err != nil { if !isNoRows(err) { return nil, "", err } user, err = s.repo.UpsertUser(ctx, User{ Email: normalizeEmail(email), DisplayName: strings.TrimSpace(displayName), Role: RoleViewer, }) if err != nil { return nil, "", err } } else { return nil, "", fmt.Errorf("user already exists") } creation, session, err := s.webauthn.BeginRegistration(user) if err != nil { return nil, "", err } challengeID, err := s.repo.SaveChallenge(ctx, user.ID, "webauthn_registration", *session, challengeTTL) if err != nil { return nil, "", err } return creation, challengeID, nil } func (s *Service) FinishPasskeyRegistration(ctx context.Context, challengeID string, r *http.Request) (User, error) { session, userID, err := s.repo.ConsumeChallenge(ctx, challengeID, "webauthn_registration") if err != nil { return User{}, err } user, err := s.repo.GetUserByID(ctx, userID) if err != nil { return User{}, err } credential, err := s.webauthn.FinishRegistration(user, session, r) if err != nil { return User{}, err } if err := s.repo.SaveCredential(ctx, user.ID, *credential); err != nil { return User{}, err } s.repo.Audit(ctx, user.ID, "auth.passkey.register", "user", user.ID, clientIP(r), r.UserAgent(), nil) return s.repo.GetUserByID(ctx, user.ID) } func (s *Service) BeginPasskeyLogin(ctx context.Context, email string) (any, string, error) { user, err := s.repo.GetUserByEmail(ctx, normalizeEmail(email)) if err != nil { return nil, "", err } assertion, session, err := s.webauthn.BeginLogin(user) if err != nil { return nil, "", err } challengeID, err := s.repo.SaveChallenge(ctx, user.ID, "webauthn_login", *session, challengeTTL) if err != nil { return nil, "", err } return assertion, challengeID, nil } func (s *Service) FinishPasskeyLogin(ctx context.Context, challengeID string, r *http.Request) (Principal, string, error) { session, userID, err := s.repo.ConsumeChallenge(ctx, challengeID, "webauthn_login") if err != nil { return Principal{}, "", err } user, err := s.repo.GetUserByID(ctx, userID) if err != nil { return Principal{}, "", err } credential, err := s.webauthn.FinishLogin(user, session, r) if err != nil { return Principal{}, "", err } if err := s.repo.SaveCredential(ctx, user.ID, *credential); err != nil { return Principal{}, "", err } principal, token, err := s.createSession(ctx, user, clientIP(r), r.UserAgent()) if err == nil { s.repo.Audit(ctx, user.ID, "auth.passkey.login", "user", user.ID, clientIP(r), r.UserAgent(), nil) } return principal, token, err } func (s *Service) ValidateSessionToken(ctx context.Context, token string) (Principal, error) { session, user, err := s.repo.ValidateSession(ctx, hashSecret(token)) if err != nil { return Principal{}, err } return principalFromUser(user, "session", session.ID, "", nil, session.ExpiresAt), nil } func (s *Service) RevokeSession(ctx context.Context, sessionID string) error { return s.repo.RevokeSession(ctx, sessionID) } func (s *Service) ChangePassword(ctx context.Context, principal Principal, currentPassword, newPassword string) error { if principal.UserID == "" { return fmt.Errorf("authentication required") } if len(newPassword) < 12 { return fmt.Errorf("password must be at least 12 characters") } user, err := s.repo.GetUserByID(ctx, principal.UserID) if err != nil { return err } if user.PasswordHash != "" { ok, err := verifyPassword(currentPassword, user.PasswordHash) if err != nil || !ok { return fmt.Errorf("invalid credentials") } } passwordHash, err := hashPassword(newPassword) if err != nil { return err } if err := s.repo.UpdatePasswordHash(ctx, user.ID, passwordHash); err != nil { return err } s.repo.Audit(ctx, user.ID, "auth.password.change", "user", user.ID, "", "", nil) return nil } func (s *Service) DeleteAccount(ctx context.Context, principal Principal, currentPassword string) error { if principal.UserID == "" { return fmt.Errorf("authentication required") } user, err := s.repo.GetUserByID(ctx, principal.UserID) if err != nil { return err } if user.Role == RoleAdmin { admins, err := s.repo.CountAdmins(ctx) if err != nil { return err } if admins <= 1 { return fmt.Errorf("cannot delete the last admin account") } } if user.PasswordHash != "" { ok, err := verifyPassword(currentPassword, user.PasswordHash) if err != nil || !ok { return fmt.Errorf("invalid credentials") } } s.repo.Audit(ctx, user.ID, "auth.account.delete", "user", user.ID, "", "", nil) return s.repo.DeleteUser(ctx, user.ID) } func (s *Service) ListUsers(ctx context.Context) ([]User, error) { return s.repo.ListUsers(ctx) } func (s *Service) UpdateSignupsEnabled(ctx context.Context, actor Principal, enabled bool) (InstanceSettings, error) { if !Allows(actor, ScopeAdmin) { return InstanceSettings{}, fmt.Errorf("admin role required") } if err := s.repo.UpdateSignupsEnabled(ctx, enabled); err != nil { return InstanceSettings{}, err } return s.repo.GetInstanceSettings(ctx) } func (s *Service) EnsureDevUser(ctx context.Context) (User, error) { const email = "dev@localhost" settings, err := s.repo.GetInstanceSettings(ctx) if err != nil { return User{}, err } if !settings.SetupComplete { return s.CompleteInitialSetup(ctx, email, "Development Admin", "development-only-password", true) } user, err := s.repo.GetUserByEmail(ctx, email) if err == nil { return user, nil } if !isNoRows(err) { return User{}, err } return s.repo.UpsertUser(ctx, User{Email: email, DisplayName: "Development Admin", Role: RoleAdmin}) } func (s *Service) PrincipalForUser(ctx context.Context, userID string) (Principal, error) { user, err := s.repo.GetUserByID(ctx, userID) if err != nil { return Principal{}, err } return principalFromUser(user, "dev", "", "", nil, time.Time{}), nil } func (s *Service) UpdateUserRole(ctx context.Context, actor Principal, userID, role string) (User, error) { if !Allows(actor, ScopeAdmin) { return User{}, fmt.Errorf("admin role required") } normalizedRole := Role(strings.ToLower(strings.TrimSpace(role))) switch normalizedRole { case RoleViewer, RoleEditor, RoleAdmin: default: return User{}, fmt.Errorf("invalid role") } user, err := s.repo.GetUserByID(ctx, userID) if err != nil { return User{}, err } if user.Role == RoleAdmin && normalizedRole != RoleAdmin { admins, err := s.repo.CountAdmins(ctx) if err != nil { return User{}, err } if admins <= 1 { return User{}, fmt.Errorf("cannot demote the last admin account") } } if err := s.repo.UpdateUserRole(ctx, user.ID, normalizedRole); err != nil { return User{}, err } s.repo.Audit(ctx, actor.UserID, "auth.user.role.update", "user", user.ID, "", "", map[string]any{"role": normalizedRole}) return s.repo.GetUserByID(ctx, user.ID) } func (s *Service) CreateAPIKey(ctx context.Context, userID, name string, scopes []Scope, expiresAt *time.Time) (CreatedAPIKey, error) { user, err := s.repo.GetUserByID(ctx, userID) if err != nil { return CreatedAPIKey{}, err } if strings.TrimSpace(name) == "" { name = "API token" } tokenID, err := randomHex(12) if err != nil { return CreatedAPIKey{}, err } secret, err := randomHex(32) if err != nil { return CreatedAPIKey{}, err } now := time.Now().UTC() keyID := "api:" + tokenID token := "cq_pat_" + tokenID + "_" + secret record := APIKey{ ID: keyID, UserID: user.ID, Name: strings.TrimSpace(name), KeyHash: hashSecret(secret), Scopes: normalizeScopes(scopes), CreatedAt: now, ExpiresAt: expiresAt, } if err := s.repo.CreateAPIKey(ctx, record); err != nil { return CreatedAPIKey{}, err } return CreatedAPIKey{Record: record, Token: token}, nil } func (s *Service) ValidateBearerToken(ctx context.Context, token string) (Principal, error) { keyID, secret, err := parseAPIToken(token) if err != nil { return Principal{}, err } key, user, err := s.repo.ValidateAPIKey(ctx, keyID, hashSecret(secret)) if err != nil { return Principal{}, err } expiresAt := time.Time{} if key.ExpiresAt != nil { expiresAt = *key.ExpiresAt } return principalFromUser(user, "api_token", "", key.ID, key.Scopes, expiresAt), nil } func (s *Service) ListAPIKeys(ctx context.Context, userID string) ([]APIKey, error) { return s.repo.ListAPIKeys(ctx, userID) } func (s *Service) RevokeAPIKey(ctx context.Context, userID, keyID string) error { return s.repo.RevokeAPIKey(ctx, userID, keyID) } func (s *Service) StartDeviceFlow(ctx context.Context, name string, scopes []Scope) (DeviceStart, error) { deviceSecret, err := randomToken(32) if err != nil { return DeviceStart{}, err } rawUserCode, err := randomToken(5) if err != nil { return DeviceStart{}, err } userCode := formatUserCode(rawUserCode) idPart, err := randomToken(12) if err != nil { return DeviceStart{}, err } now := time.Now().UTC() device := DeviceCode{ ID: "dev:" + idPart, DeviceCodeHash: hashSecret(deviceSecret), UserCodeHash: hashSecret(normalizeUserCode(userCode)), UserCodeDisplay: userCode, Name: strings.TrimSpace(name), Scopes: normalizeScopes(scopes), Status: "pending", CreatedAt: now, ExpiresAt: now.Add(deviceCodeTTL), } if device.Name == "" { device.Name = "Device" } if err := s.repo.CreateDeviceCode(ctx, device); err != nil { return DeviceStart{}, err } verifyURI := strings.TrimRight(s.publicOrigin, "/") + "/device/verify" return DeviceStart{ DeviceCode: deviceSecret, UserCode: userCode, VerificationURI: verifyURI, VerificationURIComplete: verifyURI + "?user_code=" + url.QueryEscape(userCode), ExpiresIn: int(deviceCodeTTL.Seconds()), Interval: devicePollSeconds, }, nil } func (s *Service) ApproveDeviceFlow(ctx context.Context, principal Principal, userCode string) (DeviceCode, error) { if principal.UserID == "" { return DeviceCode{}, fmt.Errorf("authentication required") } return s.repo.ApproveDeviceCode(ctx, hashSecret(normalizeUserCode(userCode)), principal.UserID) } func (s *Service) PollDeviceFlow(ctx context.Context, deviceCode string) (CreatedAPIKey, string, error) { device, err := s.repo.GetDeviceCodeByDeviceCode(ctx, hashSecret(deviceCode)) if err != nil { if errorsIsNoRows(err) { return CreatedAPIKey{}, "invalid_device_code", err } return CreatedAPIKey{}, "", err } s.repo.TouchDevicePoll(ctx, device.ID) switch device.Status { case "pending": return CreatedAPIKey{}, "authorization_pending", sql.ErrNoRows case "denied", "expired": return CreatedAPIKey{}, device.Status, fmt.Errorf("device flow %s", device.Status) case "approved": created, err := s.CreateAPIKey(ctx, device.UserID, device.Name, device.Scopes, nil) if err == nil { s.repo.FinishDeviceCode(ctx, device.ID) } return created, "", err default: return CreatedAPIKey{}, "invalid_state", fmt.Errorf("invalid device status") } } func (s *Service) createSession(ctx context.Context, user User, ip, userAgent string) (Principal, string, error) { token, err := randomToken(32) if err != nil { return Principal{}, "", err } session, err := s.repo.CreateSession(ctx, user.ID, hashSecret(token), ip, userAgent, sessionTTL) if err != nil { return Principal{}, "", err } return principalFromUser(user, "session", session.ID, "", nil, session.ExpiresAt), token, nil } func (s *Service) requirePublicSignups(ctx context.Context) error { settings, err := s.repo.GetInstanceSettings(ctx) if err != nil { return err } if !settings.SetupComplete { return fmt.Errorf("initial setup is required") } if !settings.SignupsEnabled { return fmt.Errorf("signups are disabled") } return nil } func principalFromUser(user User, method, sessionID, apiKeyID string, scopes []Scope, expiresAt time.Time) Principal { return Principal{ UserID: user.ID, Email: user.Email, DisplayName: user.DisplayName, Role: user.Role, Scopes: scopes, Method: method, SessionID: sessionID, APIKeyID: apiKeyID, ExpiresAt: expiresAt, } } func parseAPIToken(token string) (string, string, error) { parts := strings.SplitN(token, "_", 4) if len(parts) != 4 || parts[0] != "cq" || parts[1] != "pat" { return "", "", fmt.Errorf("invalid api token") } return "api:" + parts[2], parts[3], nil } func normalizeEmail(email string) string { return strings.TrimSpace(strings.ToLower(email)) } func formatUserCode(raw string) string { clean := strings.ToUpper(strings.NewReplacer("-", "", "_", "").Replace(raw)) if len(clean) > 8 { clean = clean[:8] } if len(clean) > 4 { return clean[:4] + "-" + clean[4:] } return clean } func normalizeUserCode(code string) string { return strings.ToUpper(strings.NewReplacer("-", "", " ", "", "_", "").Replace(strings.TrimSpace(code))) } func errorsIsNoRows(err error) bool { return err == sql.ErrNoRows } var dummyPasswordHash string func mustDummyPasswordHash() string { if dummyPasswordHash == "" { hash, err := hashPassword("not-the-password") if err != nil { panic(err) } dummyPasswordHash = hash } return dummyPasswordHash } func clientIP(r *http.Request) string { if r == nil { return "" } if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { parts := strings.Split(forwarded, ",") return strings.TrimSpace(parts[0]) } return r.RemoteAddr } func RoleAllows(role Role, required Scope) bool { switch required { case ScopeDocsRead, ScopeSyncRead: return role == RoleViewer || role == RoleEditor || role == RoleAdmin case ScopeDocsWrite, ScopeSyncWrite: return role == RoleEditor || role == RoleAdmin case ScopeAdmin: return role == RoleAdmin default: return false } } func Allows(principal Principal, required Scope) bool { if principal.UserID == "" { return false } if len(principal.Scopes) > 0 && !hasScope(principal.Scopes, required) { return false } return RoleAllows(principal.Role, required) }