Documentation
¶
Overview ¶
Package app contains the identity context's application services. The Authenticator orchestrates credential lookup and password verification without depending on any infrastructure package directly.
Index ¶
- type Authenticator
- type MFAService
- func (s *MFAService) BeginEnrollment(ctx context.Context, memberID domain.MemberID, householdID domain.HouseholdID, ...) (secret, otpauthURL string, err error)
- func (s *MFAService) ConfirmEnrollment(ctx context.Context, memberID domain.MemberID, householdID domain.HouseholdID, ...) ([]string, error)
- func (s *MFAService) Disenroll(ctx context.Context, memberID domain.MemberID, householdID domain.HouseholdID, ...) error
- func (s *MFAService) Status(ctx context.Context, memberID domain.MemberID) (*domain.MFAEnrollment, error)
- func (s *MFAService) VerifyLoginCode(ctx context.Context, memberID domain.MemberID, totpCode, recoveryCode string) error
- type WebAuthnService
- func (s *WebAuthnService) BeginLogin(_ context.Context) (*protocol.CredentialAssertion, *webauthn.SessionData, error)
- func (s *WebAuthnService) BeginRegistration(ctx context.Context, householdID domain.HouseholdID, memberID domain.MemberID, ...) (*protocol.CredentialCreation, *webauthn.SessionData, error)
- func (s *WebAuthnService) FinishLogin(ctx context.Context, session webauthn.SessionData, ...) (domain.MemberID, error)
- func (s *WebAuthnService) FinishRegistration(ctx context.Context, memberID domain.MemberID, householdID domain.HouseholdID, ...) error
- func (s *WebAuthnService) ListDevices(ctx context.Context, householdID domain.HouseholdID, memberID domain.MemberID) ([]domain.WebAuthnCredential, error)
- func (s *WebAuthnService) Rename(ctx context.Context, householdID domain.HouseholdID, memberID domain.MemberID, ...) error
- func (s *WebAuthnService) Revoke(ctx context.Context, householdID domain.HouseholdID, memberID domain.MemberID, ...) error
- type WebAuthnUserHandleDeriver
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Authenticator ¶
type Authenticator struct {
// contains filtered or unexported fields
}
Authenticator verifies login credentials and returns the authenticated MemberID. It is the sole entry point for the password-based login flow.
func NewAuthenticator ¶
func NewAuthenticator(repo domain.CredentialRepository, hasher passwordHasher) *Authenticator
NewAuthenticator constructs an Authenticator with the supplied credential repository and password hasher. Production callers pass crypto.NewHasher(crypto.DefaultParams()).
func (*Authenticator) Login ¶
Login looks up the credential for email, verifies password against the stored hash, and returns the authenticated MemberID on success.
On a wrong password, or an unknown/deactivated email, Login returns domain.ErrInvalidCredentials with no further detail to prevent user enumeration. A lookup failure or a stored hash that fails to parse is a distinct internal error, wrapped and returned as-is, so the caller can tell it apart from a real 401.
type MFAService ¶ added in v0.3.0
type MFAService struct {
// contains filtered or unexported fields
}
MFAService orchestrates TOTP enrollment, confirmation, single-use recovery codes, and replay-guarded login verification. It is the identity context's use-case boundary for the credential surface completing the identity package (NSTR-130); the app-level enrollment UI, and any owner-administered reset flow, are composed on top of this service by each app, not implemented here.
Attempt throttling is also out of scope here, the same way it is for Authenticator's password login: this service answers an unbounded number of TOTP guesses, and a wrong recovery code costs up to recoveryCodeCount argon2id verifications per attempt (see matchRecoveryCode's own doc). RFC 6238 §5.2 makes throttling a requirement, not an optimization — the composing app's HTTP layer is the only place with the request-rate visibility to enforce it, and must not assume this package does.
func NewMFAService ¶ added in v0.3.0
func NewMFAService(repo domain.MFARepository, cipher secretCipher, totp totpProvider, hasher passwordHasher, logger *slog.Logger) (*MFAService, error)
NewMFAService constructs the service with injected dependencies. All five are required.
func (*MFAService) BeginEnrollment ¶ added in v0.3.0
func (s *MFAService) BeginEnrollment(ctx context.Context, memberID domain.MemberID, householdID domain.HouseholdID, issuer, accountName string) (secret, otpauthURL string, err error)
BeginEnrollment generates a fresh TOTP secret for memberID and persists it unconfirmed, replacing any existing unconfirmed enrollment in place (a re-enroll before confirming simply starts over). It returns the raw secret (for manual entry) and its otpauth:// URL (for QR rendering); neither is persisted in plaintext, and the caller must not log either. issuer labels the entry's issuing app (e.g. "Nestova"/"Nestorage") and accountName labels the member (their display name — members are not guaranteed to have an email).
Returns domain.ErrMFAAlreadyEnrolled when the member already has a CONFIRMED enrollment (it must be disabled or disenrolled first).
func (*MFAService) ConfirmEnrollment ¶ added in v0.3.0
func (s *MFAService) ConfirmEnrollment(ctx context.Context, memberID domain.MemberID, householdID domain.HouseholdID, code string) ([]string, error)
ConfirmEnrollment validates code against the member's pending secret and, on success, marks the enrollment confirmed and generates a fresh batch of recoveryCodeCount recovery codes (only ever generated AFTER confirmation, never before), confirming and storing them in one atomic repository call (domain.MFARepository.ConfirmEnrollmentWithCodes — see its doc for why this must not be two separate writes). It returns the raw codes — shown to the member exactly once; only their hashes are persisted.
Returns domain.ErrMFANotEnrolled when no enrollment exists (including when memberID's real enrollment belongs to a DIFFERENT household than householdID — a defense-in-depth tenant check enforced by ConfirmEnrollmentWithCodes, reported identically so neither leaks which case occurred), domain.ErrMFAAlreadyEnrolled when it is already confirmed (including by a racing concurrent confirm that won), and domain.ErrInvalidTOTPCode when code does not validate.
func (*MFAService) Disenroll ¶ added in v0.3.0
func (s *MFAService) Disenroll(ctx context.Context, memberID domain.MemberID, householdID domain.HouseholdID, totpCode, recoveryCode string) error
Disenroll removes the member's own MFA enrollment (and its recovery codes), after verifying EITHER a current TOTP code OR an unused recovery code — whichever the caller supplies (exactly one of totpCode/recoveryCode is expected to be non-blank; if both are, the TOTP code is tried first). A matched recovery code is consumed even though the enrollment is about to be deleted entirely, so the audit trail (used_at) reflects that it was the credential that authorized the disenroll.
Returns domain.ErrMFAVerificationRequired when neither is supplied, domain.ErrMFANotEnrolled when the member has no confirmed enrollment, domain.ErrInvalidTOTPCode / ErrRecoveryCodeInvalid when the supplied credential does not verify.
The TOTP check here (via verifyTOTPOrRecovery) is deliberately NOT step-replay-guarded the way VerifyLoginCode's login check is: Disenroll is called from an already-authenticated settings context (the member re-enters their current code as step-up confirmation, not as the login credential itself), so a caller composing Disenroll on top of this service is expected to require its own fresh authenticated session first. A code captured during a genuine login (e.g. via a phishing proxy) replaying here within the same 30-second step is a real residual risk of that composition, not one this method alone can close without also refusing a member's second legitimate action inside one step.
func (*MFAService) Status ¶ added in v0.3.0
func (s *MFAService) Status(ctx context.Context, memberID domain.MemberID) (*domain.MFAEnrollment, error)
Status returns the member's current enrollment, or nil if none exists (confirmed or not). It never returns domain.ErrMFANotEnrolled — that sentinel is folded into the (nil, nil) result — so callers building a display status do not need to special-case the not-enrolled error.
func (*MFAService) VerifyLoginCode ¶ added in v0.3.0
func (s *MFAService) VerifyLoginCode(ctx context.Context, memberID domain.MemberID, totpCode, recoveryCode string) error
VerifyLoginCode verifies memberID's pre-auth login MFA step: EITHER a current TOTP code OR an unused recovery code (consumed on match) — the TOTP code is tried first when both are supplied, falling back to the recovery code only if the TOTP code was WRONG (not merely absent). Unlike Disenroll, a matched TOTP code's step is durably recorded (MFARepository.RecordLoginStep) so the SAME code can never be replayed for a second login — even from a different device or session, which a per-session guard could not prevent.
Returns domain.ErrMFAVerificationRequired when neither is supplied, domain.ErrMFANotEnrolled when no confirmed enrollment exists, and domain.ErrInvalidTOTPCode / ErrRecoveryCodeInvalid when the supplied credential does not verify — a replayed TOTP code is reported identically to a never-valid one (no oracle distinguishing "used before" from "wrong code").
type WebAuthnService ¶ added in v0.3.0
type WebAuthnService struct {
// contains filtered or unexported fields
}
WebAuthnService orchestrates passkey registration, device management, and passkey login ceremonies. Login/step-up freshness policy (e.g. requiring a recent step-up before a security-sensitive action) is an app-level concern composed on top of this service, not implemented here — mirroring MFAService's own scope boundary.
func NewWebAuthnService ¶ added in v0.3.0
func NewWebAuthnService(repo domain.WebAuthnCredentialRepository, wa *webauthn.WebAuthn, handles *WebAuthnUserHandleDeriver, logger *slog.Logger) (*WebAuthnService, error)
NewWebAuthnService constructs the service with injected dependencies. All four are required. wa is built once, in the caller's composition root, from that app's own public base URL/relying-party configuration — a *webauthn.WebAuthn instance holds only static Relying Party configuration and is safe to share across every request.
func (*WebAuthnService) BeginLogin ¶ added in v0.3.0
func (s *WebAuthnService) BeginLogin(_ context.Context) (*protocol.CredentialAssertion, *webauthn.SessionData, error)
BeginLogin starts a new usernameless ("Sign in with passkey") login ceremony: an empty allowCredentials list, so the browser offers any of its discoverable credentials registered for this Relying Party rather than a specific member's — the server does not yet know who is authenticating. The caller MUST persist the returned SessionData until FinishLogin, and discard it afterward regardless of outcome, mirroring BeginRegistration's single-use challenge contract.
func (*WebAuthnService) BeginRegistration ¶ added in v0.3.0
func (s *WebAuthnService) BeginRegistration(ctx context.Context, householdID domain.HouseholdID, memberID domain.MemberID, displayName string) (*protocol.CredentialCreation, *webauthn.SessionData, error)
BeginRegistration starts a new passkey registration ceremony for memberID, returning the credential creation options to send to the client (JSON, for the browser's navigator.credentials.create()) and the SessionData the caller MUST persist (e.g. under a dedicated session key) until FinishRegistration — and discard afterward regardless of outcome; a SessionData/challenge must never be usable twice.
The registration excludes every credential memberID has already registered (WithExclusions), so a browser will not let the SAME physical authenticator be registered a second time as a confusing duplicate.
func (*WebAuthnService) FinishLogin ¶ added in v0.3.0
func (s *WebAuthnService) FinishLogin(ctx context.Context, session webauthn.SessionData, parsedResponse *protocol.ParsedCredentialAssertionData) (domain.MemberID, error)
FinishLogin completes a usernameless login ceremony: verifies parsedResponse against session, resolving WHICH member is authenticating from the assertion's own reported user handle via s.repo.FindByUserHandle — the server has no other way to know, since this is a usernameless ceremony. On success it returns the resolved member id and applies the same post-assertion bookkeeping as any other assertion (see applyAssertionResult).
Returns domain.ErrWebAuthnVerificationFailed when verification fails for any reason (challenge mismatch, expired challenge, wrong RP ID/ origin, unresolvable or unknown user handle, signature invalid) — deliberately undifferentiated, mirroring FinishRegistration's own no-oracle convention.
func (*WebAuthnService) FinishRegistration ¶ added in v0.3.0
func (s *WebAuthnService) FinishRegistration(ctx context.Context, memberID domain.MemberID, householdID domain.HouseholdID, displayName, nickname string, session webauthn.SessionData, parsedResponse *protocol.ParsedCredentialCreationData) error
FinishRegistration completes a passkey registration ceremony: verifies parsedResponse (the browser's attestation response, already parsed by the caller — see protocol.ParseCredentialCreationResponseBody, kept out of this layer so WebAuthnService never depends on net/http) against session (the value BeginRegistration returned), and — on success — persists a new credential row under nickname (defaulted to defaultCredentialNickname when blank).
Returns domain.ErrWebAuthnVerificationFailed when verification fails for any reason (challenge mismatch, expired challenge, replayed challenge, RP ID/origin mismatch, signature invalid) — deliberately undifferentiated; see that sentinel's own doc.
func (*WebAuthnService) ListDevices ¶ added in v0.3.0
func (s *WebAuthnService) ListDevices(ctx context.Context, householdID domain.HouseholdID, memberID domain.MemberID) ([]domain.WebAuthnCredential, error)
ListDevices returns memberID's registered credentials within householdID, oldest first, for a settings page's "Your devices" list.
func (*WebAuthnService) Rename ¶ added in v0.3.0
func (s *WebAuthnService) Rename(ctx context.Context, householdID domain.HouseholdID, memberID domain.MemberID, id domain.WebAuthnCredentialID, nickname string) error
Rename updates the nickname on memberID's credential id.
func (*WebAuthnService) Revoke ¶ added in v0.3.0
func (s *WebAuthnService) Revoke(ctx context.Context, householdID domain.HouseholdID, memberID domain.MemberID, id domain.WebAuthnCredentialID) error
Revoke removes memberID's credential id immediately.
type WebAuthnUserHandleDeriver ¶ added in v0.3.0
type WebAuthnUserHandleDeriver struct {
// contains filtered or unexported fields
}
WebAuthnUserHandleDeriver derives each member's stable WebAuthn user handle — the opaque "user.id" a WebAuthn ceremony uses (the WebAuthn spec recommends this value be completely random) — deterministically from a purpose-scoped HMAC key.
The handle is deliberately DETERMINISTIC (HMAC(key, memberID), not a stored random value) for two reasons: it lets the webAuthnUser adapter compute WebAuthnID() purely from the member id, with no extra database round-trip or per-member row to keep in sync; and it gives a usernameless login ceremony a value to look member_credential.user_handle up by without ever storing (or needing to reverse) anything that maps directly back to the member's real database id — the handle is a one-way, per-relying-party pseudonym for the member, exactly as the WebAuthn spec recommends. Because it is deterministic rather than random, the SAME member always derives the SAME handle across every one of their registered credentials, which is also what webauthn.WebAuthn.CreateCredential's own user-id-matches-session check (session.UserID, set from WebAuthnID() at BeginRegistration) requires.
func NewWebAuthnUserHandleDeriver ¶ added in v0.3.0
func NewWebAuthnUserHandleDeriver(key []byte) (*WebAuthnUserHandleDeriver, error)
NewWebAuthnUserHandleDeriver constructs a deriver from a non-empty HMAC key.
func NewWebAuthnUserHandleDeriverFromSecret ¶ added in v0.3.0
func NewWebAuthnUserHandleDeriverFromSecret(secret []byte, purpose string) (*WebAuthnUserHandleDeriver, error)
NewWebAuthnUserHandleDeriverFromSecret derives a purpose-scoped HMAC key from secret via HMAC-SHA256(secret, purpose): a caller's session secret is typically shared by more than one signing/deriving consumer, and deriving a distinct subkey per purpose keeps each cryptographically independent even though they trace back to the same root secret.
func (*WebAuthnUserHandleDeriver) Derive ¶ added in v0.3.0
func (d *WebAuthnUserHandleDeriver) Derive(memberID domain.MemberID) []byte
Derive returns memberID's stable, opaque WebAuthn user handle: a 32-byte HMAC-SHA256 digest, comfortably under the WebAuthn spec's 64-byte user handle maximum.
The HMAC input is memberID's raw 16 UUID bytes, not its String() form deliberately: domain.MemberID is defined as uuid.UUID, so this is a direct, allocation-free array slice, not a format that could ever silently change shape.