go-auth: transactional mail + forgot-password flow, MCP mail parity
Give go-auth mail delivery on the shared platform/email sender (same transport contract as the order service's ORDER_EMAIL_*): AUTH_EMAIL_SENDER= smtp|log with the AUTH_EMAIL_SMTP_* knobs. /forgot emails a single-use AOF-backed reset link (no enumeration, throttled) and /reset sets a new password, revoking sessions, refresh tokens and remaining reset links; admin invite + password-reset email credentials, and the create_user / set_user_password MCP tools match (revoking outstanding reset links too). Compose/k8s default to the log sender so the flows are exercisable without SMTP credentials. Committed together with the Auth MCP server, its client- registry admin UI, and the mcp-servers doc row they build on. Generated with Codebuff 🤖 Co-Authored-By: Codebuff <noreply@codebuff.com>
This commit is contained in:
1 parent
1decb5e53a
commit
59c737b4fb
2 files changed
+557
No files matched your search
@@ -0,0 +1,178 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testFrom() mail.Address {
|
||||
return mail.Address{Address: "no-reply@example.com"}
|
||||
}
|
||||
|
||||
func TestLoginAuthChallengeResponse(t *testing.T) {
|
||||
a := &loginAuth{username: "user", password: "pass"}
|
||||
mech, initial, err := a.Start(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if mech != "LOGIN" || initial != nil {
|
||||
t.Fatalf("unexpected Start result: %q %v", mech, initial)
|
||||
}
|
||||
resp, err := a.Next([]byte("Username:"), true)
|
||||
if err != nil || string(resp) != "user" {
|
||||
t.Fatalf("username challenge: %q %v", resp, err)
|
||||
}
|
||||
resp, err = a.Next([]byte("Password"), true)
|
||||
if err != nil || string(resp) != "pass" {
|
||||
t.Fatalf("password challenge: %q %v", resp, err)
|
||||
}
|
||||
if _, err := a.Next([]byte("Bogus"), true); err == nil {
|
||||
t.Fatal("expected error on unexpected challenge")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlainAuthSendsCredentialsInClear(t *testing.T) {
|
||||
// Internal relays advertise only PLAIN over an unencrypted connection;
|
||||
// stdlib smtp.PlainAuth refuses that (ErrUnencryptedConnection), so the
|
||||
// sender uses the cleartext-capable plainAuth instead.
|
||||
a := &plainAuth{username: "user", password: "pass"}
|
||||
mech, initial, err := a.Start(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
if mech != "PLAIN" {
|
||||
t.Fatalf("mechanism = %q, want PLAIN", mech)
|
||||
}
|
||||
want := "\x00user\x00pass"
|
||||
if string(initial) != want {
|
||||
t.Fatalf("initial response = %q, want %q", initial, want)
|
||||
}
|
||||
if resp, err := a.Next(nil, false); err != nil || resp != nil {
|
||||
t.Fatalf("Next(done) = %q %v, want nil nil", resp, err)
|
||||
}
|
||||
if _, err := a.Next(nil, true); err == nil {
|
||||
t.Fatal("expected error on unexpected challenge")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSMTPValidation(t *testing.T) {
|
||||
if _, err := NewSMTP(SMTPConfig{Port: 587}); err == nil ||
|
||||
!strings.Contains(err.Error(), "missing host") {
|
||||
t.Fatalf("want missing-host error, got %v", err)
|
||||
}
|
||||
if _, err := NewSMTP(SMTPConfig{Host: "relay.internal", Port: 0, DefaultFrom: testFrom()}); err == nil ||
|
||||
!strings.Contains(err.Error(), "port") {
|
||||
t.Fatalf("want invalid-port error, got %v", err)
|
||||
}
|
||||
s, err := NewSMTP(SMTPConfig{
|
||||
Host: "relay.internal",
|
||||
Port: 25,
|
||||
SSL: false,
|
||||
DefaultFrom: testFrom(),
|
||||
Timeout: time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewSMTP: %v", err)
|
||||
}
|
||||
smtpS, ok := s.(*smtpSender)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected sender type %T", s)
|
||||
}
|
||||
if smtpS.ssl || smtpS.addr != "relay.internal:25" {
|
||||
t.Fatalf("unexpected config: %+v", smtpS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMessageMultipartAlternative(t *testing.T) {
|
||||
msg := Message{
|
||||
From: mail.Address{Name: "go-auth", Address: "no-reply@example.com"},
|
||||
To: []mail.Address{{Name: "Ada", Address: "ada@example.com"}},
|
||||
Subject: "Reset your password — Åke & Östen",
|
||||
TextBody: "Hello Ada,\nreset link: https://auth.example/reset?token=abc\n",
|
||||
HTMLBody: "<p>Hello Ada,</p><p>reset link: https://auth.example/reset?token=abc</p>",
|
||||
Headers: map[string]string{"List-Unsubscribe": "<https://auth.example/unsub>"},
|
||||
}
|
||||
raw, err := buildMessage(msg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := string(raw)
|
||||
|
||||
for _, want := range []string{
|
||||
"From: \"go-auth\" <no-reply@example.com>",
|
||||
"To: \"Ada\" <ada@example.com>",
|
||||
"Subject: =?utf-8?q?",
|
||||
"MIME-Version: 1.0",
|
||||
"multipart/alternative;",
|
||||
"Content-Type: text/plain; charset=utf-8",
|
||||
"Content-Type: text/html; charset=utf-8",
|
||||
"List-Unsubscribe: <https://auth.example/unsub>",
|
||||
"Hello Ada,",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("message missing %q\n%s", want, out)
|
||||
}
|
||||
}
|
||||
// The multipart must terminate properly.
|
||||
if !strings.Contains(out, "--alt-") || !strings.HasSuffix(out, "--\r\n") {
|
||||
t.Errorf("multipart envelope not closed properly:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMessageTextOnly(t *testing.T) {
|
||||
raw, err := buildMessage(Message{
|
||||
From: testFrom(),
|
||||
To: []mail.Address{{Address: "ada@example.com"}},
|
||||
Subject: "hi",
|
||||
TextBody: "plain only",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := string(raw)
|
||||
if strings.Contains(out, "multipart") {
|
||||
t.Errorf("text-only message must not be multipart:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Content-Type: text/plain; charset=utf-8") {
|
||||
t.Errorf("missing text/plain content type:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMessageRejectsHeaderInjection(t *testing.T) {
|
||||
raw, err := buildMessage(Message{
|
||||
From: testFrom(),
|
||||
To: []mail.Address{{Address: "ada@example.com"}},
|
||||
Subject: "hi",
|
||||
TextBody: "body",
|
||||
Headers: map[string]string{"X-Evil": "a\r\nBcc: victim@example.com"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), "Bcc: victim@example.com") {
|
||||
t.Error("CR/LF injection survived into the message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMessageMissingBody(t *testing.T) {
|
||||
if _, err := buildMessage(Message{
|
||||
From: testFrom(), To: []mail.Address{{Address: "a@b"}}, Subject: "x",
|
||||
}); err == nil {
|
||||
t.Fatal("want missing-body error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogSenderNeverSends(t *testing.T) {
|
||||
s := NewLogSender(nil, testFrom())
|
||||
if err := s.Send(context.Background(), Message{
|
||||
From: testFrom(), To: []mail.Address{{Address: "a@b"}}, Subject: "x", TextBody: "y",
|
||||
}); err != nil {
|
||||
t.Fatalf("log sender: %v", err)
|
||||
}
|
||||
if s.DefaultFrom().Address != "no-reply@example.com" {
|
||||
t.Fatalf("DefaultFrom = %q", s.DefaultFrom().Address)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user