// Copyright © 2023 Ory Corp // SPDX-License-Identifier: Apache-2.0 package code_test import ( "context" "encoding/base64" "fmt" "net/http" "net/http/httptest" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" hydraclientgo "github.com/ory/hydra-client-go/v2" "github.com/ory/x/configx" "github.com/ory/kratos/courier" "github.com/ory/kratos/courier/template" "github.com/ory/kratos/driver/config" "github.com/ory/kratos/hydra" "github.com/ory/kratos/identity" "github.com/ory/kratos/pkg" "github.com/ory/kratos/pkg/testhelpers" "github.com/ory/kratos/selfservice/flow" "github.com/ory/kratos/selfservice/flow/login" "github.com/ory/kratos/selfservice/flow/recovery" "github.com/ory/kratos/selfservice/flow/registration" "github.com/ory/kratos/selfservice/flow/verification" "github.com/ory/kratos/selfservice/strategy/code" "github.com/ory/kratos/x" "github.com/ory/kratos/x/nosurfx" "github.com/ory/x/sqlxx" "github.com/ory/x/urlx" ) var b64 = func(str string) string { return base64.StdEncoding.EncodeToString([]byte(str)) } func TestSender(t *testing.T) { t.Parallel() ctx := context.Background() conf, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValues(testhelpers.DefaultIdentitySchemaConfig("file://./stub/default.schema.json")), configx.WithValues(map[string]any{ config.ViperKeyPublicBaseURL: "https://www.ory.com/", config.ViperKeyCourierSMTPURL: "smtp://foo@bar@dev.null/", config.ViperKeySelfServiceRecoveryNotifyUnknownRecipients: true, config.ViperKeySelfServiceVerificationNotifyUnknownRecipients: true, }), ) conf.MustSet(ctx, config.ViperKeyWebhookHeaderAllowlist, []string{"user-agent", "X-CUSTOM-HEADER"}) u := &http.Request{URL: urlx.ParseOrPanic("https://www.ory.com/")} i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID) // Fake phone numbers which are unallocated numbers that telephone companies cannot assign // to customers in Germany under current legislation. // This is to protect residents against the potential influx of phone calls // that they may receive should their telephone numbers appear in a movie or film. phoneNumberKnown := x.NormalizePhoneIdentifier("+49-160-555-5762") phoneNumberUnknown := x.NormalizePhoneIdentifier("+49-155-555-4570") i.Traits = identity.Traits(fmt.Sprintf(`{"email": "tracked@ory.sh", "phone": "%s"}`, phoneNumberKnown)) require.NoError(t, reg.IdentityManager().Create(ctx, i)) t.Run("method=SendRecoveryCode email", func(t *testing.T) { recoveryCode := func(t *testing.T) { t.Helper() f, err := recovery.NewFlow(reg, time.Hour, "", u, recovery.Strategies{code.NewStrategy(reg)}, flow.TypeBrowser) require.NoError(t, err) require.NoError(t, reg.RecoveryFlowPersister().CreateRecoveryFlow(ctx, f)) header := http.Header{} header.Add("user-agent", "user-agent header") header.Add("X-CUSTOM-HEADER", "x-custom header 1") header.Add("X-Custom-header", "x-custom header 2") header.Add("some-other-header", "some-other-value") require.NoError(t, reg.CodeSender().SendRecoveryCode(ctx, f, "email", "tracked@ory.sh", header)) require.ErrorIs(t, reg.CodeSender().SendRecoveryCode(ctx, f, "email", "not-tracked@ory.sh", header), code.ErrUnknownAddress()) } t.Run("case=with default templates", func(t *testing.T) { recoveryCode(t) messages, err := reg.CourierPersister().NextMessages(ctx, 12) require.NoError(t, err) require.Len(t, messages, 2) assert.EqualValues(t, "tracked@ory.sh", messages[0].Recipient) assert.Contains(t, messages[0].Subject, "Use code") assert.Regexp(t, testhelpers.CodeRegex, messages[0].Body) assert.EqualValues(t, "not-tracked@ory.sh", messages[1].Recipient) assert.Contains(t, messages[1].Subject, "Account access attempted") assert.NotRegexp(t, testhelpers.CodeRegex, messages[1].Body, "Expected message to not contain an 6 digit recovery code, but it did: ", messages[1].Body) headers := messages[0].RequestHeaders assert.Equal(t, []any{"user-agent header"}, gjson.GetBytes(headers, "User-Agent").Value()) assert.Equal(t, []any{"x-custom header 1", "x-custom header 2"}, gjson.GetBytes(headers, "X-Custom-Header").Value()) assert.Empty(t, gjson.GetBytes(headers, "Some-Other-Header").Value(), "Expected Some-Other-Header to be empty") }) t.Run("case=with custom templates", func(t *testing.T) { subject := "custom template recovery code" body := "custom template recovery code body" t.Cleanup(func() { conf.MustSet(ctx, config.ViperKeyCourierTemplatesRecoveryCodeInvalidEmail, nil) conf.MustSet(ctx, config.ViperKeyCourierTemplatesRecoveryCodeValidEmail, nil) }) conf.MustSet(ctx, config.ViperKeyCourierTemplatesRecoveryCodeInvalidEmail, fmt.Sprintf(`{ "subject": "base64://%s", "body": { "plaintext": "base64://%s", "html": "base64://%s" }}`, b64(subject+" invalid"), b64(body), b64(body))) conf.MustSet(ctx, config.ViperKeyCourierTemplatesRecoveryCodeValidEmail, fmt.Sprintf(`{ "subject": "base64://%s", "body": { "plaintext": "base64://%s", "html": "base64://%s" }}`, b64(subject+" valid"), b64(body+" {{ .RecoveryCode }}"), b64(body+" {{ .RecoveryCode }}"))) recoveryCode(t) messages, err := reg.CourierPersister().NextMessages(ctx, 12) require.NoError(t, err) require.Len(t, messages, 2) assert.EqualValues(t, "tracked@ory.sh", messages[0].Recipient) assert.Equal(t, messages[0].Subject, subject+" valid") assert.Contains(t, messages[0].Body, body) assert.Regexp(t, testhelpers.CodeRegex, messages[0].Body) assert.EqualValues(t, "not-tracked@ory.sh", messages[1].Recipient) assert.Equal(t, messages[1].Subject, subject+" invalid") assert.Equal(t, messages[1].Body, body) headers := messages[0].RequestHeaders assert.Equal(t, []any{"user-agent header"}, gjson.GetBytes(headers, "User-Agent").Value()) assert.Equal(t, []any{"x-custom header 1", "x-custom header 2"}, gjson.GetBytes(headers, "X-Custom-Header").Value()) assert.Empty(t, gjson.GetBytes(headers, "Some-Other-Header").Value(), "Expected Some-Other-Header to be empty") }) }) t.Run("method=SendRecoveryCode sms", func(t *testing.T) { recoveryCode := func(t *testing.T) { t.Helper() f, err := recovery.NewFlow(reg, time.Hour, "", u, recovery.Strategies{code.NewStrategy(reg)}, flow.TypeBrowser) require.NoError(t, err) require.NoError(t, reg.RecoveryFlowPersister().CreateRecoveryFlow(ctx, f)) header := http.Header{} header.Add("user-agent", "user-agent header") header.Add("X-CUSTOM-HEADER", "x-custom header 1") header.Add("X-Custom-header", "x-custom header 2") header.Add("some-other-header", "some-other-value") require.NoError(t, reg.CodeSender().SendRecoveryCode(ctx, f, "sms", phoneNumberKnown, header)) require.ErrorIs(t, reg.CodeSender().SendRecoveryCode(ctx, f, "sms", phoneNumberUnknown, header), code.ErrUnknownAddress()) } t.Run("case=with default templates", func(t *testing.T) { recoveryCode(t) messages, err := reg.CourierPersister().NextMessages(ctx, 12) require.NoError(t, err) require.Len(t, messages, 1) assert.EqualValues(t, phoneNumberKnown, messages[0].Recipient) assert.Contains(t, messages[0].Body, "Your recovery code is:") assert.Contains(t, messages[0].Body, "@www.ory.com #") assert.Regexp(t, testhelpers.CodeRegex, messages[0].Body) }) t.Run("case=with custom templates", func(t *testing.T) { body := "custom template recovery code body" t.Cleanup(func() { conf.MustSet(ctx, config.ViperKeyCourierTemplatesRecoveryCodeValidSMS, nil) }) conf.MustSet(ctx, config.ViperKeyCourierTemplatesRecoveryCodeValidSMS, fmt.Sprintf(`{ "body": { "plaintext": "base64://%s"}}`, b64(body+" {{ .RecoveryCode }}"))) recoveryCode(t) messages, err := reg.CourierPersister().NextMessages(ctx, 12) require.NoError(t, err) require.Len(t, messages, 1) assert.EqualValues(t, phoneNumberKnown, messages[0].Recipient) assert.Contains(t, messages[0].Body, body) headers := messages[0].RequestHeaders assert.Equal(t, []any{"user-agent header"}, gjson.GetBytes(headers, "User-Agent").Value()) assert.Equal(t, []any{"x-custom header 1", "x-custom header 2"}, gjson.GetBytes(headers, "X-Custom-Header").Value()) assert.Empty(t, gjson.GetBytes(headers, "Some-Other-Header").Value(), "Expected Some-Other-Header to be empty") assert.Regexp(t, testhelpers.CodeRegex, messages[0].Body) }) }) t.Run("method=SendVerificationCode", func(t *testing.T) { verificationFlow := func(t *testing.T) { t.Helper() f, err := verification.NewFlow(reg, time.Hour, "", u, verification.Strategies{code.NewStrategy(reg)}, flow.TypeBrowser) require.NoError(t, err) require.NoError(t, reg.VerificationFlowPersister().CreateVerificationFlow(ctx, f)) require.NoError(t, reg.CodeSender().SendVerificationCode(ctx, f, "email", "tracked@ory.sh")) require.ErrorIs(t, reg.CodeSender().SendVerificationCode(ctx, f, "email", "not-tracked@ory.sh"), code.ErrUnknownAddress()) } t.Run("case=with default templates", func(t *testing.T) { verificationFlow(t) messages, err := reg.CourierPersister().NextMessages(ctx, 12) require.NoError(t, err) require.Len(t, messages, 2) assert.EqualValues(t, "tracked@ory.sh", messages[0].Recipient) assert.Contains(t, messages[0].Subject, "Use code") assert.Regexp(t, testhelpers.CodeRegex, messages[0].Body) assert.EqualValues(t, "not-tracked@ory.sh", messages[1].Recipient) assert.Contains(t, messages[1].Subject, "Someone tried to verify this email address") assert.NotRegexp(t, testhelpers.CodeRegex, messages[1].Body, "Expected message to not contain an 6 digit recovery code, but it did: ", messages[1].Body) }) t.Run("case=with custom templates", func(t *testing.T) { subject := "custom template verification code" body := "custom template verification code body" t.Cleanup(func() { conf.MustSet(ctx, config.ViperKeyCourierTemplatesVerificationCodeInvalidEmail, nil) conf.MustSet(ctx, config.ViperKeyCourierTemplatesVerificationCodeValidEmail, nil) }) conf.MustSet(ctx, config.ViperKeyCourierTemplatesVerificationCodeInvalidEmail, fmt.Sprintf(`{ "subject": "base64://%s", "body": { "plaintext": "base64://%s", "html": "base64://%s" }}`, b64(subject+" invalid"), b64(body), b64(body))) conf.MustSet(ctx, config.ViperKeyCourierTemplatesVerificationCodeValidEmail, fmt.Sprintf(`{ "subject": "base64://%s", "body": { "plaintext": "base64://%s", "html": "base64://%s" }}`, b64(subject+" valid"), b64(body+" {{ .VerificationCode }}"), b64(body+" {{ .VerificationCode }}"))) verificationFlow(t) messages, err := reg.CourierPersister().NextMessages(ctx, 12) require.NoError(t, err) require.Len(t, messages, 2) assert.EqualValues(t, "tracked@ory.sh", messages[0].Recipient) assert.Equal(t, messages[0].Subject, subject+" valid") assert.Contains(t, messages[0].Body, body) assert.Regexp(t, testhelpers.CodeRegex, messages[0].Body) assert.EqualValues(t, "not-tracked@ory.sh", messages[1].Recipient) assert.Equal(t, messages[1].Subject, subject+" invalid") assert.Equal(t, messages[1].Body, body) }) }) t.Run("case=should be able to disable invalid email dispatch", func(t *testing.T) { for _, tc := range []struct { flow string send func(t *testing.T) configKey string }{ { flow: "recovery", configKey: config.ViperKeySelfServiceRecoveryNotifyUnknownRecipients, send: func(t *testing.T) { s, _, err := reg.RecoveryStrategies(ctx).ActiveStrategies("code") require.NoError(t, err) f, err := recovery.NewFlow(reg, time.Hour, "", u, s, flow.TypeBrowser) require.NoError(t, err) require.NoError(t, reg.RecoveryFlowPersister().CreateRecoveryFlow(ctx, f)) err = reg.CodeSender().SendRecoveryCode(ctx, f, "email", "not-tracked@ory.sh", nil) require.ErrorIs(t, err, code.ErrUnknownAddress()) }, }, { flow: "verification", configKey: config.ViperKeySelfServiceVerificationNotifyUnknownRecipients, send: func(t *testing.T) { s, _, err := reg.VerificationStrategies(ctx).ActiveStrategies("code") require.NoError(t, err) f, err := verification.NewFlow(reg, time.Hour, "", u, s, flow.TypeBrowser) require.NoError(t, err) require.NoError(t, reg.VerificationFlowPersister().CreateVerificationFlow(ctx, f)) err = reg.CodeSender().SendVerificationCode(ctx, f, "email", "not-tracked@ory.sh") require.ErrorIs(t, err, code.ErrUnknownAddress()) }, }, } { t.Run("strategy="+tc.flow, func(t *testing.T) { conf.MustSet(ctx, tc.configKey, false) t.Cleanup(func() { conf.MustSet(ctx, tc.configKey, true) }) tc.send(t) messages, err := reg.CourierPersister().NextMessages(ctx, 0) require.ErrorIs(t, err, courier.ErrQueueEmpty) require.Len(t, messages, 0) }) } }) t.Run("method=SendCode with OAuth2 login request", func(t *testing.T) { reg.WithCSRFTokenGenerator(nosurfx.FakeCSRFTokenGenerator) // Drain messages queued by earlier subtests. _, _ = reg.CourierPersister().NextMessages(ctx, 100) hlr := &hydraclientgo.OAuth2LoginRequest{ Challenge: "test-challenge", Client: hydraclientgo.OAuth2Client{ ClientId: hydraclientgo.PtrString("test-client-id"), ClientName: hydraclientgo.PtrString("Test Client"), ClientUri: hydraclientgo.PtrString("https://client.example.com"), LogoUri: hydraclientgo.PtrString("https://client.example.com/logo.png"), Metadata: map[string]any{"brand": "acme"}, }, } expectScopedRequest := func(t *testing.T, templateData []byte) { t.Helper() assert.Equal(t, "test-challenge", gjson.GetBytes(templateData, "oauth2_login_request.challenge").String()) assert.Equal(t, "test-client-id", gjson.GetBytes(templateData, "oauth2_login_request.client.client_id").String()) assert.Equal(t, "Test Client", gjson.GetBytes(templateData, "oauth2_login_request.client.client_name").String()) assert.Equal(t, "https://client.example.com", gjson.GetBytes(templateData, "oauth2_login_request.client.client_uri").String()) assert.Equal(t, "https://client.example.com/logo.png", gjson.GetBytes(templateData, "oauth2_login_request.client.logo_uri").String()) assert.Equal(t, "acme", gjson.GetBytes(templateData, "oauth2_login_request.client.metadata.brand").String()) // Only the scoped fields may be exposed to messaging channels. assert.ElementsMatch(t, []string{"challenge", "client"}, jsonKeys(templateData, "oauth2_login_request")) assert.ElementsMatch(t, []string{"client_id", "client_name", "client_uri", "logo_uri", "metadata"}, jsonKeys(templateData, "oauth2_login_request.client")) } t.Run("flow=login", func(t *testing.T) { r := httptest.NewRequest("GET", "https://www.ory.com/self-service/login/browser", nil) f, err := login.NewFlow(reg, r, flow.TypeBrowser) require.NoError(t, err) f.HydraLoginRequest = hlr require.NoError(t, reg.LoginFlowPersister().CreateLoginFlow(ctx, f)) require.NoError(t, reg.CodeSender().SendCode(ctx, f, i, http.Header{}, code.Address{To: "tracked@ory.sh", Via: identity.ChannelTypeEmail}, code.Address{To: phoneNumberKnown, Via: identity.ChannelTypeSMS}, )) messages, err := reg.CourierPersister().NextMessages(ctx, 12) require.NoError(t, err) require.Len(t, messages, 2) for _, m := range messages { assert.EqualValues(t, template.TypeLoginCodeValid, m.TemplateType) expectScopedRequest(t, m.TemplateData) } }) t.Run("flow=registration", func(t *testing.T) { r := httptest.NewRequest("GET", "https://www.ory.com/self-service/registration/browser", nil) f, err := registration.NewFlow(reg, r, flow.TypeBrowser) require.NoError(t, err) f.HydraLoginRequest = hlr require.NoError(t, reg.RegistrationFlowPersister().CreateRegistrationFlow(ctx, f)) require.NoError(t, reg.CodeSender().SendCode(ctx, f, i, http.Header{}, code.Address{To: "tracked@ory.sh", Via: identity.ChannelTypeEmail}, code.Address{To: phoneNumberKnown, Via: identity.ChannelTypeSMS}, )) messages, err := reg.CourierPersister().NextMessages(ctx, 12) require.NoError(t, err) require.Len(t, messages, 2) for _, m := range messages { assert.EqualValues(t, template.TypeRegistrationCodeValid, m.TemplateType) expectScopedRequest(t, m.TemplateData) } }) t.Run("flow=login without OAuth2 login request", func(t *testing.T) { r := httptest.NewRequest("GET", "https://www.ory.com/self-service/login/browser", nil) f, err := login.NewFlow(reg, r, flow.TypeBrowser) require.NoError(t, err) require.NoError(t, reg.LoginFlowPersister().CreateLoginFlow(ctx, f)) require.NoError(t, reg.CodeSender().SendCode(ctx, f, i, http.Header{}, code.Address{To: "tracked@ory.sh", Via: identity.ChannelTypeEmail}, )) messages, err := reg.CourierPersister().NextMessages(ctx, 12) require.NoError(t, err) require.Len(t, messages, 1) assert.False(t, gjson.GetBytes(messages[0].TemplateData, "oauth2_login_request").Exists()) }) t.Run("flow=verification", func(t *testing.T) { reg.SetHydra(hydra.NewFake()) f, err := verification.NewFlow(reg, time.Hour, "", u, verification.Strategies{code.NewStrategy(reg)}, flow.TypeBrowser) require.NoError(t, err) f.OAuth2LoginChallenge = sqlxx.NullString(hydra.FakeValidLoginChallenge) require.NoError(t, reg.VerificationFlowPersister().CreateVerificationFlow(ctx, f)) require.NoError(t, reg.CodeSender().SendVerificationCode(ctx, f, "email", "tracked@ory.sh")) messages, err := reg.CourierPersister().NextMessages(ctx, 12) require.NoError(t, err) require.Len(t, messages, 1) assert.EqualValues(t, template.TypeVerificationCodeValid, messages[0].TemplateType) templateData := messages[0].TemplateData assert.Equal(t, hydra.FakeValidLoginChallenge, gjson.GetBytes(templateData, "oauth2_login_request.challenge").String()) assert.Equal(t, hydra.FakeClientID, gjson.GetBytes(templateData, "oauth2_login_request.client.client_id").String()) assert.ElementsMatch(t, []string{"challenge", "client"}, jsonKeys(templateData, "oauth2_login_request")) }) t.Run("flow=verification without OAuth2 login challenge", func(t *testing.T) { f, err := verification.NewFlow(reg, time.Hour, "", u, verification.Strategies{code.NewStrategy(reg)}, flow.TypeBrowser) require.NoError(t, err) require.NoError(t, reg.VerificationFlowPersister().CreateVerificationFlow(ctx, f)) require.NoError(t, reg.CodeSender().SendVerificationCode(ctx, f, "email", "tracked@ory.sh")) messages, err := reg.CourierPersister().NextMessages(ctx, 12) require.NoError(t, err) require.Len(t, messages, 1) assert.False(t, gjson.GetBytes(messages[0].TemplateData, "oauth2_login_request").Exists()) }) }) } func jsonKeys(data []byte, path string) []string { var keys []string for key := range gjson.GetBytes(data, path).Map() { keys = append(keys, key) } return keys }