snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
This commit is contained in:
67
cmd/cleanup/handler.go
Normal file
67
cmd/cleanup/handler.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cleanup
|
||||
|
||||
import (
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/x/contextx"
|
||||
|
||||
"github.com/ory/x/configx"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/kratos/driver"
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/x/flagx"
|
||||
)
|
||||
|
||||
type CleanupHandler struct{}
|
||||
|
||||
func NewCleanupHandler() *CleanupHandler {
|
||||
return &CleanupHandler{}
|
||||
}
|
||||
|
||||
func (h *CleanupHandler) CleanupSQL(cmd *cobra.Command, args []string) error {
|
||||
opts := []configx.OptionModifier{
|
||||
configx.WithFlags(cmd.Flags()),
|
||||
configx.SkipValidation(),
|
||||
}
|
||||
|
||||
if !flagx.MustGetBool(cmd, "read-from-env") {
|
||||
if len(args) != 1 {
|
||||
return errors.New(`expected to get the DSN as an argument, or the "read-from-env" flag`)
|
||||
}
|
||||
opts = append(opts, configx.WithValue(config.ViperKeyDSN, args[0]))
|
||||
}
|
||||
|
||||
d, err := driver.NewWithoutInit(
|
||||
cmd.Context(),
|
||||
cmd.ErrOrStderr(),
|
||||
driver.WithConfigOptions(opts...),
|
||||
)
|
||||
if len(d.Config().DSN(cmd.Context())) == 0 {
|
||||
return errors.New(`required config value "dsn" was not set`)
|
||||
} else if err != nil {
|
||||
return errors.Wrap(err, "An error occurred initializing cleanup")
|
||||
}
|
||||
|
||||
err = d.Init(cmd.Context(), &contextx.Default{})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "An error occurred initializing cleanup")
|
||||
}
|
||||
|
||||
keepLast := flagx.MustGetDuration(cmd, "keep-last")
|
||||
|
||||
err = d.Persister().CleanupDatabase(
|
||||
cmd.Context(),
|
||||
d.Config().DatabaseCleanupSleepTables(cmd.Context()),
|
||||
keepLast,
|
||||
d.Config().DatabaseCleanupBatchSize(cmd.Context()))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "An error occurred while cleaning up expired data")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
25
cmd/cleanup/root.go
Normal file
25
cmd/cleanup/root.go
Normal file
@@ -0,0 +1,25 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cleanup
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/x/configx"
|
||||
)
|
||||
|
||||
func NewCleanupCmd() *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: "cleanup",
|
||||
Short: "Various cleanup helpers",
|
||||
}
|
||||
configx.RegisterFlags(c.PersistentFlags())
|
||||
return c
|
||||
}
|
||||
|
||||
func RegisterCommandRecursive(parent *cobra.Command) {
|
||||
c := NewCleanupCmd()
|
||||
parent.AddCommand(c)
|
||||
c.AddCommand(NewCleanupSQLCmd())
|
||||
}
|
||||
49
cmd/cleanup/sql.go
Normal file
49
cmd/cleanup/sql.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cleanup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/kratos/driver/config"
|
||||
|
||||
"github.com/ory/x/configx"
|
||||
)
|
||||
|
||||
// cleanupSqlCmd represents the sql command
|
||||
func NewCleanupSQLCmd() *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: "sql <database-url>",
|
||||
Short: "Cleanup sql database from expired flows and sessions",
|
||||
Long: `Run this command as frequently as you need.
|
||||
It is recommended to run this command close to the SQL instance (e.g. same subnet) instead of over the public internet.
|
||||
This decreases risk of failure and decreases time required.
|
||||
You can read in the database URL using the -e flag, for example:
|
||||
export DSN=...
|
||||
kratos cleanup sql -e
|
||||
### WARNING ###
|
||||
Before running this command on an existing database, create a back up!
|
||||
`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
err := NewCleanupHandler().CleanupSQL(cmd, args)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), err)
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
configx.RegisterFlags(c.PersistentFlags())
|
||||
c.Flags().BoolP("read-from-env", "e", true, "If set, reads the database connection string from the environment variable DSN or config file key dsn.")
|
||||
c.Flags().Duration(config.ViperKeyDatabaseCleanupSleepTables, time.Minute, "How long to wait between each table cleanup")
|
||||
c.Flags().IntP(config.ViperKeyDatabaseCleanupBatchSize, "b", 100, "Set the number of records to be cleaned per run")
|
||||
c.Flags().Duration("keep-last", 0, "Don't remove records younger than")
|
||||
return c
|
||||
}
|
||||
27
cmd/cleanup/sql_test.go
Normal file
27
cmd/cleanup/sql_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cleanup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func Test_ExecuteCleanupFailedDSN(t *testing.T) {
|
||||
cmd := NewCleanupSQLCmd()
|
||||
b := bytes.NewBufferString("")
|
||||
cmd.SetOut(b)
|
||||
cmd.SetArgs([]string{"--read-from-env=false"})
|
||||
_ = cmd.Execute()
|
||||
out, err := io.ReadAll(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(out), "expected to get the DSN as an argument") {
|
||||
t.Fatalf("expected \"%s\" got \"%s\"", "expected to get the DSN as an argument", string(out))
|
||||
}
|
||||
_ = cmd.Execute()
|
||||
}
|
||||
82
cmd/cliclient/client.go
Normal file
82
cmd/cliclient/client.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cliclient
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
kratos "github.com/ory/kratos/pkg/httpclient"
|
||||
"github.com/ory/x/httpx"
|
||||
)
|
||||
|
||||
const (
|
||||
envKeyEndpoint = "KRATOS_ADMIN_URL"
|
||||
FlagEndpoint = "endpoint"
|
||||
)
|
||||
|
||||
type ContextKey int
|
||||
|
||||
const (
|
||||
ClientContextKey ContextKey = iota + 1
|
||||
)
|
||||
|
||||
type ClientContext struct {
|
||||
Endpoint string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
func NewClient(cmd *cobra.Command) (*kratos.APIClient, error) {
|
||||
if f, ok := cmd.Context().Value(ClientContextKey).(func(cmd *cobra.Command) (*ClientContext, error)); ok {
|
||||
cc, err := f(cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conf := kratos.NewConfiguration()
|
||||
conf.HTTPClient = cc.HTTPClient
|
||||
conf.Servers = kratos.ServerConfigurations{{URL: cc.Endpoint}}
|
||||
return kratos.NewAPIClient(conf), nil
|
||||
} else if f != nil {
|
||||
return nil, errors.Errorf("ClientContextKey was expected to be *client.OryKratos but it contained an invalid type %T ", f)
|
||||
}
|
||||
|
||||
endpoint, err := cmd.Flags().GetString(FlagEndpoint)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
|
||||
if endpoint == "" {
|
||||
endpoint = os.Getenv(envKeyEndpoint)
|
||||
}
|
||||
|
||||
if endpoint == "" {
|
||||
return nil, errors.Errorf("you have to set the remote endpoint, try --help for details")
|
||||
}
|
||||
|
||||
u, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, `could not parse the endpoint URL "%s"`, endpoint)
|
||||
}
|
||||
|
||||
conf := kratos.NewConfiguration()
|
||||
conf.HTTPClient = httpx.NewResilientClient(
|
||||
httpx.ResilientClientWithConnectionTimeout(10 * time.Second),
|
||||
).StandardClient()
|
||||
conf.Servers = kratos.ServerConfigurations{{URL: u.String()}}
|
||||
return kratos.NewAPIClient(conf), nil
|
||||
}
|
||||
|
||||
func RegisterClientFlags(flags *pflag.FlagSet) {
|
||||
flags.StringP(FlagEndpoint, FlagEndpoint[:1], "", fmt.Sprintf("The URL of Ory Kratos' Admin API. Alternatively set using the %s environmental variable.", envKeyEndpoint))
|
||||
}
|
||||
431
cmd/clidoc/main.go
Normal file
431
cmd/clidoc/main.go
Normal file
@@ -0,0 +1,431 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/importer"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"go/types"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/kratos/cmd"
|
||||
"github.com/ory/kratos/text"
|
||||
|
||||
"github.com/ory/x/clidoc"
|
||||
"github.com/ory/x/clock"
|
||||
)
|
||||
|
||||
var (
|
||||
aSecondAgo = time.Date(2020, 1, 1, 1, 0, 0, 0, time.UTC).Add(-time.Second)
|
||||
inAMinute = time.Date(2020, 1, 1, 1, 0, 0, 0, time.UTC).Add(time.Minute)
|
||||
|
||||
// Fixed clocks make the rendered "expired N minutes ago" / "within the next
|
||||
// N minutes" example messages deterministic for doc generation. They are
|
||||
// anchored so the durations come out as exactly one minute.
|
||||
docExpiredClock = clock.NewMock(aSecondAgo.Add(time.Minute))
|
||||
docUntilClock = clock.NewMock(inAMinute.Add(-time.Minute))
|
||||
)
|
||||
|
||||
var messages map[string]*text.Message
|
||||
|
||||
func init() {
|
||||
messages = map[string]*text.Message{
|
||||
"NewInfoNodeLabelVerifyOTP": text.NewInfoNodeLabelVerifyOTP(),
|
||||
"NewInfoNodeLabelVerificationCode": text.NewInfoNodeLabelVerificationCode(),
|
||||
"NewInfoNodeLabelRecoveryCode": text.NewInfoNodeLabelRecoveryCode(),
|
||||
"NewInfoNodeInputPassword": text.NewInfoNodeInputPassword(),
|
||||
"NewInfoNodeInputPhoneNumber": text.NewInfoNodeInputPhoneNumber(),
|
||||
"NewInfoNodeLabelGenerated": text.NewInfoNodeLabelGenerated("{title}", "{name}"),
|
||||
"NewInfoNodeLabelSave": text.NewInfoNodeLabelSave(),
|
||||
"NewInfoNodeLabelSubmit": text.NewInfoNodeLabelSubmit(),
|
||||
"NewInfoNodeLabelID": text.NewInfoNodeLabelID(),
|
||||
"NewErrorValidationSettingsFlowExpired": text.NewErrorValidationSettingsFlowExpired(docExpiredClock, aSecondAgo),
|
||||
"NewInfoSelfServiceSettingsTOTPQRCode": text.NewInfoSelfServiceSettingsTOTPQRCode(),
|
||||
"NewInfoSelfServiceSettingsTOTPSecret": text.NewInfoSelfServiceSettingsTOTPSecret("{secret}"),
|
||||
"NewInfoSelfServiceSettingsTOTPSecretLabel": text.NewInfoSelfServiceSettingsTOTPSecretLabel(),
|
||||
"NewInfoSelfServiceSettingsUpdateSuccess": text.NewInfoSelfServiceSettingsUpdateSuccess(),
|
||||
"NewInfoSelfServiceSettingsUpdateUnlinkTOTP": text.NewInfoSelfServiceSettingsUpdateUnlinkTOTP(),
|
||||
"NewInfoSelfServiceSettingsRevealLookup": text.NewInfoSelfServiceSettingsRevealLookup(),
|
||||
"NewInfoSelfServiceSettingsRegenerateLookup": text.NewInfoSelfServiceSettingsRegenerateLookup(),
|
||||
"NewInfoSelfServiceSettingsDisableLookup": text.NewInfoSelfServiceSettingsDisableLookup(),
|
||||
"NewInfoSelfServiceSettingsLookupConfirm": text.NewInfoSelfServiceSettingsLookupConfirm(),
|
||||
"NewInfoSelfServiceSettingsManagedByOrganization": text.NewInfoSelfServiceSettingsManagedByOrganization(),
|
||||
"NewInfoSelfServiceSettingsLookupSecretList": text.NewInfoSelfServiceSettingsLookupSecretList([]string{"{secrets_list}"}, []interface{}{
|
||||
text.NewInfoSelfServiceSettingsLookupSecret("{secret}"),
|
||||
text.NewInfoSelfServiceSettingsLookupSecretUsed(aSecondAgo),
|
||||
}),
|
||||
"NewInfoSelfServiceSettingsLookupSecret": text.NewInfoSelfServiceSettingsLookupSecret("{secret}"),
|
||||
"NewInfoSelfServiceSettingsLookupSecretUsed": text.NewInfoSelfServiceSettingsLookupSecretUsed(aSecondAgo),
|
||||
"NewInfoSelfServiceSettingsLookupSecretsLabel": text.NewInfoSelfServiceSettingsLookupSecretsLabel(),
|
||||
"NewInfoSelfServiceSettingsUpdateLinkOIDC": text.NewInfoSelfServiceSettingsUpdateLinkOIDC("{provider}"),
|
||||
"NewInfoSelfServiceSettingsUpdateUnlinkOIDC": text.NewInfoSelfServiceSettingsUpdateUnlinkOIDC("{provider}"),
|
||||
"NewInfoSelfServiceRegisterWebAuthnDisplayName": text.NewInfoSelfServiceRegisterWebAuthnDisplayName(),
|
||||
"NewInfoSelfServiceRemoveWebAuthn": text.NewInfoSelfServiceRemoveWebAuthn("{display_name}", aSecondAgo),
|
||||
"NewInfoSelfServiceRemovePasskey": text.NewInfoSelfServiceRemovePasskey("{display_name}", aSecondAgo),
|
||||
"NewErrorValidationVerificationFlowExpired": text.NewErrorValidationVerificationFlowExpired(docExpiredClock, aSecondAgo),
|
||||
"NewInfoSelfServiceVerificationSuccessful": text.NewInfoSelfServiceVerificationSuccessful(),
|
||||
"NewVerificationEmailSent": text.NewVerificationEmailSent(),
|
||||
"NewVerificationEmailWithCodeSent": text.NewVerificationEmailWithCodeSent(),
|
||||
"NewVerificationPhoneWithCodeSent": text.NewVerificationPhoneWithCodeSent(),
|
||||
"NewInfoSelfServiceVerificationPhoneSuccessful": text.NewInfoSelfServiceVerificationPhoneSuccessful(),
|
||||
"NewErrorValidationVerificationTokenInvalidOrAlreadyUsed": text.NewErrorValidationVerificationTokenInvalidOrAlreadyUsed(),
|
||||
"NewErrorValidationVerificationRetrySuccess": text.NewErrorValidationVerificationRetrySuccess(),
|
||||
"NewErrorValidationVerificationStateFailure": text.NewErrorValidationVerificationStateFailure(),
|
||||
"NewErrorValidationVerificationCodeInvalidOrAlreadyUsed": text.NewErrorValidationVerificationCodeInvalidOrAlreadyUsed(),
|
||||
"NewErrorSystemGeneric": text.NewErrorSystemGeneric("{reason}"),
|
||||
"NewErrorSystemNoAuthenticationMethodsAvailable": text.NewErrorSystemNoAuthenticationMethodsAvailable(),
|
||||
"NewErrorSystemOrganizationNoSSOProvidersAvailable": text.NewErrorSystemOrganizationNoSSOProvidersAvailable(),
|
||||
"NewValidationErrorGeneric": text.NewValidationErrorGeneric("{reason}"),
|
||||
"NewValidationErrorRequired": text.NewValidationErrorRequired("{property}"),
|
||||
"NewErrorValidationMinLength": text.NewErrorValidationMinLength(5, 3),
|
||||
"NewErrorValidationMaxLength": text.NewErrorValidationMaxLength(5, 6),
|
||||
"NewErrorValidationInvalidFormat": text.NewErrorValidationInvalidFormat("{pattern}"),
|
||||
"NewErrorValidationMinimum": text.NewErrorValidationMinimum(5, 3),
|
||||
"NewErrorValidationExclusiveMinimum": text.NewErrorValidationExclusiveMinimum(5, 5),
|
||||
"NewErrorValidationMaximum": text.NewErrorValidationMaximum(5, 6),
|
||||
"NewErrorValidationExclusiveMaximum": text.NewErrorValidationExclusiveMaximum(5, 5),
|
||||
"NewErrorValidationMultipleOf": text.NewErrorValidationMultipleOf(7, 3),
|
||||
"NewErrorValidationMaxItems": text.NewErrorValidationMaxItems(3, 4),
|
||||
"NewErrorValidationMinItems": text.NewErrorValidationMinItems(3, 2),
|
||||
"NewErrorValidationUniqueItems": text.NewErrorValidationUniqueItems(0, 2),
|
||||
"NewErrorValidationWrongType": text.NewErrorValidationWrongType([]string{"{allowed_types_list}"}, "{actual_type}"),
|
||||
"NewErrorValidationConst": text.NewErrorValidationConst("{expected}"),
|
||||
"NewErrorValidationConstGeneric": text.NewErrorValidationConstGeneric(),
|
||||
"NewErrorValidationPasswordPolicyViolationGeneric": text.NewErrorValidationPasswordPolicyViolationGeneric("{reason}"),
|
||||
"NewErrorValidationPasswordIdentifierTooSimilar": text.NewErrorValidationPasswordIdentifierTooSimilar(),
|
||||
"NewErrorValidationPasswordMinLength": text.NewErrorValidationPasswordMinLength(6, 5),
|
||||
"NewErrorValidationPasswordMaxLength": text.NewErrorValidationPasswordMaxLength(72, 80),
|
||||
"NewErrorValidationPasswordTooManyBreaches": text.NewErrorValidationPasswordTooManyBreaches(101),
|
||||
"NewErrorValidationPasswordNewSameAsOld": text.NewErrorValidationPasswordNewSameAsOld(),
|
||||
"NewErrorValidationInvalidCredentials": text.NewErrorValidationInvalidCredentials(),
|
||||
"NewErrorValidationDuplicateCredentials": text.NewErrorValidationDuplicateCredentials(),
|
||||
"NewErrorValidationDuplicateCredentialsWithHints": text.NewErrorValidationDuplicateCredentialsWithHints([]string{"{available_credential_types_list}"}, []string{"{available_oidc_providers_list}"}, "{credential_identifier_hint}"),
|
||||
"NewErrorValidationDuplicateCredentialsOnOIDCLink": text.NewErrorValidationDuplicateCredentialsOnOIDCLink(),
|
||||
"NewErrorValidationTOTPVerifierWrong": text.NewErrorValidationTOTPVerifierWrong(),
|
||||
"NewErrorValidationWebAuthnVerifierWrong": text.NewErrorValidationWebAuthnVerifierWrong(),
|
||||
"NewErrorValidationDeviceAuthnVerifierWrong": text.NewErrorValidationDeviceAuthnVerifierWrong(),
|
||||
"NewErrorValidationDeviceAuthnRelaxedAttestationNoLongerValid": text.NewErrorValidationDeviceAuthnRelaxedAttestationNoLongerValid(),
|
||||
"NewErrorValidationDeviceAuthnKeyReenrollmentRequired": text.NewErrorValidationDeviceAuthnKeyReenrollmentRequired(),
|
||||
"NewErrorValidationLookupAlreadyUsed": text.NewErrorValidationLookupAlreadyUsed(),
|
||||
"NewErrorValidationLookupInvalid": text.NewErrorValidationLookupInvalid(),
|
||||
"NewErrorValidationIdentifierMissing": text.NewErrorValidationIdentifierMissing(),
|
||||
"NewErrorValidationAddressNotVerified": text.NewErrorValidationAddressNotVerified(),
|
||||
"NewErrorValidationNoTOTPDevice": text.NewErrorValidationNoTOTPDevice(),
|
||||
"NewErrorValidationNoLookup": text.NewErrorValidationNoLookup(),
|
||||
"NewErrorValidationNoWebAuthnDevice": text.NewErrorValidationNoWebAuthnDevice(),
|
||||
"NewInfoLoginReAuth": text.NewInfoLoginReAuth(),
|
||||
"NewInfoLoginMFA": text.NewInfoLoginMFA(),
|
||||
"NewInfoLoginTOTPLabel": text.NewInfoLoginTOTPLabel(),
|
||||
"NewInfoLoginLookupLabel": text.NewInfoLoginLookupLabel(),
|
||||
"NewInfoLogin": text.NewInfoLogin(),
|
||||
"NewInfoLoginAndLink": text.NewInfoLoginAndLink(),
|
||||
"NewInfoLoginLinkMessage": text.NewInfoLoginLinkMessage("{duplicateIdentifier}", "{provider}", "{newLoginUrl}", []string{"{available_credential_types_list}"}, []string{"{available_oidc_providers_list}"}),
|
||||
"NewInfoLoginTOTP": text.NewInfoLoginTOTP(),
|
||||
"NewInfoLoginLookup": text.NewInfoLoginLookup(),
|
||||
"NewInfoLoginVerify": text.NewInfoLoginVerify(),
|
||||
"NewInfoLoginWith": text.NewInfoLoginWith("{provider}", "{providerID}"),
|
||||
"NewInfoLoginWithAndLink": text.NewInfoLoginWithAndLink("{provider}"),
|
||||
"NewErrorValidationNoDeviceAuthnDevice": text.NewErrorValidationNoDeviceAuthnDevice(),
|
||||
"NewErrorValidationLoginFlowExpired": text.NewErrorValidationLoginFlowExpired(docExpiredClock, aSecondAgo),
|
||||
"NewErrorValidationLoginNoStrategyFound": text.NewErrorValidationLoginNoStrategyFound(),
|
||||
"NewErrorValidationRegistrationNoStrategyFound": text.NewErrorValidationRegistrationNoStrategyFound(),
|
||||
"NewErrorValidationSettingsNoStrategyFound": text.NewErrorValidationSettingsNoStrategyFound(),
|
||||
"NewErrorValidationRecoveryNoStrategyFound": text.NewErrorValidationRecoveryNoStrategyFound(),
|
||||
"NewErrorValidationVerificationNoStrategyFound": text.NewErrorValidationVerificationNoStrategyFound(),
|
||||
"NewInfoSelfServiceLoginWebAuthn": text.NewInfoSelfServiceLoginWebAuthn(),
|
||||
"NewInfoSelfServiceLoginDeviceAuthn": text.NewInfoSelfServiceLoginDeviceAuthn(),
|
||||
"NewInfoRegistration": text.NewInfoRegistration(),
|
||||
"NewInfoRegistrationWith": text.NewInfoRegistrationWith("{provider}", "{providerID}"),
|
||||
"NewInfoRegistrationContinue": text.NewInfoRegistrationContinue(),
|
||||
"NewInfoRegistrationBack": text.NewInfoRegistrationBack(),
|
||||
"NewInfoSelfServiceChooseCredentials": text.NewInfoSelfServiceChooseCredentials(),
|
||||
"NewErrorValidationRegistrationFlowExpired": text.NewErrorValidationRegistrationFlowExpired(docExpiredClock, aSecondAgo),
|
||||
"NewErrorValidationRecoveryFlowExpired": text.NewErrorValidationRecoveryFlowExpired(docExpiredClock, aSecondAgo),
|
||||
"NewRecoverySuccessful": text.NewRecoverySuccessful(docUntilClock, inAMinute),
|
||||
"NewRecoveryEmailSent": text.NewRecoveryEmailSent(),
|
||||
"NewRecoveryEmailWithCodeSent": text.NewRecoveryEmailWithCodeSent(),
|
||||
"NewRecoveryCodeRecoverySelectAddressSent": text.NewRecoveryCodeRecoverySelectAddressSent("{masked_address}"),
|
||||
"NewRecoveryAskAnyRecoveryAddress": text.NewRecoveryAskAnyRecoveryAddress(),
|
||||
"NewRecoveryAskForFullAddress": text.NewRecoveryAskForFullAddress(),
|
||||
"NewRecoveryAskToChooseAddress": text.NewRecoveryAskToChooseAddress(),
|
||||
"NewRecoveryBack": text.NewRecoveryBack(),
|
||||
"NewErrorValidationRecoveryTokenInvalidOrAlreadyUsed": text.NewErrorValidationRecoveryTokenInvalidOrAlreadyUsed(),
|
||||
"NewErrorValidationRecoveryCodeInvalidOrAlreadyUsed": text.NewErrorValidationRecoveryCodeInvalidOrAlreadyUsed(),
|
||||
"NewErrorValidationRecoveryRetrySuccess": text.NewErrorValidationRecoveryRetrySuccess(),
|
||||
"NewErrorValidationRecoveryStateFailure": text.NewErrorValidationRecoveryStateFailure(),
|
||||
"NewInfoNodeInputEmail": text.NewInfoNodeInputEmail(),
|
||||
"NewInfoNodeInputEmailOrPhone": text.NewInfoNodeInputEmailOrPhone(),
|
||||
"NewInfoNodeResendOTP": text.NewInfoNodeResendOTP(),
|
||||
"NewInfoNodeLoginAndLinkCredential": text.NewInfoNodeLoginAndLinkCredential(),
|
||||
"NewInfoNodeLabelContinue": text.NewInfoNodeLabelContinue(),
|
||||
"NewInfoSelfServiceSettingsRegisterWebAuthn": text.NewInfoSelfServiceSettingsRegisterWebAuthn(),
|
||||
"NewInfoSelfServiceSettingsRegisterPasskey": text.NewInfoSelfServiceSettingsRegisterPasskey(),
|
||||
"NewInfoLoginWebAuthnPasswordless": text.NewInfoLoginWebAuthnPasswordless(),
|
||||
"NewInfoSelfServiceRegistrationRegisterWebAuthn": text.NewInfoSelfServiceRegistrationRegisterWebAuthn(),
|
||||
"NewInfoSelfServiceContinueLoginWebAuthn": text.NewInfoSelfServiceContinueLoginWebAuthn(),
|
||||
"NewInfoSelfServiceLoginPasskey": text.NewInfoSelfServiceLoginPasskey(),
|
||||
"NewInfoSelfServiceRegistrationRegisterPasskey": text.NewInfoSelfServiceRegistrationRegisterPasskey(),
|
||||
"NewInfoSelfServiceLoginContinue": text.NewInfoSelfServiceLoginContinue(),
|
||||
"NewErrorValidationSuchNoWebAuthnUser": text.NewErrorValidationSuchNoWebAuthnUser(),
|
||||
"NewRegistrationEmailWithCodeSent": text.NewRegistrationEmailWithCodeSent(),
|
||||
"NewLoginCodeSent": text.NewLoginCodeSent(),
|
||||
"NewLoginCodeSentForAuthenticatedUser": text.NewLoginCodeSentForAuthenticatedUser(),
|
||||
"NewErrorValidationRegistrationCodeInvalidOrAlreadyUsed": text.NewErrorValidationRegistrationCodeInvalidOrAlreadyUsed(),
|
||||
"NewErrorValidationLoginCodeInvalidOrAlreadyUsed": text.NewErrorValidationLoginCodeInvalidOrAlreadyUsed(),
|
||||
"NewErrorValidationNoCodeUser": text.NewErrorValidationNoCodeUser(),
|
||||
"NewInfoNodeLabelRegistrationCode": text.NewInfoNodeLabelRegistrationCode(),
|
||||
"NewInfoNodeLabelLoginCode": text.NewInfoNodeLabelLoginCode(),
|
||||
"NewErrorValidationLoginRetrySuccessful": text.NewErrorValidationLoginRetrySuccessful(),
|
||||
"NewErrorValidationTraitsMismatch": text.NewErrorValidationTraitsMismatch(),
|
||||
"NewInfoSelfServiceLoginCode": text.NewInfoSelfServiceLoginCode(),
|
||||
"NewErrorValidationRegistrationRetrySuccessful": text.NewErrorValidationRegistrationRetrySuccessful(),
|
||||
"NewInfoSelfServiceRegistrationRegisterCode": text.NewInfoSelfServiceRegistrationRegisterCode(),
|
||||
"NewErrorValidationLoginLinkedCredentialsDoNotMatch": text.NewErrorValidationLoginLinkedCredentialsDoNotMatch(),
|
||||
"NewErrorValidationAddressUnknown": text.NewErrorValidationAddressUnknown(),
|
||||
"NewInfoSelfServiceLoginCodeMFA": text.NewInfoSelfServiceLoginCodeMFA(),
|
||||
"NewInfoLoginPassword": text.NewInfoLoginPassword(),
|
||||
"NewErrorValidationAccountNotFound": text.NewErrorValidationAccountNotFound(),
|
||||
"NewInfoSelfServiceLoginAAL2CodeAddress": text.NewInfoSelfServiceLoginAAL2CodeAddress("{channel}", "{address}"),
|
||||
"NewErrorCaptchaFailed": text.NewErrorCaptchaFailed(),
|
||||
"NewCaptchaContainerMessage": text.NewCaptchaContainerMessage(),
|
||||
"NewErrorValidationEmail": text.NewErrorValidationEmail("{value}"),
|
||||
"NewErrorValidationPhone": text.NewErrorValidationPhone("{value}"),
|
||||
"NewErrorValidationIdentityDisabled": text.NewErrorValidationIdentityDisabled(),
|
||||
"NewErrorValidationSettingsTooManyAddressChanges": text.NewErrorValidationSettingsTooManyAddressChanges(),
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
if os.Args[1] == "elements" {
|
||||
path, err := filepath.Abs(os.Args[2])
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "Unable to determine absolute path for elements generation: %+v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := generateElements(path); err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "Unable to generate locales for elements: %+v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err := clidoc.Generate(cmd.NewRootCmd(nil, nil), []string{filepath.Join(os.Args[2], "cli")}); err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "Unable to generate CLI docs: %+v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := validateAllMessages(filepath.Join(os.Args[1], "text")); err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "Unable to validate messages: %+v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
sortedMessages := sortMessages()
|
||||
for i := 1; i < len(sortedMessages); i++ {
|
||||
if sortedMessages[i].ID == sortedMessages[i-1].ID {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "Message ID %d is used more than once: %q %q\n", sortedMessages[i].ID, sortedMessages[i].Text, sortedMessages[i-1].Text)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeMessages(filepath.Join(os.Args[2], "concepts/ui-messages.md"), sortedMessages); err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "Unable to generate message table: %+v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := writeMessagesJson(filepath.Join(os.Args[2], "concepts/messages.json"), sortedMessages); err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "Unable to generate messages.json: %+v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("All files have been generated and updated.")
|
||||
}
|
||||
|
||||
func codeEncode(in interface{}) string {
|
||||
out, err := json.MarshalIndent(in, "", " ")
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "Unable to encode to JSON: %+v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func sortMessages() []*text.Message {
|
||||
var toSort []*text.Message
|
||||
for _, m := range messages {
|
||||
toSort = append(toSort, m)
|
||||
}
|
||||
|
||||
sort.Slice(toSort, func(i, j int) bool {
|
||||
if toSort[i].ID == toSort[j].ID {
|
||||
return toSort[i].Text < toSort[j].Text
|
||||
}
|
||||
return toSort[i].ID < toSort[j].ID
|
||||
})
|
||||
|
||||
return toSort
|
||||
}
|
||||
|
||||
func writeMessages(path string, sortedMessages []*text.Message) error {
|
||||
//nolint:gosec
|
||||
content, err := os.ReadFile(path) // #nosec
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var w bytes.Buffer
|
||||
for _, m := range sortedMessages {
|
||||
fmt.Fprintf(&w, `###### %s (%d)
|
||||
|
||||
%s
|
||||
|
||||
`, m.Text, m.ID, "```json\n"+codeEncode(m)+"\n```")
|
||||
}
|
||||
|
||||
r := regexp.MustCompile(`(?s)<!-- START MESSAGE TABLE -->(.*?)<!-- END MESSAGE TABLE -->`)
|
||||
result := r.ReplaceAllString(string(content), "<!-- START MESSAGE TABLE -->\n"+w.String()+"\n<!-- END MESSAGE TABLE -->")
|
||||
|
||||
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) // #nosec
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := f.WriteString(result); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeMessagesJson(path string, sortedMessages []*text.Message) error {
|
||||
result := codeEncode(sortedMessages)
|
||||
|
||||
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644) // #nosec
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := f.WriteString(result); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAllMessages(path string) error {
|
||||
type message struct {
|
||||
ID, Name string
|
||||
}
|
||||
|
||||
usedIDs := make([]message, 0, len(messages))
|
||||
set := token.NewFileSet()
|
||||
packs, err := parser.ParseDir(set, path, nil, 0)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "unable to parse text directory")
|
||||
}
|
||||
info := &types.Info{
|
||||
Defs: make(map[*ast.Ident]types.Object),
|
||||
}
|
||||
|
||||
//nolint:staticcheck
|
||||
var pack *ast.Package
|
||||
for _, p := range packs {
|
||||
if p.Name == "text" {
|
||||
pack = p
|
||||
break
|
||||
}
|
||||
}
|
||||
allFiles := make([]*ast.File, 0)
|
||||
for fn, f := range pack.Files {
|
||||
if strings.HasSuffix(fn, "/id.go") {
|
||||
allFiles = append(allFiles, f)
|
||||
}
|
||||
}
|
||||
_, err = (&types.Config{Importer: importer.Default()}).Check("text", set, allFiles, info)
|
||||
if err != nil {
|
||||
return err // type error
|
||||
}
|
||||
|
||||
for _, f := range pack.Files {
|
||||
for _, d := range f.Decls {
|
||||
switch decl := d.(type) {
|
||||
case *ast.FuncDecl:
|
||||
if name := decl.Name.String(); decl.Name.IsExported() && strings.HasPrefix(name, "New") {
|
||||
if _, ok := messages[name]; !ok {
|
||||
return errors.Errorf("expected to find message %s in the list for the documentation generation but could not", name)
|
||||
}
|
||||
}
|
||||
case *ast.GenDecl:
|
||||
if decl.Tok == token.CONST {
|
||||
for _, spec := range decl.Specs {
|
||||
value := spec.(*ast.ValueSpec) // safe because decl.Tok is token.CONST
|
||||
if t, ok := value.Type.(*ast.Ident); ok {
|
||||
if t.Name == "ID" {
|
||||
for _, name := range value.Names {
|
||||
c := info.ObjectOf(name)
|
||||
if c == nil {
|
||||
return errors.Errorf("expected to find const %s in text/id.go", name.Name)
|
||||
}
|
||||
usedIDs = append(usedIDs, message{
|
||||
ID: c.(*types.Const).Val().ExactString(),
|
||||
Name: name.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(usedIDs, func(i, j int) bool {
|
||||
return usedIDs[i].ID < usedIDs[j].ID
|
||||
})
|
||||
for i := 1; i < len(usedIDs); i++ {
|
||||
if usedIDs[i].ID == usedIDs[i-1].ID {
|
||||
return errors.Errorf("message ID %s is used more than once: %s %s", usedIDs[i].ID, usedIDs[i].Name, usedIDs[i-1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateElements(messageFilePath string) error {
|
||||
// If the file at messageFilePath does not exist, create it
|
||||
f, err := os.OpenFile(messageFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) // #nosec
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "unable to open or create message file at %s", messageFilePath)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return errors.Wrapf(err, "unable to close message file at %s", messageFilePath)
|
||||
}
|
||||
|
||||
sortedMessages := sortMessages()
|
||||
if err := writeMessagesJson(messageFilePath, sortedMessages); err != nil {
|
||||
return errors.Wrapf(err, "unable to write messages json to %s", messageFilePath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
14
cmd/clidoc/main_test.go
Normal file
14
cmd/clidoc/main_test.go
Normal file
@@ -0,0 +1,14 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMessages(t *testing.T) {
|
||||
require.NoError(t, validateAllMessages("../../text"))
|
||||
}
|
||||
27
cmd/courier/root.go
Normal file
27
cmd/courier/root.go
Normal file
@@ -0,0 +1,27 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package courier
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/kratos/driver"
|
||||
"github.com/ory/x/configx"
|
||||
)
|
||||
|
||||
// NewCourierCmd creates a new courier command
|
||||
func NewCourierCmd() *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: "courier",
|
||||
Short: "Commands related to the Ory Kratos message courier",
|
||||
}
|
||||
configx.RegisterFlags(c.PersistentFlags())
|
||||
return c
|
||||
}
|
||||
|
||||
func RegisterCommandRecursive(parent *cobra.Command, dOpts []driver.RegistryOption) {
|
||||
c := NewCourierCmd()
|
||||
parent.AddCommand(c)
|
||||
c.AddCommand(NewWatchCmd(dOpts))
|
||||
}
|
||||
108
cmd/courier/watch.go
Normal file
108
cmd/courier/watch.go
Normal file
@@ -0,0 +1,108 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package courier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/urfave/negroni"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/ory/graceful"
|
||||
"github.com/ory/kratos/driver"
|
||||
"github.com/ory/x/configx"
|
||||
"github.com/ory/x/prometheusx"
|
||||
"github.com/ory/x/reqlog"
|
||||
)
|
||||
|
||||
func NewWatchCmd(dOpts []driver.RegistryOption) *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: "watch",
|
||||
Short: "Starts the Ory Kratos message courier",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
r, err := driver.New(cmd.Context(), cmd.ErrOrStderr(), append(dOpts, driver.WithConfigOptions(configx.WithFlags(cmd.Flags())))...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return StartCourier(cmd.Context(), r)
|
||||
},
|
||||
}
|
||||
c.PersistentFlags().Int("expose-metrics-port", 0, "The port to expose the metrics endpoint on (not exposed by default)")
|
||||
return c
|
||||
}
|
||||
|
||||
func StartCourier(ctx context.Context, r driver.Registry) error {
|
||||
eg, ctx := errgroup.WithContext(ctx)
|
||||
|
||||
if port := r.Config().CourierExposeMetricsPort(ctx); port != 0 {
|
||||
eg.Go(func() error {
|
||||
return ServeMetrics(ctx, r, port)
|
||||
})
|
||||
}
|
||||
|
||||
eg.Go(func() error {
|
||||
return Watch(ctx, r)
|
||||
})
|
||||
|
||||
return eg.Wait()
|
||||
}
|
||||
|
||||
func ServeMetrics(ctx context.Context, r driver.Registry, port int) error {
|
||||
cfg := r.Config().ServeAdmin(ctx)
|
||||
l := r.Logger()
|
||||
|
||||
recovery := negroni.NewRecovery()
|
||||
recovery.Logger = l
|
||||
|
||||
n := negroni.New(
|
||||
recovery,
|
||||
reqlog.NewMiddlewareFromLogger(l, "admin#"+cfg.BaseURL.String()),
|
||||
)
|
||||
|
||||
router := http.NewServeMux()
|
||||
router.Handle(prometheusx.MetricsPrometheusPath, prometheusx.Handler())
|
||||
n.UseHandler(router)
|
||||
|
||||
var handler http.Handler = n
|
||||
|
||||
//#nosec G112 -- the correct settings are set by graceful.WithDefaults
|
||||
server := graceful.WithDefaults(&http.Server{
|
||||
Addr: configx.GetAddress(cfg.Host, port),
|
||||
Handler: handler,
|
||||
})
|
||||
|
||||
l.WithField("addr", server.Addr).Info("Starting the metrics httpd")
|
||||
if err := graceful.GracefulContext(ctx, server.ListenAndServe, server.Shutdown); err != nil {
|
||||
l.Errorln("Failed to gracefully shutdown metrics httpd")
|
||||
return err
|
||||
}
|
||||
l.Println("Metrics httpd was shutdown gracefully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func Watch(ctx context.Context, r driver.Registry) error {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
r.Logger().Println("Courier worker started.")
|
||||
if err := graceful.Graceful(func() error {
|
||||
c, err := r.Courier(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.Work(ctx)
|
||||
}, func(_ context.Context) error {
|
||||
cancel()
|
||||
return nil
|
||||
}); err != nil {
|
||||
r.Logger().WithError(err).Error("Failed to run courier worker.")
|
||||
return err
|
||||
}
|
||||
|
||||
r.Logger().Println("Courier worker was shutdown gracefully.")
|
||||
return nil
|
||||
}
|
||||
47
cmd/courier/watch_test.go
Normal file
47
cmd/courier/watch_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package courier
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/phayes/freeport"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/kratos/pkg"
|
||||
"github.com/ory/x/configx"
|
||||
)
|
||||
|
||||
func TestStartCourier(t *testing.T) {
|
||||
t.Run("case=without metrics", func(t *testing.T) {
|
||||
_, r := pkg.NewFastRegistryWithMocks(t)
|
||||
go func() { _ = StartCourier(t.Context(), r) }()
|
||||
require.Equal(t, r.Config().CourierExposeMetricsPort(t.Context()), 0)
|
||||
})
|
||||
|
||||
t.Run("case=with metrics", func(t *testing.T) {
|
||||
port, err := freeport.GetFreePort()
|
||||
require.NoError(t, err)
|
||||
_, r := pkg.NewFastRegistryWithMocks(t, configx.WithValue("expose-metrics-port", port))
|
||||
go func() { _ = StartCourier(t.Context(), r) }()
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
t.Cleanup(transport.CloseIdleConnections)
|
||||
client := &http.Client{Transport: transport}
|
||||
url := fmt.Sprintf("http://127.0.0.1:%d/metrics/prometheus", port)
|
||||
require.EventuallyWithT(t, func(t *assert.CollectT) {
|
||||
res, err := client.Get(url)
|
||||
if !assert.NoError(t, err) {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
assert.NoError(t, res.Body.Close())
|
||||
}()
|
||||
assert.Equal(t, 200, res.StatusCode)
|
||||
}, 10*time.Second, 10*time.Millisecond)
|
||||
})
|
||||
}
|
||||
371
cmd/daemon/serve.go
Normal file
371
cmd/daemon/serve.go
Normal file
@@ -0,0 +1,371 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/cors"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/urfave/negroni"
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/ory/analytics-go/v5"
|
||||
"github.com/ory/graceful"
|
||||
"github.com/ory/kratos/cmd/courier"
|
||||
"github.com/ory/kratos/driver"
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/schema"
|
||||
"github.com/ory/kratos/selfservice/errorx"
|
||||
"github.com/ory/kratos/selfservice/flow/login"
|
||||
"github.com/ory/kratos/selfservice/flow/logout"
|
||||
"github.com/ory/kratos/selfservice/flow/recovery"
|
||||
"github.com/ory/kratos/selfservice/flow/registration"
|
||||
"github.com/ory/kratos/selfservice/flow/settings"
|
||||
"github.com/ory/kratos/selfservice/flow/verification"
|
||||
"github.com/ory/kratos/selfservice/strategy/link"
|
||||
"github.com/ory/kratos/selfservice/strategy/oidc"
|
||||
"github.com/ory/kratos/session"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/kratos/x/nosurfx"
|
||||
"github.com/ory/x/healthx"
|
||||
"github.com/ory/x/httprouterx"
|
||||
"github.com/ory/x/metricsx"
|
||||
"github.com/ory/x/networkx"
|
||||
"github.com/ory/x/otelx"
|
||||
"github.com/ory/x/otelx/semconv"
|
||||
"github.com/ory/x/popx"
|
||||
"github.com/ory/x/prometheusx"
|
||||
"github.com/ory/x/reqlog"
|
||||
"github.com/ory/x/urlx"
|
||||
)
|
||||
|
||||
func init() {
|
||||
graceful.DefaultShutdownTimeout = 120 * time.Second
|
||||
}
|
||||
|
||||
var httpMetrics = prometheusx.NewHTTPMetrics("kratos", prometheusx.HTTPPrefix, config.Version, config.Commit, config.Date)
|
||||
|
||||
func servePublic(ctx context.Context, r *driver.RegistryDefault, cmd *cobra.Command) (func() error, error) {
|
||||
cfg := r.Config().ServePublic(ctx)
|
||||
l := r.Logger()
|
||||
|
||||
rec := negroni.NewRecovery()
|
||||
rec.Logger = l
|
||||
|
||||
router := httprouterx.NewRouterPublic()
|
||||
n := negroni.New(
|
||||
rec,
|
||||
httpMetrics,
|
||||
)
|
||||
|
||||
for _, mw := range r.HTTPMiddlewares() {
|
||||
n.Use(mw)
|
||||
}
|
||||
|
||||
publicLogger := reqlog.NewMiddlewareFromLogger(l, "public#"+cfg.BaseURL.String())
|
||||
|
||||
if cfg.RequestLog.DisableHealth {
|
||||
publicLogger.ExcludePaths(healthx.AliveCheckPath, healthx.ReadyCheckPath)
|
||||
}
|
||||
|
||||
n.UseFunc(semconv.Middleware)
|
||||
n.Use(publicLogger)
|
||||
n.Use(x.HTTPLoaderContextMiddleware(r))
|
||||
n.UseFunc(httprouterx.NoCacheNegroni)
|
||||
n.Use(sqa(ctx, cmd, r))
|
||||
|
||||
csrf := nosurfx.NewCSRFHandler(router, r)
|
||||
|
||||
// we need to always load the CORS middleware even if it is disabled, to allow hot-enabling CORS
|
||||
n.UseFunc(func(w http.ResponseWriter, req *http.Request, next http.HandlerFunc) {
|
||||
cfg, enabled := r.Config().CORSPublic(req.Context())
|
||||
if !enabled {
|
||||
next(w, req)
|
||||
return
|
||||
}
|
||||
cors.New(cfg).ServeHTTP(w, req, next)
|
||||
})
|
||||
|
||||
r.WithCSRFHandler(csrf)
|
||||
n.UseHandler(r.CSRFHandler())
|
||||
|
||||
// Disable CSRF for these endpoints
|
||||
csrf.DisablePath(healthx.AliveCheckPath)
|
||||
csrf.DisablePath(healthx.ReadyCheckPath)
|
||||
csrf.DisablePath(healthx.VersionPath)
|
||||
csrf.DisablePath(prometheusx.MetricsPrometheusPath)
|
||||
|
||||
r.RegisterPublicRoutes(ctx, router)
|
||||
|
||||
var handler http.Handler = n
|
||||
if tracer := r.Tracer(ctx); tracer.IsLoaded() {
|
||||
handler = otelx.NewMiddleware(handler, "servePublic",
|
||||
otelhttp.WithTracerProvider(tracer.Provider()),
|
||||
)
|
||||
}
|
||||
|
||||
handler = http.MaxBytesHandler(handler, 5*1024*1024 /* 5 MB */) // Important: this must be the outermost handler or our tracing breaks
|
||||
|
||||
certFunc, err := cfg.TLS.GetCertFunc(ctx, l, "public")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
//#nosec G112 -- the correct settings are set by graceful.WithDefaults
|
||||
server := graceful.WithDefaults(&http.Server{
|
||||
Handler: handler,
|
||||
TLSConfig: &tls.Config{GetCertificate: certFunc, MinVersion: tls.VersionTLS12},
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
})
|
||||
addr := cfg.GetAddress()
|
||||
|
||||
return func() error {
|
||||
l.WithField("addr", addr).Info("Starting the public httpd")
|
||||
if err := graceful.GracefulContext(ctx, func() error {
|
||||
listener, err := networkx.MakeListener(addr, &cfg.Socket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if certFunc == nil {
|
||||
return server.Serve(listener)
|
||||
}
|
||||
return server.ServeTLS(listener, "", "")
|
||||
}, server.Shutdown); err != nil {
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
l.Errorf("Failed to gracefully shutdown public httpd: %s", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
l.Println("Public httpd was shutdown gracefully")
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
func serveAdmin(ctx context.Context, r *driver.RegistryDefault, cmd *cobra.Command) (func() error, error) {
|
||||
cfg := r.Config().ServeAdmin(ctx)
|
||||
l := r.Logger()
|
||||
|
||||
rec := negroni.NewRecovery()
|
||||
rec.Logger = l
|
||||
|
||||
router := httprouterx.NewRouterAdminWithPrefix()
|
||||
n := negroni.New(
|
||||
rec,
|
||||
httpMetrics,
|
||||
)
|
||||
|
||||
for _, mw := range r.HTTPMiddlewares() {
|
||||
n.Use(mw)
|
||||
}
|
||||
|
||||
adminLogger := reqlog.NewMiddlewareFromLogger(l, "admin#"+cfg.BaseURL.String())
|
||||
|
||||
if cfg.RequestLog.DisableHealth {
|
||||
adminLogger.ExcludePaths(
|
||||
httprouterx.AdminPrefix+healthx.AliveCheckPath,
|
||||
httprouterx.AdminPrefix+healthx.ReadyCheckPath,
|
||||
httprouterx.AdminPrefix+prometheusx.MetricsPrometheusPath,
|
||||
)
|
||||
}
|
||||
n.UseFunc(semconv.Middleware)
|
||||
n.Use(adminLogger)
|
||||
n.UseFunc(httprouterx.AddAdminPrefixIfNotPresentNegroni)
|
||||
n.UseFunc(httprouterx.NoCacheNegroni)
|
||||
n.Use(x.HTTPLoaderContextMiddleware(r))
|
||||
n.Use(sqa(ctx, cmd, r))
|
||||
|
||||
r.RegisterAdminRoutes(ctx, router)
|
||||
|
||||
n.UseHandler(router)
|
||||
|
||||
var handler http.Handler = n
|
||||
if tracer := r.Tracer(ctx); tracer.IsLoaded() {
|
||||
handler = otelx.NewMiddleware(handler, "serveAdmin",
|
||||
otelhttp.WithTracerProvider(tracer.Provider()),
|
||||
)
|
||||
}
|
||||
|
||||
handler = http.MaxBytesHandler(handler, 5*1024*1024 /* 5 MB */) // Important: this must be the outermost handler or our tracing breaks
|
||||
|
||||
certFunc, err := cfg.TLS.GetCertFunc(ctx, l, "admin")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
//#nosec G112 -- the correct settings are set by graceful.WithDefaults
|
||||
server := graceful.WithDefaults(&http.Server{
|
||||
Handler: handler,
|
||||
TLSConfig: &tls.Config{GetCertificate: certFunc, MinVersion: tls.VersionTLS12},
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 120 * time.Second,
|
||||
IdleTimeout: 600 * time.Second,
|
||||
})
|
||||
|
||||
addr := cfg.GetAddress()
|
||||
|
||||
return func() error {
|
||||
l.WithField("addr", addr).Info("Starting the admin httpd")
|
||||
if err := graceful.GracefulContext(ctx, func() error {
|
||||
listener, err := networkx.MakeListener(addr, &cfg.Socket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if certFunc == nil {
|
||||
return server.Serve(listener)
|
||||
}
|
||||
return server.ServeTLS(listener, "", "")
|
||||
}, server.Shutdown); err != nil {
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
l.Errorf("Failed to gracefully shutdown admin httpd: %s", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
l.Println("Admin httpd was shutdown gracefully")
|
||||
return nil
|
||||
}, nil
|
||||
}
|
||||
|
||||
func sqa(ctx context.Context, cmd *cobra.Command, d driver.Registry) *metricsx.Service {
|
||||
urls := []string{
|
||||
d.Config().ServePublic(ctx).BaseURL.Host,
|
||||
d.Config().ServeAdmin(ctx).BaseURL.Host,
|
||||
d.Config().SelfServiceFlowLoginUI(ctx).Host,
|
||||
d.Config().SelfServiceFlowSettingsUI(ctx).Host,
|
||||
d.Config().SelfServiceFlowErrorURL(ctx).Host,
|
||||
d.Config().SelfServiceFlowRegistrationUI(ctx).Host,
|
||||
d.Config().SelfServiceFlowRecoveryUI(ctx).Host,
|
||||
d.Config().ServePublic(ctx).Host,
|
||||
d.Config().ServeAdmin(ctx).Host,
|
||||
}
|
||||
if c, y := d.Config().CORSPublic(ctx); y {
|
||||
urls = append(urls, c.AllowedOrigins...)
|
||||
}
|
||||
host := urlx.ExtractPublicAddress(urls...)
|
||||
|
||||
// Creates only ones
|
||||
// instance
|
||||
return metricsx.New(
|
||||
cmd,
|
||||
d.Logger(),
|
||||
d.Config().GetProvider(ctx),
|
||||
&metricsx.Options{
|
||||
Service: "kratos",
|
||||
DeploymentId: metricsx.Hash(d.Persister().NetworkID(ctx).String()),
|
||||
DBDialect: d.Persister().GetConnection(ctx).Dialect.Name(),
|
||||
IsDevelopment: d.Config().IsInsecureDevMode(ctx),
|
||||
WriteKey: "qQlI6q8Q4WvkzTjKQSor4sHYOikHIvvi",
|
||||
WhitelistedPaths: []string{
|
||||
"/",
|
||||
healthx.AliveCheckPath,
|
||||
healthx.ReadyCheckPath,
|
||||
healthx.VersionPath,
|
||||
|
||||
oidc.RouteBase,
|
||||
|
||||
login.RouteInitBrowserFlow,
|
||||
login.RouteInitAPIFlow,
|
||||
login.RouteGetFlow,
|
||||
login.RouteSubmitFlow,
|
||||
|
||||
logout.RouteInitBrowserFlow,
|
||||
logout.RouteSubmitFlow,
|
||||
logout.RouteAPIFlow,
|
||||
|
||||
registration.RouteInitBrowserFlow,
|
||||
registration.RouteInitAPIFlow,
|
||||
registration.RouteGetFlow,
|
||||
registration.RouteSubmitFlow,
|
||||
|
||||
session.RouteWhoami,
|
||||
|
||||
httprouterx.AdminPrefix + "/" + schema.SchemasPath,
|
||||
httprouterx.AdminPrefix + identity.RouteCollection,
|
||||
|
||||
settings.RouteInitBrowserFlow,
|
||||
settings.RouteInitAPIFlow,
|
||||
settings.RouteGetFlow,
|
||||
settings.RouteSubmitFlow,
|
||||
|
||||
verification.RouteInitAPIFlow,
|
||||
verification.RouteInitBrowserFlow,
|
||||
verification.RouteGetFlow,
|
||||
verification.RouteSubmitFlow,
|
||||
|
||||
recovery.RouteInitAPIFlow,
|
||||
recovery.RouteInitBrowserFlow,
|
||||
recovery.RouteGetFlow,
|
||||
recovery.RouteSubmitFlow,
|
||||
|
||||
link.RouteAdminCreateRecoveryLink,
|
||||
|
||||
errorx.RouteGet,
|
||||
prometheusx.MetricsPrometheusPath,
|
||||
},
|
||||
BuildVersion: config.Version,
|
||||
BuildHash: config.Commit,
|
||||
BuildTime: config.Date,
|
||||
Config: &analytics.Config{
|
||||
Endpoint: "https://sqa.ory.sh",
|
||||
GzipCompressionLevel: 6,
|
||||
BatchMaxSize: 500 * 1000,
|
||||
BatchSize: 1000,
|
||||
Interval: time.Hour * 6,
|
||||
},
|
||||
Hostname: host,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func courierTask(ctx context.Context, d driver.Registry) func() error {
|
||||
return func() error {
|
||||
if d.Config().IsBackgroundCourierEnabled(ctx) {
|
||||
return courier.Watch(ctx, d)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func ServeAll(d *driver.RegistryDefault) func(cmd *cobra.Command, args []string) error {
|
||||
return func(cmd *cobra.Command, _ []string) error {
|
||||
ctx := cmd.Context()
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
cmd.SetContext(ctx)
|
||||
|
||||
if err := popx.VerifyDialect(ctx, d.Persister().GetConnection(ctx)); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
// construct all tasks upfront to avoid race conditions
|
||||
publicSrv, err := servePublic(ctx, d, cmd)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
adminSrv, err := serveAdmin(ctx, d, cmd)
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
tasks := []func() error{
|
||||
publicSrv,
|
||||
adminSrv,
|
||||
courierTask(ctx, d),
|
||||
}
|
||||
for _, task := range tasks {
|
||||
g.Go(task)
|
||||
}
|
||||
return g.Wait()
|
||||
}
|
||||
}
|
||||
76
cmd/daemon/serve_test.go
Normal file
76
cmd/daemon/serve_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package daemon
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/phayes/freeport"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/ory/x/configx"
|
||||
|
||||
"github.com/ory/kratos/pkg"
|
||||
)
|
||||
|
||||
func TestMetricsRouterPaths(t *testing.T) {
|
||||
// TODO refactor this test to be parallelizable once we rewrite the server setup to be properly testable
|
||||
ports, err := freeport.GetFreePorts(2)
|
||||
require.NoError(t, err)
|
||||
adminPort, publicPort := ports[0], ports[1]
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().Bool("sqa-opt-out", true, "")
|
||||
_, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValues(map[string]any{
|
||||
"serve.admin.port": adminPort,
|
||||
"serve.public.port": publicPort,
|
||||
}))
|
||||
|
||||
startAdmin, err := serveAdmin(t.Context(), reg, cmd)
|
||||
require.NoError(t, err)
|
||||
startPublic, err := servePublic(t.Context(), reg, cmd)
|
||||
require.NoError(t, err)
|
||||
eg, _ := errgroup.WithContext(t.Context())
|
||||
eg.Go(startAdmin)
|
||||
eg.Go(startPublic)
|
||||
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
t.Cleanup(transport.CloseIdleConnections)
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
require.EventuallyWithT(t, func(t *assert.CollectT) {
|
||||
resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/health/ready", publicPort))
|
||||
require.NoError(t, err)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
assert.Equalf(t, http.StatusOK, resp.StatusCode, "%s", body)
|
||||
}, 2*time.Second, 10*time.Millisecond)
|
||||
|
||||
// Make some requests that should be recorded in the metrics
|
||||
req, _ := http.NewRequest(http.MethodDelete, fmt.Sprintf("http://127.0.0.1:%d/sessions/session-id", publicPort), nil)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
resp, err = client.Get(fmt.Sprintf("http://127.0.0.1:%d/admin/identities/8e5f29ec-db29-4e56-8517-75f7d4e2f1bc/sessions", adminPort))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
assert.EventuallyWithT(t, func(t *assert.CollectT) {
|
||||
res, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/admin/metrics/prometheus", adminPort))
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, http.StatusOK, res.StatusCode)
|
||||
respBody, err := io.ReadAll(res.Body)
|
||||
body := string(respBody)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, body, `endpoint="/sessions/{param}"`)
|
||||
assert.Contains(t, body, `endpoint="/admin/identities/{param}/sessions"`)
|
||||
}, 5*time.Second, 100*time.Millisecond)
|
||||
}
|
||||
332
cmd/hashers/argon2/calibrate.go
Normal file
332
cmd/hashers/argon2/calibrate.go
Normal file
@@ -0,0 +1,332 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package argon2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/ory/x/configx"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/inhies/go-bytesize"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/hash"
|
||||
)
|
||||
|
||||
const (
|
||||
FlagStartMemory = "start-memory"
|
||||
FlagMaxMemory = "max-memory"
|
||||
FlagAdjustMemory = "adjust-memory-by"
|
||||
FlagStartIterations = "start-iterations"
|
||||
FlagMaxConcurrent = "max-concurrent"
|
||||
|
||||
FlagRuns = "probe-runs"
|
||||
)
|
||||
|
||||
type (
|
||||
colorWriter struct {
|
||||
c *color.Color
|
||||
w io.Writer
|
||||
}
|
||||
loadResult struct {
|
||||
r *resultTable
|
||||
v *config.Argon2
|
||||
}
|
||||
loadResults []*loadResult
|
||||
)
|
||||
|
||||
var _ cmdx.Table = loadResults{}
|
||||
|
||||
func (l loadResults) Header() []string {
|
||||
return append((&resultTable{}).Header(), "MEMOry PARAM", "ITERATIONS PARAM")
|
||||
}
|
||||
|
||||
func (l loadResults) Table() [][]string {
|
||||
t := make([][]string, len(l))
|
||||
|
||||
for i, r := range l {
|
||||
t[i] = append(r.r.Columns(), fmt.Sprintf("%d", r.v.Memory), fmt.Sprintf("%d", r.v.Iterations))
|
||||
}
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
func (l loadResults) Interface() interface{} {
|
||||
return l
|
||||
}
|
||||
|
||||
func (l loadResults) Len() int {
|
||||
return len(l)
|
||||
}
|
||||
|
||||
func (c *colorWriter) Write(o []byte) (int, error) {
|
||||
return c.c.Fprintf(c.w, "%s", o)
|
||||
}
|
||||
|
||||
func newCalibrateCmd() *cobra.Command {
|
||||
var (
|
||||
maxMemory, adjustMemory bytesize.ByteSize = 0, 512 * bytesize.MB
|
||||
runs int
|
||||
)
|
||||
|
||||
flagConfig := &argon2Config{
|
||||
localConfig: config.Argon2{},
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "calibrate <requests-per-minute>",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Short: "Computes Optimal Argon2 Parameters",
|
||||
Long: `This command helps you calibrate the configuration parameters for Argon2. Password hashing is a trade-off between security, resource consumption, and user experience. Resource consumption should not be too high and the login should not take too long.
|
||||
|
||||
We recommend that the login process takes between half a second and one second for password hashing, giving a good balance between security and user experience.
|
||||
|
||||
Please note that the values depend on the machine you run the hashing on. If you have RAM constraints, please set the memory dedicated to Ory Kratos to avoid out of memory panics.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
progressPrinter := cmdx.NewLoudErrPrinter(cmd)
|
||||
resultPrinter := cmdx.NewLoudPrinter(cmd, &colorWriter{c: color.New(color.FgGreen), w: cmd.ErrOrStderr()})
|
||||
|
||||
conf, err := configProvider(cmd, flagConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// always take start flags, or their default
|
||||
conf.localConfig.Memory = flagConfig.localConfig.Memory
|
||||
conf.localConfig.Iterations = flagConfig.localConfig.Iterations
|
||||
|
||||
desiredDuration := conf.localConfig.ExpectedDuration
|
||||
|
||||
reqPerMin, err := strconv.ParseInt(args[0], 0, 0)
|
||||
if err != nil {
|
||||
// we want the error and usage string printed so just return
|
||||
return err
|
||||
}
|
||||
|
||||
hasher := hash.NewHasherArgon2(conf)
|
||||
|
||||
var currentDuration time.Duration
|
||||
|
||||
_, _ = progressPrinter.Printf("Increasing memory to get over %s:\n", desiredDuration)
|
||||
|
||||
for {
|
||||
if maxMemory != 0 && conf.localConfig.Memory > maxMemory {
|
||||
// don't further increase memory
|
||||
_, _ = progressPrinter.Println(" ouch, hit the memory limit there")
|
||||
conf.localConfig.Memory = maxMemory
|
||||
break
|
||||
}
|
||||
|
||||
currentDuration, err = probe(cmd, hasher, runs, progressPrinter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = progressPrinter.Printf(" took %s with %s of memory\n", currentDuration, conf.localConfig.Memory)
|
||||
|
||||
if currentDuration > desiredDuration {
|
||||
if conf.localConfig.Memory <= adjustMemory {
|
||||
// adjusting the memory would now result in <= 0B
|
||||
adjustMemory = adjustMemory >> 1
|
||||
}
|
||||
conf.localConfig.Memory -= adjustMemory
|
||||
break
|
||||
}
|
||||
|
||||
// adjust config
|
||||
conf.localConfig.Memory += adjustMemory
|
||||
}
|
||||
|
||||
_, _ = progressPrinter.Printf("Decreasing memory to get under %s:\n", desiredDuration)
|
||||
|
||||
for {
|
||||
currentDuration, err = probe(cmd, hasher, runs, progressPrinter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = progressPrinter.Printf(" took %s with %s of memory\n", currentDuration, conf.localConfig.Memory)
|
||||
|
||||
if currentDuration < desiredDuration {
|
||||
break
|
||||
}
|
||||
|
||||
for conf.localConfig.Memory <= adjustMemory {
|
||||
// adjusting the memory would now result in <= 0B
|
||||
adjustMemory = adjustMemory >> 1
|
||||
}
|
||||
|
||||
// adjust config
|
||||
conf.localConfig.Memory -= adjustMemory
|
||||
}
|
||||
|
||||
_, _ = resultPrinter.Printf("Settled on %s of memory.\n", conf.localConfig.Memory)
|
||||
_, _ = progressPrinter.Printf("Increasing iterations to get over %s:\n", desiredDuration)
|
||||
|
||||
for {
|
||||
currentDuration, err = probe(cmd, hasher, runs, progressPrinter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = progressPrinter.Printf(" took %s with %d iterations\n", currentDuration, conf.localConfig.Iterations)
|
||||
|
||||
if currentDuration > desiredDuration {
|
||||
if conf.localConfig.Iterations > 1 {
|
||||
conf.localConfig.Iterations -= 1
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// adjust config
|
||||
conf.localConfig.Iterations += 1
|
||||
}
|
||||
|
||||
_, _ = progressPrinter.Printf("Decreasing iterations to get under %s:\n", desiredDuration)
|
||||
|
||||
for {
|
||||
currentDuration, err = probe(cmd, hasher, runs, progressPrinter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, _ = progressPrinter.Printf(" took %s with %d iterations\n", currentDuration, conf.localConfig.Iterations)
|
||||
|
||||
// break also when iterations is 1; this catches the case where 1 was only slightly under the desired time and took longer a bit longer on another run
|
||||
if currentDuration < desiredDuration || conf.localConfig.Iterations == 1 {
|
||||
break
|
||||
}
|
||||
|
||||
// adjust config
|
||||
conf.localConfig.Iterations -= 1
|
||||
}
|
||||
_, _ = resultPrinter.Printf("Settled on %d iterations.\n", conf.localConfig.Iterations)
|
||||
|
||||
results := make(loadResults, 5)
|
||||
for i := 0; i < len(results); i++ {
|
||||
_, _ = progressPrinter.Printf("\nRunning load test to simulate %d login requests per minute.\n", reqPerMin)
|
||||
|
||||
res, err := runLoadTest(cmd, conf, int(reqPerMin))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cv, _ := conf.HasherArgon2()
|
||||
ccv := *cv
|
||||
results[i] = &loadResult{r: res, v: &ccv}
|
||||
|
||||
_, _ = progressPrinter.Println("The load test result is:")
|
||||
_, _ = progressPrinter.Println()
|
||||
cmdx.PrintRow(cmd, res)
|
||||
|
||||
switch {
|
||||
// too fast
|
||||
case res.MedianTime < conf.localConfig.ExpectedDuration:
|
||||
_, _ = progressPrinter.Printf("The median was %s under the minimal duration of %s, going to increase the hash cost.\n", conf.localConfig.ExpectedDuration-res.MedianTime, conf.localConfig.ExpectedDuration)
|
||||
|
||||
// try to increase memory first
|
||||
if res.MaxMem+64*bytesize.MB < maxMemory {
|
||||
// only small amounts of memory are sensible as we already benchmarked a single, non-concurrent request
|
||||
conf.localConfig.Memory += 64 * bytesize.MB
|
||||
_, _ = progressPrinter.Printf("Increasing memory to %s\n", conf.localConfig.Memory)
|
||||
} else {
|
||||
// increasing memory is not allowed by maxMemory, therefore increase CPU load
|
||||
conf.localConfig.Iterations++
|
||||
_, _ = progressPrinter.Printf("Increasing iterations to %d\n", conf.localConfig.Iterations)
|
||||
}
|
||||
// too much memory
|
||||
case res.MaxMem > conf.localConfig.DedicatedMemory:
|
||||
_, _ = progressPrinter.Printf("The required memory was %s more than the maximum allowed of %s.\n", res.MaxMem-maxMemory, conf.localConfig.DedicatedMemory)
|
||||
|
||||
//nolint:gosec // disable G115
|
||||
conf.localConfig.Memory -= (res.MaxMem - conf.localConfig.DedicatedMemory) / bytesize.ByteSize(reqPerMin)
|
||||
_, _ = progressPrinter.Printf("Decreasing memory to %s\n", conf.localConfig.Memory)
|
||||
// too slow
|
||||
case res.MaxTime > conf.localConfig.ExpectedDeviation+conf.localConfig.ExpectedDuration:
|
||||
_, _ = progressPrinter.Printf("The longest request took %s longer than the longest acceptable time of %s, going to decrease the hash cost.\n", res.MaxTime-conf.localConfig.ExpectedDeviation+conf.localConfig.ExpectedDuration, conf.localConfig.ExpectedDeviation+conf.localConfig.ExpectedDuration)
|
||||
|
||||
// try to decrease iterations first
|
||||
if conf.localConfig.Iterations > 1 {
|
||||
conf.localConfig.Iterations--
|
||||
_, _ = progressPrinter.Printf("Decreasing iterations to %d\n", conf.localConfig.Iterations)
|
||||
} else {
|
||||
// decreasing iterations is not possible anymore, decreasing memory
|
||||
// only small amounts of memory are sensible as we already benchmarked a single, non-concurrent request
|
||||
conf.localConfig.Memory -= 64 * bytesize.MB
|
||||
_, _ = progressPrinter.Printf("Decreasing memory to %s\n", conf.localConfig.Memory)
|
||||
}
|
||||
// too high deviation
|
||||
case res.StdDev > conf.localConfig.ExpectedDeviation:
|
||||
_, _ = progressPrinter.Printf("The deviation was %s more than the expected deviation of %s.\n", res.StdDev-conf.localConfig.ExpectedDeviation, conf.localConfig.ExpectedDeviation)
|
||||
|
||||
// try to decrease iterations first
|
||||
if conf.localConfig.Iterations > 1 {
|
||||
conf.localConfig.Iterations--
|
||||
_, _ = progressPrinter.Printf("Decreasing iterations to %d\n", conf.localConfig.Iterations)
|
||||
} else {
|
||||
// decreasing iterations is not possible anymore, decreasing memory
|
||||
// only small amounts of memory are sensible as we already benchmarked a single, non-concurrent request
|
||||
conf.localConfig.Memory -= 64 * bytesize.MB
|
||||
_, _ = progressPrinter.Printf("Decreasing memory to %s\n", conf.localConfig.Memory)
|
||||
}
|
||||
// all values seem reasonable
|
||||
default:
|
||||
_, _ = progressPrinter.Println("These values look good to me.")
|
||||
_, _ = progressPrinter.Println()
|
||||
cmdx.PrintRow(cmd, conf)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Could not automatically determine good parameters. Have a look at all the measurements taken and select acceptable values yourself. Have a look in the docs for more information: https://www.ory.com/kratos/docs/debug/performance-out-of-memory-password-hashing-argon2")
|
||||
cmdx.PrintTable(cmd, results)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
flags := cmd.Flags()
|
||||
|
||||
flags.IntVarP(&runs, FlagRuns, "r", 2, "Runs per probe, median of all runs is taken as the result.")
|
||||
|
||||
flags.VarP(&flagConfig.localConfig.Memory, FlagStartMemory, "m", "Amount of memory to start probing at.")
|
||||
flags.Var(&maxMemory, FlagMaxMemory, "Maximum memory allowed (0 means no limit).")
|
||||
flags.Var(&adjustMemory, FlagAdjustMemory, "Amount by which the memory is adjusted in every step while probing.")
|
||||
|
||||
flags.Uint32VarP(&flagConfig.localConfig.Iterations, FlagStartIterations, "i", 1, "Number of iterations to start probing at.")
|
||||
|
||||
flags.Uint8(FlagMaxConcurrent, 16, "Maximum number of concurrent hashing operations.")
|
||||
|
||||
registerArgon2ConstantConfigFlags(flags, flagConfig)
|
||||
cmdx.RegisterFormatFlags(flags)
|
||||
configx.RegisterFlags(flags)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func probe(cmd *cobra.Command, hasher hash.Hasher, runs int, progressPrinter *cmdx.ConditionalPrinter) (time.Duration, error) {
|
||||
// force GC at the start of the experiment
|
||||
runtime.GC()
|
||||
|
||||
start := time.Now()
|
||||
|
||||
var mid time.Time
|
||||
for i := 0; i < runs; i++ {
|
||||
mid = time.Now()
|
||||
_, err := hasher.Generate(cmd.Context(), []byte("password"))
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Could not generate a hash: %s\n", err)
|
||||
return 0, cmdx.FailSilently(cmd)
|
||||
}
|
||||
|
||||
_, _ = progressPrinter.Printf(" took %s in try %d\n", time.Since(mid), i)
|
||||
}
|
||||
return time.Duration(int64(time.Since(start)) / int64(runs)), nil
|
||||
}
|
||||
83
cmd/hashers/argon2/hash.go
Normal file
83
cmd/hashers/argon2/hash.go
Normal file
@@ -0,0 +1,83 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package argon2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/kratos/hash"
|
||||
"github.com/ory/x/cmdx"
|
||||
"github.com/ory/x/configx"
|
||||
"github.com/ory/x/flagx"
|
||||
)
|
||||
|
||||
const (
|
||||
FlagParallel = "parallel"
|
||||
)
|
||||
|
||||
func newHashCmd() *cobra.Command {
|
||||
flagConfig := &argon2Config{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "hash <password1> [<password2> ...]",
|
||||
Short: "Hash a list of passwords for benchmarking the hashing parameters",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
conf, err := configProvider(cmd, flagConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
flagConfig.ctx = cmd.Context()
|
||||
|
||||
hasher := hash.NewHasherArgon2(conf)
|
||||
hashes := make([][]byte, len(args))
|
||||
errs := make(chan error, len(args))
|
||||
|
||||
start := time.Now()
|
||||
|
||||
for i, pw := range args {
|
||||
go func(i int, pw string) {
|
||||
start := time.Now()
|
||||
h, err := hasher.Generate(cmd.Context(), []byte(pw))
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "password %d: %s\n", i, time.Since(start))
|
||||
|
||||
hashes[i] = h
|
||||
errs <- err
|
||||
}(i, pw)
|
||||
|
||||
if !flagx.MustGetBool(cmd, FlagParallel) {
|
||||
if err := <-errs; err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Could not generate hash: %s\n", err.Error())
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if flagx.MustGetBool(cmd, FlagParallel) {
|
||||
for i := 0; i < len(args); i++ {
|
||||
if err := <-errs; err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Could not generate hash: %s\n", err.Error())
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "total: %s\n", time.Since(start))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
flags := cmd.Flags()
|
||||
|
||||
flags.Bool(FlagParallel, false, "Run all hashing operations in parallel.")
|
||||
|
||||
registerArgon2ConfigFlags(flags, flagConfig)
|
||||
configx.RegisterFlags(flags)
|
||||
|
||||
return cmd
|
||||
}
|
||||
269
cmd/hashers/argon2/loadtest.go
Normal file
269
cmd/hashers/argon2/loadtest.go
Normal file
@@ -0,0 +1,269 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package argon2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/ory/x/flagx"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/inhies/go-bytesize"
|
||||
"github.com/montanaflynn/stats"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/ory/kratos/hash"
|
||||
"github.com/ory/x/cmdx"
|
||||
"github.com/ory/x/configx"
|
||||
)
|
||||
|
||||
type resultTable struct {
|
||||
TotalTime time.Duration `json:"total_time"`
|
||||
MedianTime time.Duration `json:"median_request_time"`
|
||||
StdDev time.Duration `json:"std_deviation"`
|
||||
MinTime time.Duration `json:"min_request_time"`
|
||||
MaxTime time.Duration `json:"max_request_time"`
|
||||
MaxMem bytesize.ByteSize `json:"mem_used"`
|
||||
}
|
||||
|
||||
var (
|
||||
ErrSampleTimeExceeded = fmt.Errorf("the sample time was exceeded")
|
||||
ErrMemoryConsumptionExceeded = fmt.Errorf("the memory consumption was exceeded")
|
||||
|
||||
_ cmdx.TableRow = &resultTable{}
|
||||
)
|
||||
|
||||
func (r *resultTable) Header() []string {
|
||||
return []string{"TOTAL SAMPLE TIME", "MEDIAN REQUEST TIME", "STANDARD DEVIATION", "MIN REQUEST TIME", "MAX REQUEST TIME", "MEMOry USED"}
|
||||
}
|
||||
|
||||
func (r *resultTable) Columns() []string {
|
||||
return []string{
|
||||
r.TotalTime.String(),
|
||||
r.MedianTime.String(),
|
||||
r.StdDev.String(),
|
||||
r.MinTime.String(),
|
||||
r.MaxTime.String(),
|
||||
r.MaxMem.String(),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *resultTable) Interface() interface{} {
|
||||
return r
|
||||
}
|
||||
|
||||
func newLoadTestCmd() *cobra.Command {
|
||||
flagConf := &argon2Config{}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "load-test <authentication-requests-per-minute>",
|
||||
Short: "Simulate the password hashing with a number of concurrent requests/minute.",
|
||||
Long: "Simulates a number of concurrent authentication requests per minute. Gives statistical data about the measured performance and resource consumption. Can be used to tune and test the hashing parameters for peak demand situations.",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
perMinute, err := strconv.ParseInt(args[0], 0, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
flagConf.ctx = cmd.Context()
|
||||
|
||||
conf, err := configProvider(cmd, flagConf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !flagx.MustGetBool(cmd, cmdx.FlagQuiet) {
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "The hashing configuration used is:")
|
||||
cmdx.PrintRow(cmd, conf)
|
||||
}
|
||||
|
||||
res, err := runLoadTest(cmd, conf, int(perMinute))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmdx.PrintRow(cmd, res)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
registerArgon2ConfigFlags(cmd.Flags(), flagConf)
|
||||
configx.RegisterFlags(cmd.Flags())
|
||||
cmdx.RegisterFormatFlags(cmd.Flags())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runLoadTest(cmd *cobra.Command, conf *argon2Config, reqPerMin int) (*resultTable, error) {
|
||||
// force GC at the start of the experiment
|
||||
runtime.GC()
|
||||
|
||||
sampleTime := time.Minute / 3
|
||||
reqNum := reqPerMin / int(time.Minute/sampleTime)
|
||||
|
||||
progressPrinter := cmdx.NewLoudErrPrinter(cmd)
|
||||
_, _ = progressPrinter.Printf("It takes about %s to collect all necessary data, please be patient.\n", sampleTime)
|
||||
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
hasher := hash.NewHasherArgon2(conf)
|
||||
allDone := make(chan struct{})
|
||||
startAll := time.Now()
|
||||
var cancelReason error
|
||||
|
||||
var memStats []uint64
|
||||
go func() {
|
||||
clock := time.NewTicker(time.Second)
|
||||
defer func() {
|
||||
clock.Stop()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-cmd.Context().Done():
|
||||
return
|
||||
case <-allDone:
|
||||
return
|
||||
case <-clock.C:
|
||||
// cancel if the allowed time is exceeded by 110%
|
||||
if time.Since(startAll) > ((sampleTime+conf.localConfig.ExpectedDuration+conf.localConfig.ExpectedDeviation)/100)*110 {
|
||||
cancelReason = ErrSampleTimeExceeded
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
|
||||
ms := runtime.MemStats{}
|
||||
runtime.ReadMemStats(&ms)
|
||||
|
||||
// cancel if memory is exceeded by 110%
|
||||
if ms.HeapAlloc > (uint64(conf.localConfig.DedicatedMemory)/100)*110 {
|
||||
cancelReason = ErrMemoryConsumptionExceeded
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
|
||||
memStats = append(memStats, ms.HeapAlloc)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
// don't read std_in when quiet
|
||||
if flagx.MustGetBool(cmd, cmdx.FlagQuiet) {
|
||||
return
|
||||
}
|
||||
|
||||
input := make([]byte, 1)
|
||||
for {
|
||||
n, err := cmd.InOrStdin().Read(input)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if n != 0 {
|
||||
_, _ = color.New(color.FgRed).Fprintln(cmd.ErrOrStderr(), "I SAID BE PATIENT!!!")
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case <-allDone:
|
||||
return
|
||||
case <-cmd.Context().Done():
|
||||
return
|
||||
case <-time.After(time.Millisecond):
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
calcTimes := make([]time.Duration, reqNum)
|
||||
eg, _ := errgroup.WithContext(ctx)
|
||||
|
||||
for i := 0; i < reqNum; i++ {
|
||||
eg.Go(func(i int) func() error {
|
||||
return func() error {
|
||||
// wait randomly before starting, max. sample time
|
||||
//#nosec G404 -- just a timeout to collect statistical data
|
||||
t := time.Duration(rand.Intn(int(sampleTime)))
|
||||
timer := time.NewTimer(t)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-timer.C:
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
done := make(chan struct{})
|
||||
var err error
|
||||
|
||||
go func() {
|
||||
_, err = hasher.Generate(ctx, []byte("password"))
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-done:
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
calcTimes[i] = time.Since(start)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}(i))
|
||||
}
|
||||
|
||||
if err := eg.Wait(); err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Error during hashing: %+v\n", err)
|
||||
return nil, cmdx.FailSilently(cmd)
|
||||
}
|
||||
switch cancelReason {
|
||||
case ErrSampleTimeExceeded:
|
||||
memUsed, err2 := stats.LoadRawData(memStats).Max()
|
||||
if err2 != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Unexpected maths error: %+v\nRaw Data: %+v\n", cancelReason, memStats)
|
||||
}
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "The hashing load test took too long. This indicates that you don't have enough resources to handle %d login requests per minute with the desired minimal time of %s. The memory used was %s. Either dedicate more CPU/memory, or decrease the hashing cost (memory and iterations parameters).\n", reqPerMin, conf.localConfig.ExpectedDuration, bytesize.ByteSize(memUsed))
|
||||
return nil, cmdx.FailSilently(cmd)
|
||||
case ErrMemoryConsumptionExceeded:
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "The hashing load test exceeded the memory limit of %s. This indicates that you don't have enough resources to handle %d login requests per minute with the desired minimal time of %s. Either dedicate more memory, or decrease the hashing cost (memory and iterations parameters).\n", conf.localConfig.DedicatedMemory, reqPerMin, conf.localConfig.ExpectedDuration)
|
||||
return nil, cmdx.FailSilently(cmd)
|
||||
}
|
||||
|
||||
totalTime := time.Since(startAll)
|
||||
close(allDone)
|
||||
|
||||
calcData := stats.LoadRawData(calcTimes)
|
||||
|
||||
duration := func(f func() (float64, error)) time.Duration {
|
||||
v, err := f()
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Unexpected maths error: %+v\nRaw Data: %+v\n", err, calcTimes)
|
||||
}
|
||||
return time.Duration(int64(v))
|
||||
}
|
||||
|
||||
memUsed, err := stats.LoadRawData(memStats).Max()
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Unexpected maths error: %+v\nRaw Data: %+v\n", err, memStats)
|
||||
}
|
||||
|
||||
return &resultTable{
|
||||
TotalTime: totalTime,
|
||||
MedianTime: duration(calcData.Mean),
|
||||
StdDev: duration(calcData.StandardDeviation),
|
||||
MinTime: duration(calcData.Min),
|
||||
MaxTime: duration(calcData.Max),
|
||||
MaxMem: bytesize.ByteSize(memUsed),
|
||||
}, nil
|
||||
}
|
||||
177
cmd/hashers/argon2/root.go
Normal file
177
cmd/hashers/argon2/root.go
Normal file
@@ -0,0 +1,177 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package argon2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/ory/x/contextx"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/x/cmdx"
|
||||
"github.com/ory/x/configx"
|
||||
"github.com/ory/x/logrusx"
|
||||
)
|
||||
|
||||
const (
|
||||
FlagIterations = "iterations"
|
||||
FlagParallelism = "parallelism"
|
||||
FlagSaltLength = "salt-length"
|
||||
FlagKeyLength = "key-length"
|
||||
FlagMemory = "memory"
|
||||
FlagDedicatedMemory = "dedicated-memory"
|
||||
FlagMinimalDuration = "min-duration"
|
||||
FlagExpectedDeviation = "expected-deviation"
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "argon2",
|
||||
}
|
||||
|
||||
func RegisterCommandRecursive(parent *cobra.Command) {
|
||||
parent.AddCommand(rootCmd)
|
||||
|
||||
rootCmd.AddCommand(newCalibrateCmd(), newHashCmd(), newLoadTestCmd())
|
||||
}
|
||||
|
||||
func registerArgon2ConstantConfigFlags(flags *pflag.FlagSet, c *argon2Config) {
|
||||
// set default value first
|
||||
c.localConfig.DedicatedMemory = config.Argon2DefaultDedicatedMemory
|
||||
flags.Var(&c.localConfig.DedicatedMemory, FlagDedicatedMemory, "Amount of memory dedicated for password hashing. Kratos will try to not consume more memory.")
|
||||
flags.DurationVar(&c.localConfig.ExpectedDuration, FlagMinimalDuration, config.Argon2DefaultDuration, "Minimal duration a hashing operation (~login request) takes.")
|
||||
flags.DurationVar(&c.localConfig.ExpectedDeviation, FlagExpectedDeviation, config.Argon2DefaultDeviation, "Expected deviation of the time a hashing operation (~login request) takes.")
|
||||
|
||||
flags.Uint8Var(&c.localConfig.Parallelism, FlagParallelism, config.Argon2DefaultParallelism, "Number of threads to use.")
|
||||
|
||||
flags.Uint32Var(&c.localConfig.SaltLength, FlagSaltLength, config.Argon2DefaultSaltLength, "Length of the salt in bytes.")
|
||||
flags.Uint32Var(&c.localConfig.KeyLength, FlagKeyLength, config.Argon2DefaultKeyLength, "Length of the key in bytes.")
|
||||
}
|
||||
|
||||
func registerArgon2ConfigFlags(flags *pflag.FlagSet, c *argon2Config) {
|
||||
flags.Uint32Var(&c.localConfig.Iterations, FlagIterations, 1, "Number of iterations to start probing at.")
|
||||
|
||||
// set default value first
|
||||
c.localConfig.Memory = config.Argon2DefaultMemory
|
||||
flags.Var(&c.localConfig.Memory, FlagMemory, "Memory to use.")
|
||||
|
||||
registerArgon2ConstantConfigFlags(flags, c)
|
||||
}
|
||||
|
||||
func configProvider(cmd *cobra.Command, flagConf *argon2Config) (*argon2Config, error) {
|
||||
l := logrusx.New("Ory Kratos", config.Version)
|
||||
conf := &argon2Config{}
|
||||
var err error
|
||||
conf.config, err = config.New(
|
||||
cmd.Context(),
|
||||
l,
|
||||
cmd.ErrOrStderr(),
|
||||
&contextx.Default{},
|
||||
configx.WithFlags(cmd.Flags()),
|
||||
configx.SkipValidation(),
|
||||
configx.WithContext(cmd.Context()),
|
||||
configx.WithImmutables("hashers"),
|
||||
)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Unable to initialize the config provider: %s\n", err)
|
||||
return nil, cmdx.FailSilently(cmd)
|
||||
}
|
||||
conf.localConfig = *conf.config.HasherArgon2(cmd.Context())
|
||||
|
||||
if cmd.Flags().Changed(FlagIterations) {
|
||||
conf.localConfig.Iterations = flagConf.localConfig.Iterations
|
||||
}
|
||||
if cmd.Flags().Changed(FlagParallelism) {
|
||||
conf.localConfig.Parallelism = flagConf.localConfig.Parallelism
|
||||
}
|
||||
if cmd.Flags().Changed(FlagMemory) {
|
||||
conf.localConfig.Memory = flagConf.localConfig.Memory
|
||||
}
|
||||
if cmd.Flags().Changed(FlagDedicatedMemory) {
|
||||
conf.localConfig.DedicatedMemory = flagConf.localConfig.DedicatedMemory
|
||||
}
|
||||
if cmd.Flags().Changed(FlagKeyLength) {
|
||||
conf.localConfig.KeyLength = flagConf.localConfig.KeyLength
|
||||
}
|
||||
if cmd.Flags().Changed(FlagSaltLength) {
|
||||
conf.localConfig.SaltLength = flagConf.localConfig.SaltLength
|
||||
}
|
||||
if cmd.Flags().Changed(FlagExpectedDeviation) {
|
||||
conf.localConfig.ExpectedDeviation = flagConf.localConfig.ExpectedDeviation
|
||||
}
|
||||
if cmd.Flags().Changed(FlagMinimalDuration) {
|
||||
conf.localConfig.ExpectedDuration = flagConf.localConfig.ExpectedDuration
|
||||
}
|
||||
|
||||
return conf, nil
|
||||
}
|
||||
|
||||
type (
|
||||
argon2Config struct {
|
||||
localConfig config.Argon2
|
||||
config *config.Config
|
||||
ctx context.Context
|
||||
}
|
||||
)
|
||||
|
||||
var _ cmdx.TableRow = (*argon2Config)(nil)
|
||||
|
||||
func (c *argon2Config) Header() []string {
|
||||
var header []string
|
||||
|
||||
t := reflect.TypeOf(c.localConfig)
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
header = append(header, strings.ReplaceAll(strings.ToUpper(t.Field(i).Tag.Get("json")), "_", " "))
|
||||
}
|
||||
|
||||
return header
|
||||
}
|
||||
|
||||
func (c *argon2Config) Columns() []string {
|
||||
conf, _ := c.HasherArgon2()
|
||||
return []string{
|
||||
conf.Memory.String(),
|
||||
fmt.Sprintf("%d", conf.Iterations),
|
||||
fmt.Sprintf("%d", conf.Parallelism),
|
||||
fmt.Sprintf("%d", conf.SaltLength),
|
||||
fmt.Sprintf("%d", conf.KeyLength),
|
||||
conf.ExpectedDuration.String(),
|
||||
conf.ExpectedDeviation.String(),
|
||||
conf.DedicatedMemory.String(),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *argon2Config) Interface() interface{} {
|
||||
i, _ := c.HasherArgon2()
|
||||
return i
|
||||
}
|
||||
|
||||
func (c *argon2Config) Config() *config.Config {
|
||||
ac, _ := c.HasherArgon2()
|
||||
for k, v := range map[string]interface{}{
|
||||
config.ViperKeyHasherArgon2ConfigIterations: ac.Iterations,
|
||||
config.ViperKeyHasherArgon2ConfigMemory: ac.Memory,
|
||||
config.ViperKeyHasherArgon2ConfigParallelism: ac.Parallelism,
|
||||
config.ViperKeyHasherArgon2ConfigDedicatedMemory: ac.DedicatedMemory,
|
||||
config.ViperKeyHasherArgon2ConfigKeyLength: ac.KeyLength,
|
||||
config.ViperKeyHasherArgon2ConfigSaltLength: ac.SaltLength,
|
||||
config.ViperKeyHasherArgon2ConfigExpectedDuration: ac.ExpectedDuration,
|
||||
config.ViperKeyHasherArgon2ConfigExpectedDeviation: ac.ExpectedDeviation,
|
||||
} {
|
||||
_ = c.config.Set(c.ctx, k, v)
|
||||
}
|
||||
return c.config
|
||||
}
|
||||
|
||||
func (c *argon2Config) HasherArgon2() (*config.Argon2, error) {
|
||||
if c.localConfig.Memory == 0 {
|
||||
c.localConfig.Memory = config.Argon2DefaultMemory
|
||||
}
|
||||
return &c.localConfig, nil
|
||||
}
|
||||
25
cmd/hashers/root.go
Normal file
25
cmd/hashers/root.go
Normal file
@@ -0,0 +1,25 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package hashers
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/kratos/cmd/hashers/argon2"
|
||||
)
|
||||
|
||||
func NewRootCmd() *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: "hashers",
|
||||
Short: "This command contains helpers around hashing",
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func RegisterCommandRecursive(parent *cobra.Command) {
|
||||
rootCmd := NewRootCmd()
|
||||
parent.AddCommand(rootCmd)
|
||||
|
||||
argon2.RegisterCommandRecursive(rootCmd)
|
||||
}
|
||||
90
cmd/identities/definitions.go
Normal file
90
cmd/identities/definitions.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package identities
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
kratos "github.com/ory/kratos/pkg/httpclient"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
)
|
||||
|
||||
type (
|
||||
outputIdentity kratos.Identity
|
||||
outputIdentityCollection struct {
|
||||
Identities []kratos.Identity `json:"identities"`
|
||||
NextPageToken string `json:"next_page_token"`
|
||||
includePageToken bool
|
||||
}
|
||||
)
|
||||
|
||||
func (outputIdentity) Header() []string {
|
||||
return []string{"ID", "EXTERNAL ID", "VERIFIED ADDRESSES", "RECOVERY ADDRESSES", "SCHEMA ID", "SCHEMA URL"}
|
||||
}
|
||||
|
||||
func (i outputIdentity) Columns() []string {
|
||||
data := [6]string{
|
||||
i.Id,
|
||||
cmdx.None,
|
||||
cmdx.None,
|
||||
cmdx.None,
|
||||
cmdx.None,
|
||||
cmdx.None,
|
||||
}
|
||||
|
||||
if i.ExternalId != nil && len(*i.ExternalId) > 0 {
|
||||
data[1] = *i.ExternalId
|
||||
}
|
||||
|
||||
addresses := make([]string, 0, len(i.VerifiableAddresses))
|
||||
for _, a := range i.VerifiableAddresses {
|
||||
if len(a.Value) > 0 {
|
||||
addresses = append(addresses, a.Value)
|
||||
}
|
||||
}
|
||||
data[2] = strings.Join(addresses, ", ")
|
||||
|
||||
addresses = addresses[:0]
|
||||
for _, a := range i.RecoveryAddresses {
|
||||
if len(a.Value) > 0 {
|
||||
addresses = append(addresses, a.Value)
|
||||
}
|
||||
}
|
||||
data[3] = strings.Join(addresses, ", ")
|
||||
data[4] = i.SchemaId
|
||||
data[5] = i.SchemaUrl
|
||||
|
||||
return data[:]
|
||||
}
|
||||
|
||||
func (i outputIdentity) Interface() interface{} {
|
||||
return i
|
||||
}
|
||||
|
||||
func (outputIdentityCollection) Header() []string {
|
||||
return outputIdentity{}.Header()
|
||||
}
|
||||
|
||||
func (c outputIdentityCollection) Table() [][]string {
|
||||
rows := make([][]string, len(c.Identities))
|
||||
for i, ident := range c.Identities {
|
||||
rows[i] = outputIdentity(ident).Columns()
|
||||
}
|
||||
return append(rows,
|
||||
[]string{""},
|
||||
[]string{"NEXT PAGE TOKEN", c.NextPageToken},
|
||||
)
|
||||
}
|
||||
|
||||
func (c outputIdentityCollection) Interface() interface{} {
|
||||
if c.includePageToken {
|
||||
return c
|
||||
}
|
||||
return c.Identities
|
||||
}
|
||||
|
||||
func (c *outputIdentityCollection) Len() int {
|
||||
return len(c.Identities)
|
||||
}
|
||||
72
cmd/identities/delete.go
Normal file
72
cmd/identities/delete.go
Normal file
@@ -0,0 +1,72 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package identities
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/kratos/cmd/cliclient"
|
||||
"github.com/ory/kratos/pkg/clihelpers"
|
||||
"github.com/ory/x/cmdx"
|
||||
)
|
||||
|
||||
func NewDeleteCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete",
|
||||
Short: "Delete resources",
|
||||
}
|
||||
cmd.AddCommand(NewDeleteIdentityCmd())
|
||||
cliclient.RegisterClientFlags(cmd.PersistentFlags())
|
||||
cmdx.RegisterFormatFlags(cmd.PersistentFlags())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func NewDeleteIdentityCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "identity id-0 [id-1] [id-2] [id-n]",
|
||||
Short: "Delete one or more identities by their ID(s)",
|
||||
Long: fmt.Sprintf(`This command deletes one or more identities by ID. To delete an identity by some selector, e.g. the recovery email address, use the list command in combination with jq.
|
||||
|
||||
%s`, clihelpers.WarningJQIsComplicated),
|
||||
Example: `To delete the identity with the recovery email address "foo@bar.com", run:
|
||||
|
||||
{{ .CommandPath }} $({{ .Root.Name }} list identities --format json | jq -r 'map(select(.recovery_addresses[].value == "foo@bar.com")) | .[].id')`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := cliclient.NewClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var (
|
||||
deleted = make([]cmdx.OutputIder, 0, len(args))
|
||||
failed = make(map[string]error)
|
||||
)
|
||||
|
||||
for _, a := range args {
|
||||
_, err := c.IdentityAPI.DeleteIdentity(cmd.Context(), a).Execute()
|
||||
if err != nil {
|
||||
failed[a] = cmdx.PrintOpenAPIError(cmd, err)
|
||||
continue
|
||||
}
|
||||
deleted = append(deleted, cmdx.OutputIder(a))
|
||||
}
|
||||
|
||||
if len(deleted) == 1 {
|
||||
cmdx.PrintRow(cmd, &deleted[0])
|
||||
} else if len(deleted) > 1 {
|
||||
cmdx.PrintTable(cmd, &cmdx.OutputIderCollection{Items: deleted})
|
||||
}
|
||||
|
||||
cmdx.PrintErrors(cmd, failed)
|
||||
if len(failed) != 0 {
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
61
cmd/identities/delete_test.go
Normal file
61
cmd/identities/delete_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package identities_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/ory/kratos/cmd/identities"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/identity"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/x/sqlcon"
|
||||
)
|
||||
|
||||
func TestDeleteCmd(t *testing.T) {
|
||||
reg, cmd := setup(t, identities.NewDeleteIdentityCmd)
|
||||
|
||||
t.Run("case=deletes successfully", func(t *testing.T) {
|
||||
// create identity to delete
|
||||
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
||||
require.NoError(t, reg.Persister().CreateIdentity(context.Background(), i))
|
||||
|
||||
stdOut := cmd.ExecNoErr(t, i.ID.String())
|
||||
|
||||
// expect ID and no error
|
||||
assert.Equal(t, i.ID.String(), gjson.Parse(stdOut).String())
|
||||
|
||||
// expect identity to be deleted
|
||||
_, err := reg.Persister().GetIdentity(context.Background(), i.ID, identity.ExpandNothing)
|
||||
assert.True(t, errors.Is(err, sqlcon.ErrNoRows()))
|
||||
})
|
||||
|
||||
t.Run("case=deletes three identities", func(t *testing.T) {
|
||||
is, ids := makeIdentities(t, reg, 3)
|
||||
|
||||
stdOut := cmd.ExecNoErr(t, ids...)
|
||||
|
||||
assert.Equal(t, `["`+strings.Join(ids, "\",\"")+"\"]\n", stdOut)
|
||||
|
||||
for _, i := range is {
|
||||
_, err := reg.Persister().GetIdentity(context.Background(), i.ID, identity.ExpandNothing)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("case=fails with unknown ID", func(t *testing.T) {
|
||||
stdErr := cmd.ExecExpectedErr(t, x.NewUUID().String())
|
||||
|
||||
assert.Contains(t, stdErr, "Unable to locate the resource", stdErr)
|
||||
})
|
||||
}
|
||||
116
cmd/identities/get.go
Normal file
116
cmd/identities/get.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package identities
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
kratos "github.com/ory/kratos/pkg/httpclient"
|
||||
"github.com/ory/x/cmdx"
|
||||
"github.com/ory/x/stringsx"
|
||||
|
||||
"github.com/ory/kratos/pkg/clihelpers"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/kratos/cmd/cliclient"
|
||||
)
|
||||
|
||||
const (
|
||||
FlagIncludeCreds = "include-credentials"
|
||||
FlagExternalID = "external-id"
|
||||
)
|
||||
|
||||
func NewGetCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "get",
|
||||
Short: "Get resources",
|
||||
}
|
||||
cmd.AddCommand(NewGetIdentityCmd())
|
||||
cliclient.RegisterClientFlags(cmd.PersistentFlags())
|
||||
cmdx.RegisterFormatFlags(cmd.PersistentFlags())
|
||||
return cmd
|
||||
}
|
||||
|
||||
func NewGetIdentityCmd() *cobra.Command {
|
||||
var (
|
||||
includeCreds []string
|
||||
byExternalID bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "identity [id-1] [id-2] [id-n]",
|
||||
Short: "Get one or more identities by their ID(s)",
|
||||
Long: fmt.Sprintf(`This command gets all the details about an identity. To get an identity by some selector, e.g. the recovery email address, use the list command in combination with jq.
|
||||
|
||||
Use the --%s flag to look up identities by their external ID instead of their Ory-internal ID.
|
||||
|
||||
%s`, FlagExternalID, clihelpers.WarningJQIsComplicated),
|
||||
Example: `To get the identities with the recovery email address at the domain "ory.sh", run:
|
||||
|
||||
{{ .CommandPath }} $({{ .Root.Name }} ls identities --format json | jq -r 'map(select(.recovery_addresses[].value | endswith("@ory.sh"))) | .[].id')
|
||||
|
||||
To get an identity by its external ID, run:
|
||||
|
||||
{{ .CommandPath }} --external-id customer-12345`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := cliclient.NewClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// we check includeCreds argument is valid
|
||||
for _, opt := range includeCreds {
|
||||
e := stringsx.SwitchExact(opt)
|
||||
if !e.AddCase("oidc") {
|
||||
cmd.PrintErrln(`You have to put a valid value of credentials type to be included, try --help for details.`)
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
identities := make([]kratos.Identity, 0, len(args))
|
||||
failed := make(map[string]error)
|
||||
for _, id := range args {
|
||||
var identity *kratos.Identity
|
||||
if byExternalID {
|
||||
identity, _, err = c.IdentityAPI.
|
||||
GetIdentityByExternalID(cmd.Context(), id).
|
||||
IncludeCredential(includeCreds).
|
||||
Execute()
|
||||
} else {
|
||||
identity, _, err = c.IdentityAPI.
|
||||
GetIdentity(cmd.Context(), id).
|
||||
IncludeCredential(includeCreds).
|
||||
Execute()
|
||||
}
|
||||
|
||||
if clihelpers.SDKError(err) != nil {
|
||||
failed[id] = cmdx.PrintOpenAPIError(cmd, err)
|
||||
continue
|
||||
}
|
||||
|
||||
identities = append(identities, *identity)
|
||||
}
|
||||
|
||||
if len(identities) == 1 {
|
||||
cmdx.PrintRow(cmd, (*outputIdentity)(&identities[0]))
|
||||
} else if len(identities) > 1 {
|
||||
cmdx.PrintTable(cmd, &outputIdentityCollection{Identities: identities, includePageToken: false})
|
||||
}
|
||||
cmdx.PrintErrors(cmd, failed)
|
||||
|
||||
if len(failed) != 0 {
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
flags := cmd.Flags()
|
||||
// include credential flag to add third party tokens in returned data
|
||||
flags.StringArrayVarP(&includeCreds, FlagIncludeCreds, "i", []string{}, `Include third party tokens (only "oidc" supported) `)
|
||||
flags.BoolVar(&byExternalID, FlagExternalID, false, "Treat the provided ID(s) as external IDs and look up the identities by their external ID instead of their Ory-internal ID.")
|
||||
return cmd
|
||||
}
|
||||
132
cmd/identities/get_test.go
Normal file
132
cmd/identities/get_test.go
Normal file
@@ -0,0 +1,132 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package identities_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/ory/kratos/cmd/identities"
|
||||
"github.com/ory/x/assertx"
|
||||
"github.com/ory/x/sqlxx"
|
||||
|
||||
"github.com/ory/kratos/x"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/identity"
|
||||
)
|
||||
|
||||
func TestGetCmd(t *testing.T) {
|
||||
reg, cmd := setup(t, identities.NewGetIdentityCmd)
|
||||
|
||||
t.Run("case=gets a single identity", func(t *testing.T) {
|
||||
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
||||
i.MetadataPublic = []byte(`"public"`)
|
||||
i.MetadataAdmin = []byte(`"admin"`)
|
||||
require.NoError(t, reg.Persister().CreateIdentity(context.Background(), i))
|
||||
|
||||
stdOut := cmd.ExecNoErr(t, i.ID.String())
|
||||
|
||||
ij, err := json.Marshal(identity.WithCredentialsNoConfigAndAdminMetadataInJSON(*i))
|
||||
require.NoError(t, err)
|
||||
|
||||
assertx.EqualAsJSONExcept(t, json.RawMessage(ij), json.RawMessage(stdOut), []string{"created_at", "updated_at", "AdditionalProperties"})
|
||||
})
|
||||
|
||||
t.Run("case=gets three identities", func(t *testing.T) {
|
||||
is, ids := makeIdentities(t, reg, 3)
|
||||
|
||||
stdOut := cmd.ExecNoErr(t, ids...)
|
||||
|
||||
isj, err := json.Marshal(is)
|
||||
require.NoError(t, err)
|
||||
|
||||
assertx.EqualAsJSONExcept(t, json.RawMessage(isj), json.RawMessage(stdOut), []string{"created_at", "updated_at", "AdditionalProperties"})
|
||||
})
|
||||
|
||||
t.Run("case=fails with unknown ID", func(t *testing.T) {
|
||||
stdErr := cmd.ExecExpectedErr(t, x.NewUUID().String())
|
||||
|
||||
assert.Contains(t, stdErr, "Unable to locate the resource", stdErr)
|
||||
})
|
||||
|
||||
t.Run("case=gets a single identity by its external ID", func(t *testing.T) {
|
||||
externalID := "customer-" + x.NewUUID().String()
|
||||
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
||||
i.ExternalID = sqlxx.NullString(externalID)
|
||||
require.NoError(t, reg.Persister().CreateIdentity(context.Background(), i))
|
||||
|
||||
stdOut := cmd.ExecNoErr(t, "--"+identities.FlagExternalID, externalID)
|
||||
|
||||
assert.Equal(t, i.ID.String(), gjson.Get(stdOut, "id").String())
|
||||
assert.Equal(t, externalID, gjson.Get(stdOut, "external_id").String())
|
||||
})
|
||||
|
||||
t.Run("case=fails with unknown external ID", func(t *testing.T) {
|
||||
stdErr := cmd.ExecExpectedErr(t, "--"+identities.FlagExternalID, "does-not-exist")
|
||||
|
||||
assert.Contains(t, stdErr, "Unable to locate the resource", stdErr)
|
||||
})
|
||||
|
||||
t.Run("case=gets a single identity with oidc credentials", func(t *testing.T) {
|
||||
applyCredentials := func(identifier, accessToken, refreshToken, idToken string, encrypt bool) identity.Credentials {
|
||||
toJson := func(c identity.CredentialsOIDC) []byte {
|
||||
out, err := json.Marshal(&c)
|
||||
require.NoError(t, err)
|
||||
return out
|
||||
}
|
||||
transform := func(token string) string {
|
||||
if encrypt {
|
||||
s, err := reg.Cipher(context.Background()).Encrypt(context.Background(), []byte(token))
|
||||
require.NoError(t, err)
|
||||
return s
|
||||
}
|
||||
return token
|
||||
}
|
||||
return identity.Credentials{
|
||||
Type: identity.CredentialsTypeOIDC,
|
||||
Identifiers: []string{"bar:" + identifier},
|
||||
Config: toJson(identity.CredentialsOIDC{Providers: []identity.CredentialsOIDCProvider{
|
||||
{
|
||||
Subject: "foo",
|
||||
Provider: "bar",
|
||||
InitialAccessToken: transform(accessToken + "0"),
|
||||
InitialRefreshToken: transform(refreshToken + "0"),
|
||||
InitialIDToken: transform(idToken + "0"),
|
||||
Organization: "foo-org-id",
|
||||
},
|
||||
{
|
||||
Subject: "baz",
|
||||
Provider: "zab",
|
||||
InitialAccessToken: transform(accessToken + "1"),
|
||||
InitialRefreshToken: transform(refreshToken + "1"),
|
||||
InitialIDToken: transform(idToken + "1"),
|
||||
Organization: "bar-org-id",
|
||||
},
|
||||
}}),
|
||||
}
|
||||
}
|
||||
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
||||
i.MetadataPublic = []byte(`"public"`)
|
||||
i.MetadataAdmin = []byte(`"admin"`)
|
||||
i.SetCredentials(identity.CredentialsTypeOIDC, applyCredentials("uniqueIdentifier", "accessBar", "refreshBar", "idBar", true))
|
||||
// duplicate identity with decrypted tokens
|
||||
di := i.CopyWithoutCredentials()
|
||||
di.SetCredentials(identity.CredentialsTypeOIDC, applyCredentials("uniqueIdentifier", "accessBar", "refreshBar", "idBar", false))
|
||||
|
||||
require.NoError(t, reg.Persister().CreateIdentity(context.Background(), i))
|
||||
|
||||
stdOut := cmd.ExecNoErr(t, "--"+identities.FlagIncludeCreds, "oidc", i.ID.String())
|
||||
ij, err := json.Marshal(identity.WithCredentialsAndAdminMetadataInJSON(*di))
|
||||
require.NoError(t, err)
|
||||
|
||||
ii := []string{"id", "schema_url", "state_changed_at", "created_at", "updated_at", "credentials.oidc.created_at", "credentials.oidc.updated_at", "credentials.oidc.version", "AdditionalProperties"}
|
||||
assertx.EqualAsJSONExcept(t, json.RawMessage(ij), json.RawMessage(stdOut), ii)
|
||||
})
|
||||
}
|
||||
53
cmd/identities/helpers.go
Normal file
53
cmd/identities/helpers.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package identities
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
)
|
||||
|
||||
func parseIdentities(raw []byte) (rawIdentities []string) {
|
||||
res := gjson.ParseBytes(raw)
|
||||
if !res.IsArray() {
|
||||
return []string{res.Raw}
|
||||
}
|
||||
res.ForEach(func(_, v gjson.Result) bool {
|
||||
rawIdentities = append(rawIdentities, v.Raw)
|
||||
return true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func readIdentities(cmd *cobra.Command, args []string) (map[string]string, error) {
|
||||
rawIdentities := make(map[string]string)
|
||||
if len(args) == 0 {
|
||||
fc, err := io.ReadAll(cmd.InOrStdin())
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "STD_IN: Could not read: %s\n", err)
|
||||
return nil, cmdx.FailSilently(cmd)
|
||||
}
|
||||
for i, id := range parseIdentities(fc) {
|
||||
rawIdentities[fmt.Sprintf("STD_IN[%d]", i)] = id
|
||||
}
|
||||
return rawIdentities, nil
|
||||
}
|
||||
for _, fn := range args {
|
||||
fc, err := os.ReadFile(fn) // #nosec G304 -- file is supplied by user
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s: Could not open identity file: %s\n", fn, err)
|
||||
return nil, cmdx.FailSilently(cmd)
|
||||
}
|
||||
for i, id := range parseIdentities(fc) {
|
||||
rawIdentities[fmt.Sprintf("%s[%d]", fn, i)] = id
|
||||
}
|
||||
}
|
||||
return rawIdentities, nil
|
||||
}
|
||||
49
cmd/identities/helpers_test.go
Normal file
49
cmd/identities/helpers_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package identities_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
|
||||
"github.com/ory/kratos/identity"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/kratos/cmd/cliclient"
|
||||
"github.com/ory/kratos/driver"
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/kratos/pkg"
|
||||
"github.com/ory/kratos/pkg/testhelpers"
|
||||
)
|
||||
|
||||
func setup(t *testing.T, newCmd func() *cobra.Command) (*driver.RegistryDefault, *cmdx.CommandExecuter) {
|
||||
conf, reg := pkg.NewFastRegistryWithMocks(t)
|
||||
_, admin := testhelpers.NewKratosServerWithCSRF(t, reg)
|
||||
testhelpers.SetDefaultIdentitySchema(conf, "file://./stubs/identity.schema.json")
|
||||
// setup command
|
||||
return reg, &cmdx.CommandExecuter{
|
||||
New: func() *cobra.Command {
|
||||
cmd := newCmd()
|
||||
cliclient.RegisterClientFlags(cmd.Flags())
|
||||
cmdx.RegisterFormatFlags(cmd.Flags())
|
||||
return cmd
|
||||
},
|
||||
PersistentArgs: []string{"--" + cliclient.FlagEndpoint, admin.URL, "--" + cmdx.FlagFormat, string(cmdx.FormatJSON)},
|
||||
}
|
||||
}
|
||||
|
||||
func makeIdentities(t *testing.T, reg driver.Registry, n int) (is []*identity.Identity, ids []string) {
|
||||
for j := 0; j < n; j++ {
|
||||
i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
|
||||
i.MetadataPublic = []byte(`{"foo":"bar"}`)
|
||||
require.NoError(t, reg.Persister().CreateIdentity(context.Background(), i))
|
||||
is = append(is, i)
|
||||
ids = append(ids, i.ID.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
98
cmd/identities/import.go
Normal file
98
cmd/identities/import.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package identities
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
kratos "github.com/ory/kratos/pkg/httpclient"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/kratos/cmd/cliclient"
|
||||
)
|
||||
|
||||
func NewImportCmd() *cobra.Command {
|
||||
var cmd = &cobra.Command{
|
||||
Use: "import",
|
||||
Short: "Import resources",
|
||||
}
|
||||
cmd.AddCommand(NewImportIdentitiesCmd())
|
||||
cliclient.RegisterClientFlags(cmd.PersistentFlags())
|
||||
cmdx.RegisterFormatFlags(cmd.PersistentFlags())
|
||||
return cmd
|
||||
}
|
||||
|
||||
// NewImportIdentitiesCmd represents the import command
|
||||
func NewImportIdentitiesCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "identities file-1.json [file-2.json] [file-3.json] [file-n.json]",
|
||||
Short: "Import one or more identities from files or STD_IN",
|
||||
Example: `Create an example identity:
|
||||
|
||||
cat > ./file.json <<EOF
|
||||
{
|
||||
"schema_id": "default",
|
||||
"traits": {
|
||||
"email": "foo@example.com"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
{{ .CommandPath }} file.json
|
||||
|
||||
Alternatively:
|
||||
|
||||
cat file.json | {{ .CommandPath }}`,
|
||||
Long: `Import identities from files or STD_IN.
|
||||
|
||||
Files can contain only a single or an array of identities. The validity of files can be tested beforehand using "... identities validate".`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := cliclient.NewClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
imported := make([]kratos.Identity, 0, len(args))
|
||||
failed := make(map[string]error)
|
||||
|
||||
is, err := readIdentities(cmd, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for src, i := range is {
|
||||
var params kratos.CreateIdentityBody
|
||||
err = json.Unmarshal([]byte(i), ¶ms)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "STD_IN: Could not parse identity")
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
|
||||
ident, _, err := c.IdentityAPI.CreateIdentity(cmd.Context()).CreateIdentityBody(params).Execute()
|
||||
if err != nil {
|
||||
failed[src] = cmdx.PrintOpenAPIError(cmd, err)
|
||||
} else {
|
||||
imported = append(imported, *ident)
|
||||
}
|
||||
}
|
||||
|
||||
if len(imported) == 1 {
|
||||
cmdx.PrintRow(cmd, (*outputIdentity)(&imported[0]))
|
||||
} else {
|
||||
cmdx.PrintTable(cmd, &outputIdentityCollection{Identities: imported})
|
||||
}
|
||||
cmdx.PrintErrors(cmd, failed)
|
||||
|
||||
if len(failed) != 0 {
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
150
cmd/identities/import_test.go
Normal file
150
cmd/identities/import_test.go
Normal file
@@ -0,0 +1,150 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package identities_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/ory/kratos/identity"
|
||||
|
||||
"github.com/ory/kratos/cmd/identities"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/ory/kratos/driver/config"
|
||||
kratos "github.com/ory/kratos/pkg/httpclient"
|
||||
)
|
||||
|
||||
func TestImportCmd(t *testing.T) {
|
||||
reg, cmd := setup(t, identities.NewImportIdentitiesCmd)
|
||||
|
||||
t.Run("case=imports a new identity from file", func(t *testing.T) {
|
||||
i := kratos.CreateIdentityBody{
|
||||
SchemaId: config.DefaultIdentityTraitsSchemaID,
|
||||
Traits: map[string]interface{}{},
|
||||
}
|
||||
ij, err := json.Marshal(i)
|
||||
require.NoError(t, err)
|
||||
f, err := os.CreateTemp("", "")
|
||||
require.NoError(t, err)
|
||||
_, err = f.Write(ij)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, f.Close())
|
||||
|
||||
stdOut := cmd.ExecNoErr(t, f.Name())
|
||||
|
||||
id, err := uuid.FromString(gjson.Get(stdOut, "id").String())
|
||||
require.NoError(t, err)
|
||||
_, err = reg.Persister().GetIdentity(context.Background(), id, identity.ExpandNothing)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("case=imports multiple identities from single file", func(t *testing.T) {
|
||||
i := []kratos.CreateIdentityBody{
|
||||
{
|
||||
SchemaId: config.DefaultIdentityTraitsSchemaID,
|
||||
Traits: map[string]interface{}{},
|
||||
},
|
||||
{
|
||||
SchemaId: config.DefaultIdentityTraitsSchemaID,
|
||||
Traits: map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
ij, err := json.Marshal(i)
|
||||
require.NoError(t, err)
|
||||
f, err := os.CreateTemp("", "")
|
||||
require.NoError(t, err)
|
||||
_, err = f.Write(ij)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, f.Close())
|
||||
|
||||
stdOut := cmd.ExecNoErr(t, f.Name())
|
||||
|
||||
id, err := uuid.FromString(gjson.Get(stdOut, "0.id").String())
|
||||
require.NoError(t, err)
|
||||
_, err = reg.Persister().GetIdentity(context.Background(), id, identity.ExpandNothing)
|
||||
assert.NoError(t, err)
|
||||
|
||||
id, err = uuid.FromString(gjson.Get(stdOut, "1.id").String())
|
||||
require.NoError(t, err)
|
||||
_, err = reg.Persister().GetIdentity(context.Background(), id, identity.ExpandNothing)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("case=imports a new identity from STD_IN", func(t *testing.T) {
|
||||
i := []kratos.CreateIdentityBody{
|
||||
{
|
||||
SchemaId: config.DefaultIdentityTraitsSchemaID,
|
||||
Traits: map[string]interface{}{},
|
||||
},
|
||||
{
|
||||
SchemaId: config.DefaultIdentityTraitsSchemaID,
|
||||
Traits: map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
ij, err := json.Marshal(i)
|
||||
require.NoError(t, err)
|
||||
|
||||
stdOut, stdErr, err := cmd.Exec(bytes.NewBuffer(ij))
|
||||
require.NoError(t, err, "%s %s", stdOut, stdErr)
|
||||
|
||||
id, err := uuid.FromString(gjson.Get(stdOut, "0.id").String())
|
||||
require.NoError(t, err)
|
||||
_, err = reg.Persister().GetIdentity(context.Background(), id, identity.ExpandNothing)
|
||||
assert.NoError(t, err)
|
||||
|
||||
id, err = uuid.FromString(gjson.Get(stdOut, "1.id").String())
|
||||
require.NoError(t, err)
|
||||
_, err = reg.Persister().GetIdentity(context.Background(), id, identity.ExpandNothing)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("case=imports multiple identities from STD_IN", func(t *testing.T) {
|
||||
i := kratos.CreateIdentityBody{
|
||||
SchemaId: config.DefaultIdentityTraitsSchemaID,
|
||||
Traits: map[string]interface{}{},
|
||||
}
|
||||
ij, err := json.Marshal(i)
|
||||
require.NoError(t, err)
|
||||
|
||||
stdOut, stdErr, err := cmd.Exec(bytes.NewBuffer(ij))
|
||||
require.NoError(t, err, "%s %s", stdOut, stdErr)
|
||||
|
||||
id, err := uuid.FromString(gjson.Get(stdOut, "id").String())
|
||||
require.NoError(t, err)
|
||||
_, err = reg.Persister().GetIdentity(context.Background(), id, identity.ExpandNothing)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("case=imports an identity with an external ID", func(t *testing.T) {
|
||||
externalID := "customer-12345"
|
||||
i := kratos.CreateIdentityBody{
|
||||
SchemaId: config.DefaultIdentityTraitsSchemaID,
|
||||
Traits: map[string]interface{}{},
|
||||
ExternalId: kratos.PtrString(externalID),
|
||||
}
|
||||
ij, err := json.Marshal(i)
|
||||
require.NoError(t, err)
|
||||
|
||||
stdOut, stdErr, err := cmd.Exec(bytes.NewBuffer(ij))
|
||||
require.NoError(t, err, "%s %s", stdOut, stdErr)
|
||||
|
||||
// the external ID must be reflected in the command output
|
||||
assert.Equal(t, externalID, gjson.Get(stdOut, "external_id").String())
|
||||
|
||||
// and it must be persisted on the identity
|
||||
id, err := uuid.FromString(gjson.Get(stdOut, "id").String())
|
||||
require.NoError(t, err)
|
||||
stored, err := reg.Persister().GetIdentity(context.Background(), id, identity.ExpandNothing)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, externalID, stored.ExternalID.String())
|
||||
})
|
||||
}
|
||||
80
cmd/identities/list.go
Normal file
80
cmd/identities/list.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package identities
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/x/flagx"
|
||||
|
||||
"github.com/ory/x/pagination/keysetclient"
|
||||
|
||||
"github.com/ory/kratos/cmd/cliclient"
|
||||
"github.com/ory/x/cmdx"
|
||||
)
|
||||
|
||||
func NewListCmd() *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Short: "List resources",
|
||||
}
|
||||
c.AddCommand(NewListIdentitiesCmd())
|
||||
cliclient.RegisterClientFlags(c.PersistentFlags())
|
||||
cmdx.RegisterFormatFlags(c.PersistentFlags())
|
||||
return c
|
||||
}
|
||||
|
||||
func NewListIdentitiesCmd() *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: "identities",
|
||||
Short: "List identities",
|
||||
Long: `Return a list of identities.
|
||||
|
||||
The consistency defaults to ` + "`eventual`" + ` and can be set to ` + "`strong`" + ` or ` + "`eventual`" + `.
|
||||
Eventual consistency means that the list operation will return faster and might not include recently created or updated identities. Replication lag is about 5 seconds.`,
|
||||
Example: "{{ .CommandPath }} --page-size 100 --consistency eventual",
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
// zero or exactly two args
|
||||
if len(args) != 0 && len(args) != 2 {
|
||||
return fmt.Errorf("expected zero or two args, got %d: %+v", len(args), args)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := cliclient.NewClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
consistency := flagx.MustGetString(cmd, "consistency")
|
||||
req := c.IdentityAPI.ListIdentities(cmd.Context()).Consistency(consistency)
|
||||
page, perPage, err := cmdx.ParseTokenPaginationArgs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req = req.PageToken(page)
|
||||
req = req.PageSize(int64(perPage))
|
||||
|
||||
identities, res, err := req.Execute()
|
||||
if err != nil {
|
||||
return cmdx.PrintOpenAPIError(cmd, err)
|
||||
}
|
||||
|
||||
pages := keysetclient.ParseHeader(res)
|
||||
cmdx.PrintTable(cmd, &outputIdentityCollection{
|
||||
Identities: identities,
|
||||
NextPageToken: pages.NextToken,
|
||||
includePageToken: true,
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
c.Flags().String("consistency", "eventual", "The read consistency to use. Can be either \"strong\" or \"eventual\". Defaults to \"eventual\".")
|
||||
cmdx.RegisterTokenPaginationFlags(c)
|
||||
return c
|
||||
}
|
||||
66
cmd/identities/list_test.go
Normal file
66
cmd/identities/list_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package identities_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/ory/kratos/cmd/identities"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ory/kratos/identity"
|
||||
)
|
||||
|
||||
func TestListCmd(t *testing.T) {
|
||||
reg, cmd := setup(t, identities.NewListIdentitiesCmd)
|
||||
|
||||
var deleteIdentities = func(t *testing.T, is []*identity.Identity) {
|
||||
for _, i := range is {
|
||||
require.NoError(t, reg.Persister().DeleteIdentity(context.Background(), i.ID))
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("case=lists all identities with default pagination", func(t *testing.T) {
|
||||
is, ids := makeIdentities(t, reg, 5)
|
||||
t.Cleanup(func() {
|
||||
deleteIdentities(t, is)
|
||||
})
|
||||
|
||||
stdOut := cmd.ExecNoErr(t, "--"+cmdx.FlagQuiet)
|
||||
|
||||
for _, i := range ids {
|
||||
assert.Contains(t, stdOut, i)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("case=lists all identities with pagination", func(t *testing.T) {
|
||||
is, ids := makeIdentities(t, reg, 10)
|
||||
t.Cleanup(func() {
|
||||
deleteIdentities(t, is)
|
||||
})
|
||||
|
||||
first := cmd.ExecNoErr(t, "--format", "json-pretty", "--page-size", "2")
|
||||
nextPageToken := gjson.Get(first, "next_page_token").String()
|
||||
actualIDs := gjson.Get(first, "identities.#.id").Array()
|
||||
for nextPageToken != "" {
|
||||
next := cmd.ExecNoErr(t, "--page-size", "2", "--page-token", nextPageToken)
|
||||
actualIDs = append(actualIDs, gjson.Get(next, "identities.#.id").Array()...)
|
||||
nextPageToken = gjson.Get(next, "next_page_token").String()
|
||||
}
|
||||
|
||||
actualIDsString := make([]string, len(actualIDs))
|
||||
for i, id := range actualIDs {
|
||||
actualIDsString[i] = id.Str
|
||||
}
|
||||
|
||||
assert.ElementsMatch(t, ids, actualIDsString)
|
||||
})
|
||||
}
|
||||
15
cmd/identities/stubs/identity.schema.json
Normal file
15
cmd/identities/stubs/identity.schema.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"testKey": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
152
cmd/identities/validate.go
Normal file
152
cmd/identities/validate.go
Normal file
@@ -0,0 +1,152 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package identities
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/ory/kratos/spec"
|
||||
|
||||
"github.com/ory/x/jsonschemax"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/ory/jsonschema/v3"
|
||||
"github.com/ory/kratos/cmd/cliclient"
|
||||
)
|
||||
|
||||
func NewValidateCmd() *cobra.Command {
|
||||
var cmd = &cobra.Command{
|
||||
Use: "validate",
|
||||
Short: "Validate resources",
|
||||
}
|
||||
cmd.AddCommand(NewValidateIdentityCmd())
|
||||
cliclient.RegisterClientFlags(cmd.PersistentFlags())
|
||||
cmdx.RegisterFormatFlags(cmd.PersistentFlags())
|
||||
return cmd
|
||||
|
||||
}
|
||||
|
||||
func NewValidateIdentityCmd() *cobra.Command {
|
||||
var c = &cobra.Command{
|
||||
Use: "identity file.json [file-2.json] [file-3.json] [file-n.json]",
|
||||
Short: "Validate local identity files",
|
||||
Long: `This command allows validation of identity files.
|
||||
It validates against the payload of the API and the identity schema as configured in Ory Kratos.
|
||||
Identities can be supplied via STD_IN or JSON files containing a single or an array of identities.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := cliclient.NewClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
is, err := readIdentities(cmd, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for src, i := range is {
|
||||
err = ValidateIdentity(cmd, src, i, func(ctx context.Context, id string) (map[string]interface{}, *http.Response, error) {
|
||||
return c.IdentityAPI.GetIdentitySchema(ctx, id).Execute()
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "All identity files are valid.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
var schemas = make(map[string]*jsonschema.Schema)
|
||||
|
||||
const createIdentityPath = "api.json#/components/schemas/createIdentityBody"
|
||||
|
||||
type SchemaGetter = func(ctx context.Context, id string) (map[string]interface{}, *http.Response, error)
|
||||
|
||||
// ValidateIdentity validates the json payload fc against
|
||||
// 1. the swagger payload definition and
|
||||
// 2. the remote custom identity schema.
|
||||
func ValidateIdentity(cmd *cobra.Command, src, i string, getRemoteSchema SchemaGetter) error {
|
||||
swaggerSchema, ok := schemas[createIdentityPath]
|
||||
if !ok {
|
||||
// add swagger schema
|
||||
schemaCompiler := jsonschema.NewCompiler()
|
||||
err := schemaCompiler.AddResource("api.json", bytes.NewReader(spec.API))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Could not add swagger schema to the schema compiler. This is an error with the binary you use and should be reported. Thanks ;)")
|
||||
}
|
||||
|
||||
// compile swagger payload definition
|
||||
swaggerSchema, err = schemaCompiler.Compile(cmd.Context(), createIdentityPath)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Could not compile the identity schema. This is an error with the binary you use and should be reported. Thanks ;)")
|
||||
}
|
||||
// force additional properties to false because swagger does not render this
|
||||
swaggerSchema.AdditionalProperties = false
|
||||
schemas[createIdentityPath] = swaggerSchema
|
||||
}
|
||||
|
||||
// validate against swagger definition
|
||||
var foundValidationErrors bool
|
||||
err := swaggerSchema.Validate(bytes.NewBufferString(i))
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s: not valid\n", src)
|
||||
jsonschemax.FormatValidationErrorForCLI(cmd.ErrOrStderr(), []byte(i), err)
|
||||
foundValidationErrors = true
|
||||
}
|
||||
|
||||
// get custom identity schema id
|
||||
sid := gjson.Get(i, "schema_id")
|
||||
if !sid.Exists() {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), `%s: Expected key "schema_id" to be defined in identity file`, src)
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
|
||||
customSchema, ok := schemas[sid.String()]
|
||||
if !ok {
|
||||
ts, _, err := getRemoteSchema(cmd.Context(), sid.String())
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s: Could not fetch schema with ID \"%s\": %s\n", src, sid.String(), err)
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
sf, err := json.Marshal(ts)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, fmt.Sprintf("%s: Could not marshal the traits schema. This usually means there is a problem with your upstream service as it served an invalid response.", src))
|
||||
}
|
||||
|
||||
// compile custom identity schema
|
||||
customSchema, err = jsonschema.CompileString(cmd.Context(), "identity_traits.schema.json", string(sf))
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s: Could not compile the traits schema: %s\n", src, err)
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
schemas[sid.String()] = customSchema
|
||||
}
|
||||
|
||||
// validate against custom identity schema
|
||||
err = customSchema.Validate(bytes.NewBufferString(i))
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s: not valid\n", src)
|
||||
jsonschemax.FormatValidationErrorForCLI(cmd.ErrOrStderr(), []byte(i), err)
|
||||
foundValidationErrors = true
|
||||
}
|
||||
|
||||
if foundValidationErrors {
|
||||
return cmdx.FailSilently(cmd)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
78
cmd/identities/validate_test.go
Normal file
78
cmd/identities/validate_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package identities_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/ory/kratos/cmd/identities"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestValidateIdentity(t *testing.T) {
|
||||
var testCmd = func() (*cobra.Command, *bytes.Buffer, *bytes.Buffer) {
|
||||
out, err := &bytes.Buffer{}, &bytes.Buffer{}
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetOut(out)
|
||||
cmd.SetErr(err)
|
||||
return cmd, out, err
|
||||
}
|
||||
|
||||
var testSchemaGetter = func(resp map[string]interface{}) identities.SchemaGetter {
|
||||
return func(_ context.Context, _ string) (map[string]interface{}, *http.Response, error) {
|
||||
return resp, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
for i, tc := range []struct {
|
||||
description string
|
||||
payload string
|
||||
expectedRequiredError string
|
||||
identitySchema map[string]interface{}
|
||||
}{
|
||||
{
|
||||
description: "requires schema_id from swagger schema",
|
||||
payload: "{}",
|
||||
expectedRequiredError: "schema_id",
|
||||
},
|
||||
{
|
||||
description: "requires traits from swagger schema",
|
||||
payload: `{"schema_id": "case2"}`,
|
||||
expectedRequiredError: "traits",
|
||||
},
|
||||
{
|
||||
description: "requires custom trait key",
|
||||
payload: `{"schema_id": "case3", "traits": {}}`,
|
||||
expectedRequiredError: "random-property",
|
||||
identitySchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"required": []string{"random-property"},
|
||||
"properties": map[string]interface{}{
|
||||
"random-property": map[string]interface{}{
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("case=%d/description=%s", i, tc.description), func(t *testing.T) {
|
||||
cmd, stdOut, stdErr := testCmd()
|
||||
|
||||
if tc.identitySchema == nil {
|
||||
tc.identitySchema = map[string]interface{}{}
|
||||
}
|
||||
err := identities.ValidateIdentity(cmd, "test identity", tc.payload, testSchemaGetter(tc.identitySchema))
|
||||
assert.Error(t, err, stdOut.String(), stdErr.String())
|
||||
assert.Len(t, stdOut.String(), 0, stdErr.String())
|
||||
assert.Contains(t, stdErr.String(), "required", stdOut.String())
|
||||
assert.Contains(t, stdErr.String(), tc.expectedRequiredError, stdOut.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
61
cmd/jsonnet/format.go
Normal file
61
cmd/jsonnet/format.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package jsonnet
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/google/go-jsonnet/formatter"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
"github.com/ory/x/flagx"
|
||||
)
|
||||
|
||||
func NewFormatCmd() *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: "format",
|
||||
Short: "Helpers for formatting code",
|
||||
}
|
||||
c.AddCommand(NewJsonnetFormatCmd())
|
||||
return c
|
||||
}
|
||||
|
||||
func NewJsonnetFormatCmd() *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: "jsonnet path/to/files/*.jsonnet [more/files.jsonnet] [supports/**/{foo,bar}.jsonnet]",
|
||||
Long: `Formats JSONNet files using the official JSONNet formatter.
|
||||
|
||||
Use -w or --write to write output back to files instead of stdout.
|
||||
|
||||
` + GlobHelp,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
for _, pattern := range args {
|
||||
files, err := filepath.Glob(pattern)
|
||||
cmdx.Must(err, `Glob path "%s" is not valid: %s`, pattern, err)
|
||||
|
||||
shouldWrite := flagx.MustGetBool(cmd, "write")
|
||||
for _, file := range files {
|
||||
content, err := os.ReadFile(file) // #nosec G304 -- the file is provided by the user
|
||||
cmdx.Must(err, `Unable to read file "%s" because: %s`, file, err)
|
||||
|
||||
output, err := formatter.Format(file, string(content), formatter.DefaultOptions())
|
||||
cmdx.Must(err, `JSONNet file "%s" could not be formatted: %s`, file, err)
|
||||
|
||||
if shouldWrite {
|
||||
err := os.WriteFile(file, []byte(output), 0o644) //#nosec
|
||||
cmdx.Must(err, `Could not write to file "%s" because: %s`, file, err)
|
||||
} else {
|
||||
fmt.Println(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
c.Flags().BoolP("write", "w", false, "Write formatted output back to file.")
|
||||
return c
|
||||
}
|
||||
57
cmd/jsonnet/lint.go
Normal file
57
cmd/jsonnet/lint.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package jsonnet
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/google/go-jsonnet/linter"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
"github.com/ory/x/jsonnetsecure"
|
||||
)
|
||||
|
||||
func NewLintCmd() *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: "lint",
|
||||
Short: "Helpers for linting code",
|
||||
}
|
||||
c.AddCommand(NewJsonnetLintCmd())
|
||||
return c
|
||||
}
|
||||
|
||||
func NewJsonnetLintCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "lint path/to/files/*.jsonnet [more/files.jsonnet] [supports/**/{foo,bar}.jsonnet]",
|
||||
Long: `Lints JSONNet files using the official JSONNet linter and exits with a status code of 1 when issues are detected.
|
||||
|
||||
` + GlobHelp,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
vm := jsonnetsecure.MakeInProcessVM()
|
||||
|
||||
for _, pattern := range args {
|
||||
files, err := filepath.Glob(pattern)
|
||||
cmdx.Must(err, `Glob path "%s" is not valid: %s`, pattern, err)
|
||||
|
||||
for _, file := range files {
|
||||
content, err := os.ReadFile(file) // #nosec G304 -- file is supplied by user
|
||||
cmdx.Must(err, `Unable to read file "%s" because: %s`, file, err)
|
||||
|
||||
var outBuilder strings.Builder
|
||||
errorsFound := linter.LintSnippet(vm, &outBuilder, []linter.Snippet{{FileName: file, Code: string(content)}})
|
||||
|
||||
if errorsFound {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "Linter found issues.")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
29
cmd/jsonnet/root.go
Normal file
29
cmd/jsonnet/root.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package jsonnet
|
||||
|
||||
const GlobHelp = `Glob Syntax:
|
||||
|
||||
pattern:
|
||||
{ term }
|
||||
|
||||
term:
|
||||
'*' matches any sequence of non-separator characters
|
||||
'**' matches any sequence of characters
|
||||
'?' matches any single non-separator character
|
||||
'[' [ '!' ] { character-range } ']'
|
||||
character class (must be non-empty)
|
||||
'{' pattern-list '}'
|
||||
pattern alternatives
|
||||
c matches character c (c != '*', '**', '?', '\', '[', '{', '}')
|
||||
'\' c matches character c
|
||||
|
||||
character-range:
|
||||
c matches character c (c != '\\', '-', ']')
|
||||
'\' c matches character c
|
||||
lo '-' hi matches character c for lo <= c <= hi
|
||||
|
||||
pattern-list:
|
||||
pattern { ',' pattern }
|
||||
comma-separated (without spaces) patterns`
|
||||
105
cmd/migrate/handler.go
Normal file
105
cmd/migrate/handler.go
Normal file
@@ -0,0 +1,105 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/x/popx"
|
||||
|
||||
"github.com/ory/x/contextx"
|
||||
|
||||
"github.com/ory/x/configx"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/kratos/driver"
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/x/cmdx"
|
||||
"github.com/ory/x/flagx"
|
||||
)
|
||||
|
||||
type MigrateHandler struct{}
|
||||
|
||||
func NewMigrateHandler() *MigrateHandler {
|
||||
return &MigrateHandler{}
|
||||
}
|
||||
|
||||
func (h *MigrateHandler) getPersister(cmd *cobra.Command, args []string, opts []driver.RegistryOption) (driver.Registry, error) {
|
||||
return getPersister(cmd, args, opts)
|
||||
}
|
||||
|
||||
func getPersister(cmd *cobra.Command, args []string, opts []driver.RegistryOption) (d driver.Registry, err error) {
|
||||
if flagx.MustGetBool(cmd, "read-from-env") {
|
||||
d, err = driver.NewWithoutInit(
|
||||
cmd.Context(),
|
||||
cmd.ErrOrStderr(),
|
||||
driver.WithConfigOptions(
|
||||
configx.WithFlags(cmd.Flags()),
|
||||
configx.SkipValidation(),
|
||||
))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(d.Config().DSN(cmd.Context())) == 0 {
|
||||
fmt.Println(cmd.UsageString())
|
||||
fmt.Println("")
|
||||
fmt.Println("When using flag -e, environment variable DSN must be set")
|
||||
return nil, cmdx.FailSilently(cmd)
|
||||
}
|
||||
} else {
|
||||
if len(args) != 1 {
|
||||
fmt.Println(cmd.UsageString())
|
||||
return nil, cmdx.FailSilently(cmd)
|
||||
}
|
||||
d, err = driver.NewWithoutInit(
|
||||
cmd.Context(),
|
||||
cmd.ErrOrStderr(),
|
||||
driver.WithConfigOptions(
|
||||
configx.WithFlags(cmd.Flags()),
|
||||
configx.SkipValidation(),
|
||||
configx.WithValue(config.ViperKeyDSN, args[0]),
|
||||
))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
err = d.Init(cmd.Context(), &contextx.Default{}, append(opts, driver.SkipNetworkInit)...)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "an error occurred initializing migrations")
|
||||
}
|
||||
|
||||
if err := popx.VerifyDialect(cmd.Context(), d.Persister().GetConnection(cmd.Context())); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (h *MigrateHandler) MigrateSQLDown(cmd *cobra.Command, args []string, opts ...driver.RegistryOption) error {
|
||||
p, err := h.getPersister(cmd, args, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return popx.MigrateSQLDown(cmd, p.Persister())
|
||||
}
|
||||
|
||||
func (h *MigrateHandler) MigrateSQLStatus(cmd *cobra.Command, args []string, opts ...driver.RegistryOption) error {
|
||||
p, err := h.getPersister(cmd, args, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return popx.MigrateStatus(cmd, p.Persister())
|
||||
}
|
||||
|
||||
func (h *MigrateHandler) MigrateSQLUp(cmd *cobra.Command, args []string, opts ...driver.RegistryOption) error {
|
||||
p, err := h.getPersister(cmd, args, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return popx.MigrateSQLUp(cmd, p.Persister())
|
||||
}
|
||||
55
cmd/migrate/normalize_phone.go
Normal file
55
cmd/migrate/normalize_phone.go
Normal file
@@ -0,0 +1,55 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/kratos/driver"
|
||||
"github.com/ory/x/configx"
|
||||
)
|
||||
|
||||
func NewNormalizePhoneCmd(opts ...driver.RegistryOption) *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: "normalize-phone-numbers [database-url]",
|
||||
Short: "Normalize phone numbers to E.164 format in the database",
|
||||
Long: `Normalizes all phone numbers in identity_credential_identifiers,
|
||||
identity_verifiable_addresses, and identity_recovery_addresses to E.164 format.
|
||||
|
||||
This command uses keyset pagination to iterate over the database in batches.
|
||||
It is safe to run multiple times (idempotent) and can be interrupted and resumed
|
||||
using the --start-after flag with the last ID printed in the progress output.
|
||||
|
||||
Run this command AFTER deploying the code changes that normalize phone numbers
|
||||
on write, to ensure all legacy data is also normalized.
|
||||
|
||||
You can read in the database URL using the -e flag, for example:
|
||||
export DSN=...
|
||||
kratos migrate normalize-phone-numbers -e
|
||||
|
||||
### WARNING ###
|
||||
Before running this command on an existing database, create a back up!
|
||||
`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
err := NewNormalizePhoneHandler().NormalizePhoneNumbers(cmd, args, opts...)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
configx.RegisterFlags(c.PersistentFlags())
|
||||
c.Flags().BoolP("read-from-env", "e", false, "If set, reads the database connection string from the environment variable DSN or config file key dsn.")
|
||||
c.Flags().IntP("batch-size", "b", 1000, "Number of rows to process per batch")
|
||||
c.Flags().Bool("dry-run", false, "If set, only report what would change without writing")
|
||||
c.Flags().Duration("batch-delay", time.Second, "Delay between batches to reduce database load (e.g. 100ms, 1s)")
|
||||
c.Flags().StringSlice("start-after", nil, "Resume after a table's last processed ID, e.g. --start-after credentials=<id> --start-after verifiable=<id>")
|
||||
|
||||
return c
|
||||
}
|
||||
221
cmd/migrate/normalize_phone_handler.go
Normal file
221
cmd/migrate/normalize_phone_handler.go
Normal file
@@ -0,0 +1,221 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/kratos/driver"
|
||||
"github.com/ory/kratos/x"
|
||||
"github.com/ory/pop/v6"
|
||||
"github.com/ory/x/flagx"
|
||||
)
|
||||
|
||||
// phonePattern matches values that look like phone numbers: + followed by at least one digit,
|
||||
// possibly with spaces, dashes, or parens. This filters out OIDC subjects and other
|
||||
// identifiers that happen to start with +.
|
||||
var phonePattern = regexp.MustCompile(`^\+[\d\s\-().]+$`)
|
||||
|
||||
type NormalizePhoneHandler struct{}
|
||||
|
||||
func NewNormalizePhoneHandler() *NormalizePhoneHandler {
|
||||
return &NormalizePhoneHandler{}
|
||||
}
|
||||
|
||||
type normalizeStats struct {
|
||||
scanned int
|
||||
updated int
|
||||
skipped int
|
||||
errors int
|
||||
}
|
||||
|
||||
func (h *NormalizePhoneHandler) NormalizePhoneNumbers(cmd *cobra.Command, args []string, opts ...driver.RegistryOption) error {
|
||||
d, err := getPersister(cmd, args, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conn := d.Persister().GetConnection(cmd.Context())
|
||||
batchSize := flagx.MustGetInt(cmd, "batch-size")
|
||||
dryRun := flagx.MustGetBool(cmd, "dry-run")
|
||||
batchDelay := flagx.MustGetDuration(cmd, "batch-delay")
|
||||
|
||||
if dryRun {
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Dry run mode enabled. No changes will be written.")
|
||||
}
|
||||
|
||||
tables := []tableConfig{
|
||||
{
|
||||
key: "ci",
|
||||
name: "Credential identifiers",
|
||||
selectQuery: `
|
||||
SELECT id, identifier AS value
|
||||
FROM identity_credential_identifiers
|
||||
WHERE identifier LIKE '+%'
|
||||
AND id > ?
|
||||
ORDER BY id ASC LIMIT ?`,
|
||||
updateQuery: `UPDATE identity_credential_identifiers SET identifier = ?, updated_at = ? WHERE id = ? AND identifier = ?`,
|
||||
},
|
||||
{
|
||||
key: "va",
|
||||
name: "Verifiable addresses",
|
||||
selectQuery: `
|
||||
SELECT id, value
|
||||
FROM identity_verifiable_addresses
|
||||
WHERE via = 'sms'
|
||||
AND id > ?
|
||||
AND value LIKE '+%'
|
||||
ORDER BY id ASC LIMIT ?`,
|
||||
updateQuery: `UPDATE identity_verifiable_addresses SET value = ?, updated_at = ? WHERE id = ? AND value = ?`,
|
||||
},
|
||||
{
|
||||
key: "ra",
|
||||
name: "Recovery addresses",
|
||||
selectQuery: `
|
||||
SELECT id, value
|
||||
FROM identity_recovery_addresses
|
||||
WHERE via = 'sms'
|
||||
AND id > ?
|
||||
AND value LIKE '+%'
|
||||
ORDER BY id ASC LIMIT ?`,
|
||||
updateQuery: `UPDATE identity_recovery_addresses SET value = ?, updated_at = ? WHERE id = ? AND value = ?`,
|
||||
},
|
||||
}
|
||||
|
||||
startAfterMap, err := parseStartAfter(flagx.MustGetStringSlice(cmd, "start-after"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
allStats := make([]normalizeStats, len(tables))
|
||||
for i, table := range tables {
|
||||
startAfter := startAfterMap[table.key]
|
||||
if startAfter != uuid.Nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s: resuming after ID %s\n", table.name, startAfter)
|
||||
}
|
||||
stats, err := normalizeTable(conn, batchSize, batchDelay, startAfter, dryRun, cmd, table)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "normalizing %s", table.name)
|
||||
}
|
||||
allStats[i] = stats
|
||||
}
|
||||
|
||||
printSummary(cmd, tables, allStats)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type tableConfig struct {
|
||||
key string
|
||||
name string
|
||||
selectQuery string
|
||||
updateQuery string
|
||||
}
|
||||
|
||||
// parseStartAfter parses --start-after flags of the form "key=uuid".
|
||||
func parseStartAfter(args []string) (map[string]uuid.UUID, error) {
|
||||
result := make(map[string]uuid.UUID)
|
||||
for _, arg := range args {
|
||||
key, val, ok := strings.Cut(arg, "=")
|
||||
if !ok {
|
||||
return nil, errors.Errorf("invalid --start-after format %q: expected key=uuid (e.g. credentials=<id>)", arg)
|
||||
}
|
||||
id, err := uuid.FromString(val)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "invalid UUID in --start-after %q", arg)
|
||||
}
|
||||
result[key] = id
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func printSummary(cmd *cobra.Command, tables []tableConfig, allStats []normalizeStats) {
|
||||
out := cmd.OutOrStdout()
|
||||
|
||||
_, _ = fmt.Fprintln(out)
|
||||
|
||||
tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
|
||||
_, _ = fmt.Fprintln(tw, "\tSCANNED\tUPDATED\tSKIPPED\tERRORS")
|
||||
|
||||
var total normalizeStats
|
||||
for i, table := range tables {
|
||||
s := allStats[i]
|
||||
_, _ = fmt.Fprintf(tw, "%s\t%d\t%d\t%d\t%d\n", table.name, s.scanned, s.updated, s.skipped, s.errors)
|
||||
total.scanned += s.scanned
|
||||
total.updated += s.updated
|
||||
total.skipped += s.skipped
|
||||
total.errors += s.errors
|
||||
}
|
||||
_, _ = fmt.Fprintf(tw, "TOTAL\t%d\t%d\t%d\t%d\n", total.scanned, total.updated, total.skipped, total.errors)
|
||||
_ = tw.Flush()
|
||||
}
|
||||
|
||||
func normalizeTable(conn *pop.Connection, batchSize int, batchDelay time.Duration, startAfter uuid.UUID, dryRun bool, cmd *cobra.Command, table tableConfig) (normalizeStats, error) {
|
||||
var stats normalizeStats
|
||||
lastID := startAfter
|
||||
|
||||
for {
|
||||
var rows []struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
Value string `db:"value"`
|
||||
}
|
||||
|
||||
if err := conn.RawQuery(table.selectQuery, lastID, batchSize).All(&rows); err != nil {
|
||||
return stats, errors.Wrapf(err, "querying %s", table.name)
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
lastID = row.ID
|
||||
stats.scanned++
|
||||
|
||||
// The SQL query pre-filters with LIKE '+%'. This Go-side regex additionally
|
||||
// rejects non-phone identifiers (e.g. OIDC subjects) that happen to start
|
||||
// with '+'. We cannot use SQL regex because SQLite does not support it.
|
||||
if !phonePattern.MatchString(row.Value) {
|
||||
stats.skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
normalized := x.NormalizePhoneIdentifier(row.Value)
|
||||
if normalized == row.Value {
|
||||
stats.skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "[dry-run] %s %s: %q -> %q\n", table.name, row.ID, row.Value, normalized)
|
||||
stats.updated++
|
||||
continue
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
if err := conn.RawQuery(table.updateQuery, normalized, now, row.ID, row.Value).Exec(); err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "error updating %s %s (%q -> %q): %v\n", table.name, row.ID, row.Value, normalized, err)
|
||||
stats.errors++
|
||||
continue
|
||||
}
|
||||
stats.updated++
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s\tprocessed %d rows so far (--start-after %s=%s)\n", table.name, stats.scanned, table.key, lastID)
|
||||
|
||||
if batchDelay > 0 {
|
||||
time.Sleep(batchDelay)
|
||||
}
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
70
cmd/migrate/normalize_phone_handler_test.go
Normal file
70
cmd/migrate/normalize_phone_handler_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/ory/kratos/x"
|
||||
)
|
||||
|
||||
func TestNormalizePhoneIdentifier(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
shouldSkip bool
|
||||
}{
|
||||
{
|
||||
name: "German phone with spaces",
|
||||
input: "+49 176 671 11 638",
|
||||
expected: "+4917667111638",
|
||||
},
|
||||
{
|
||||
name: "German phone with dashes",
|
||||
input: "+49-176-671-11-638",
|
||||
expected: "+4917667111638",
|
||||
},
|
||||
{
|
||||
name: "Already normalized E.164",
|
||||
input: "+4917667111638",
|
||||
expected: "+4917667111638",
|
||||
shouldSkip: true,
|
||||
},
|
||||
{
|
||||
name: "US phone with parens",
|
||||
input: "+1 (415) 555-2671",
|
||||
expected: "+14155552671",
|
||||
},
|
||||
{
|
||||
name: "Malaysian phone with extra zero",
|
||||
input: "+600136866982",
|
||||
expected: "+60136866982",
|
||||
},
|
||||
{
|
||||
name: "Not a valid phone number",
|
||||
input: "+invalid",
|
||||
expected: "+invalid",
|
||||
shouldSkip: true,
|
||||
},
|
||||
{
|
||||
name: "Email address (not a phone)",
|
||||
input: "+foo@bar.com",
|
||||
expected: "+foo@bar.com",
|
||||
shouldSkip: true,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
normalized := x.NormalizePhoneIdentifier(tc.input)
|
||||
assert.Equal(t, tc.expected, normalized)
|
||||
if tc.shouldSkip {
|
||||
assert.Equal(t, tc.input, normalized, "should be unchanged (skip)")
|
||||
} else {
|
||||
assert.NotEqual(t, tc.input, normalized, "should be changed (update)")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
47
cmd/migrate/root.go
Normal file
47
cmd/migrate/root.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/kratos/driver"
|
||||
"github.com/ory/x/configx"
|
||||
"github.com/ory/x/popx"
|
||||
)
|
||||
|
||||
func NewMigrateCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "migrate",
|
||||
Short: "Various migration helpers",
|
||||
}
|
||||
}
|
||||
|
||||
func RegisterCommandRecursive(parent *cobra.Command) {
|
||||
c := NewMigrateCmd()
|
||||
|
||||
configx.RegisterFlags(c.PersistentFlags())
|
||||
c.AddCommand(NewMigrateSQLCmd())
|
||||
c.AddCommand(NewNormalizePhoneCmd())
|
||||
|
||||
parent.AddCommand(c)
|
||||
}
|
||||
|
||||
func NewMigrateSQLDownCmd(opts ...driver.RegistryOption) *cobra.Command {
|
||||
return popx.NewMigrateSQLDownCmd(func(cmd *cobra.Command, args []string) error {
|
||||
return NewMigrateHandler().MigrateSQLDown(cmd, args, opts...)
|
||||
})
|
||||
}
|
||||
|
||||
func NewMigrateSQLUpCmd(opts ...driver.RegistryOption) *cobra.Command {
|
||||
return popx.NewMigrateSQLUpCmd(func(cmd *cobra.Command, args []string) error {
|
||||
return NewMigrateHandler().MigrateSQLUp(cmd, args, opts...)
|
||||
})
|
||||
}
|
||||
|
||||
func NewMigrateSQLStatusCmd(opts ...driver.RegistryOption) *cobra.Command {
|
||||
return popx.NewMigrateSQLStatusCmd(func(cmd *cobra.Command, args []string) error {
|
||||
return NewMigrateHandler().MigrateSQLStatus(cmd, args, opts...)
|
||||
})
|
||||
}
|
||||
45
cmd/migrate/sql.go
Normal file
45
cmd/migrate/sql.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/kratos/driver"
|
||||
"github.com/ory/x/configx"
|
||||
)
|
||||
|
||||
func NewMigrateSQLCmd(opts ...driver.RegistryOption) *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: "sql [database-url]",
|
||||
Deprecated: "Please use `kratos migrate sql up|down|status` instead.",
|
||||
Short: "Create SQL schemas and apply migration plans",
|
||||
Long: `Run this command on a fresh SQL installation and when you upgrade Ory Kratos to a new minor version.
|
||||
|
||||
It is recommended to run this command close to the SQL instance (e.g. same subnet) instead of over the public internet.
|
||||
This decreases risk of failure and decreases time required.
|
||||
|
||||
You can read in the database URL using the -e flag, for example:
|
||||
export DSN=...
|
||||
kratos migrate sql -e
|
||||
|
||||
### WARNING ###
|
||||
|
||||
Before running this command on an existing database, create a back up!
|
||||
`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return NewMigrateHandler().MigrateSQLUp(cmd, args, opts...)
|
||||
},
|
||||
}
|
||||
|
||||
configx.RegisterFlags(c.PersistentFlags())
|
||||
c.PersistentFlags().BoolP("read-from-env", "e", false, "If set, reads the database connection string from the environment variable DSN or config file key dsn.")
|
||||
c.Flags().BoolP("yes", "y", false, "If set all confirmation requests are accepted without user interaction.")
|
||||
|
||||
c.AddCommand(NewMigrateSQLStatusCmd(opts...))
|
||||
c.AddCommand(NewMigrateSQLUpCmd(opts...))
|
||||
c.AddCommand(NewMigrateSQLDownCmd(opts...))
|
||||
|
||||
return c
|
||||
}
|
||||
29
cmd/remote/root.go
Normal file
29
cmd/remote/root.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package remote
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
|
||||
"github.com/ory/kratos/cmd/cliclient"
|
||||
)
|
||||
|
||||
var remoteCmd = &cobra.Command{
|
||||
Use: "remote",
|
||||
Short: "Helpers and management for remote Ory Kratos instances",
|
||||
}
|
||||
|
||||
func RegisterCommandRecursive(parent *cobra.Command) {
|
||||
parent.AddCommand(remoteCmd)
|
||||
|
||||
remoteCmd.AddCommand(versionCmd)
|
||||
remoteCmd.AddCommand(statusCmd)
|
||||
}
|
||||
|
||||
func init() {
|
||||
cliclient.RegisterClientFlags(remoteCmd.PersistentFlags())
|
||||
cmdx.RegisterFormatFlags(remoteCmd.PersistentFlags())
|
||||
}
|
||||
69
cmd/remote/status.go
Normal file
69
cmd/remote/status.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package remote
|
||||
|
||||
import (
|
||||
"github.com/ory/x/cmdx"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/kratos/cmd/cliclient"
|
||||
)
|
||||
|
||||
type statusState struct {
|
||||
Alive bool `json:"alive"`
|
||||
Ready bool `json:"ready"`
|
||||
}
|
||||
|
||||
func (s *statusState) Header() []string {
|
||||
return []string{"ALIVE", "READY"}
|
||||
}
|
||||
|
||||
func (s *statusState) Columns() []string {
|
||||
f := [2]string{
|
||||
"false",
|
||||
"false",
|
||||
}
|
||||
if s.Alive {
|
||||
f[0] = "true"
|
||||
}
|
||||
if s.Ready {
|
||||
f[1] = "true"
|
||||
}
|
||||
return f[:]
|
||||
}
|
||||
|
||||
func (s *statusState) Interface() interface{} {
|
||||
return s
|
||||
}
|
||||
|
||||
var statusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Print the alive and readiness status of a Ory Kratos instance",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := cliclient.NewClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
state := &statusState{}
|
||||
defer cmdx.PrintRow(cmd, state)
|
||||
|
||||
alive, _, err := c.MetadataAPI.IsAlive(cmd.Context()).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
state.Alive = alive.Status == "ok"
|
||||
|
||||
ready, _, err := c.MetadataAPI.IsReady(cmd.Context()).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
state.Ready = ready.Status == "ok"
|
||||
return nil
|
||||
},
|
||||
}
|
||||
47
cmd/remote/version.go
Normal file
47
cmd/remote/version.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package remote
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/kratos/cmd/cliclient"
|
||||
"github.com/ory/x/cmdx"
|
||||
)
|
||||
|
||||
type versionValue struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
func (v *versionValue) Header() []string {
|
||||
return []string{"VERSION"}
|
||||
}
|
||||
|
||||
func (v *versionValue) Columns() []string {
|
||||
return []string{v.Version}
|
||||
}
|
||||
|
||||
func (v *versionValue) Interface() interface{} {
|
||||
return v
|
||||
}
|
||||
|
||||
var versionCmd = &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print the version of an Ory Kratos instance",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := cliclient.NewClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, _, err := c.MetadataAPI.GetVersion(cmd.Context()).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmdx.PrintRow(cmd, &versionValue{Version: resp.Version})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
72
cmd/root.go
Normal file
72
cmd/root.go
Normal file
@@ -0,0 +1,72 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/kratos/cmd/cleanup"
|
||||
"github.com/ory/kratos/cmd/courier"
|
||||
"github.com/ory/kratos/cmd/hashers"
|
||||
"github.com/ory/kratos/cmd/identities"
|
||||
"github.com/ory/kratos/cmd/jsonnet"
|
||||
"github.com/ory/kratos/cmd/migrate"
|
||||
"github.com/ory/kratos/cmd/remote"
|
||||
"github.com/ory/kratos/cmd/serve"
|
||||
"github.com/ory/kratos/driver"
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/x/cmdx"
|
||||
"github.com/ory/x/jsonnetsecure"
|
||||
"github.com/ory/x/profilex"
|
||||
)
|
||||
|
||||
func NewRootCmd(driverOpts ...driver.RegistryOption) (cmd *cobra.Command) {
|
||||
cmd = &cobra.Command{
|
||||
Use: "kratos",
|
||||
}
|
||||
cmdx.EnableUsageTemplating(cmd)
|
||||
|
||||
courier.RegisterCommandRecursive(cmd, driverOpts)
|
||||
cmd.AddCommand(identities.NewGetCmd())
|
||||
cmd.AddCommand(identities.NewDeleteCmd())
|
||||
cmd.AddCommand(jsonnet.NewFormatCmd())
|
||||
hashers.RegisterCommandRecursive(cmd)
|
||||
cmd.AddCommand(identities.NewImportCmd())
|
||||
cmd.AddCommand(jsonnet.NewLintCmd())
|
||||
cmd.AddCommand(identities.NewListCmd())
|
||||
migrate.RegisterCommandRecursive(cmd)
|
||||
serve.RegisterCommandRecursive(cmd, driverOpts)
|
||||
cleanup.RegisterCommandRecursive(cmd)
|
||||
remote.RegisterCommandRecursive(cmd)
|
||||
cmd.AddCommand(identities.NewValidateCmd())
|
||||
cmd.AddCommand(cmdx.Version(&config.Version, &config.Commit, &config.Date))
|
||||
|
||||
// Registers a hidden "jsonnet" subcommand for process-isolated Jsonnet VMs.
|
||||
cmd.AddCommand(jsonnetsecure.NewJsonnetCmd())
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// Execute adds all child commands to the root command and sets flags appropriately.
|
||||
// This is called by main.main(). It only needs to happen once to the RootCmd.
|
||||
func Execute() int {
|
||||
defer profilex.Profile().Stop()
|
||||
|
||||
jsonnetPool := jsonnetsecure.NewProcessPool(runtime.GOMAXPROCS(0))
|
||||
defer jsonnetPool.Close()
|
||||
|
||||
c := NewRootCmd(driver.WithJsonnetPool(jsonnetPool))
|
||||
|
||||
if err := c.Execute(); err != nil {
|
||||
if !errors.Is(err, cmdx.ErrNoPrintButFail) {
|
||||
_, _ = fmt.Fprintln(c.ErrOrStderr(), err)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
14
cmd/root_test.go
Normal file
14
cmd/root_test.go
Normal file
@@ -0,0 +1,14 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ory/x/cmdx"
|
||||
)
|
||||
|
||||
func TestUsageStrings(t *testing.T) {
|
||||
cmdx.AssertUsageTemplates(t, NewRootCmd())
|
||||
}
|
||||
58
cmd/serve/root.go
Normal file
58
cmd/serve/root.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package serve
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/ory/kratos/cmd/daemon"
|
||||
"github.com/ory/kratos/driver"
|
||||
"github.com/ory/kratos/driver/config"
|
||||
"github.com/ory/x/configx"
|
||||
)
|
||||
|
||||
// NewServeCmd returns the serve command
|
||||
func NewServeCmd(dOpts ...driver.RegistryOption) (serveCmd *cobra.Command) {
|
||||
serveCmd = &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Run the Ory Kratos server",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
d, err := driver.New(ctx, cmd.ErrOrStderr(), append(dOpts, driver.WithConfigOptions(configx.WithFlags(cmd.Flags())))...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if d.Config().IsInsecureDevMode(ctx) {
|
||||
d.Logger().Warn(`
|
||||
|
||||
YOU ARE RUNNING Ory KRATOS IN DEV MODE.
|
||||
SECURITY IS DISABLED.
|
||||
DON'T DO THIS IN PRODUCTION!
|
||||
|
||||
`)
|
||||
}
|
||||
|
||||
configVersion := d.Config().ConfigVersion(ctx)
|
||||
if configVersion == config.UnknownVersion {
|
||||
d.Logger().Warn("The config has no version specified. Add the version to improve your development experience.")
|
||||
} else if config.Version != "" &&
|
||||
configVersion != config.Version {
|
||||
d.Logger().Warnf("Config version is '%s' but kratos runs on version '%s'", configVersion, config.Version)
|
||||
}
|
||||
|
||||
return daemon.ServeAll(d)(cmd, args)
|
||||
},
|
||||
}
|
||||
configx.RegisterFlags(serveCmd.PersistentFlags())
|
||||
|
||||
serveCmd.PersistentFlags().Bool("sqa-opt-out", false, "Disable anonymized telemetry reports - for more information please visit https://www.ory.com/docs/ecosystem/sqa")
|
||||
serveCmd.PersistentFlags().Bool("dev", false, "Disables critical security features to make development easier")
|
||||
serveCmd.PersistentFlags().Bool("watch-courier", false, "Run the message courier as a background task, to simplify single-instance setup")
|
||||
return serveCmd
|
||||
}
|
||||
|
||||
func RegisterCommandRecursive(parent *cobra.Command, dOpts []driver.RegistryOption) {
|
||||
parent.AddCommand(NewServeCmd(dOpts...))
|
||||
}
|
||||
45
cmd/serve/root_test.go
Normal file
45
cmd/serve/root_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package serve_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ory/kratos/pkg/testhelpers"
|
||||
)
|
||||
|
||||
func TestServe(t *testing.T) {
|
||||
_, _ = testhelpers.StartE2EServer(t, "./stub/kratos.yml", nil)
|
||||
}
|
||||
|
||||
func TestServeTLSBase64(t *testing.T) {
|
||||
_, _, certBase64, keyBase64 := testhelpers.GenerateTLSCertificateFilesForTests(t)
|
||||
publicPort, adminPort := testhelpers.StartE2EServerOnly(t,
|
||||
"./stub/kratos.yml",
|
||||
true,
|
||||
testhelpers.ConfigOptions{
|
||||
"serve.public.tls.key.base64": keyBase64,
|
||||
"serve.public.tls.cert.base64": certBase64,
|
||||
"serve.admin.tls.key.base64": keyBase64,
|
||||
"serve.admin.tls.cert.base64": certBase64,
|
||||
},
|
||||
)
|
||||
testhelpers.CheckE2EServerOnHTTPS(t, publicPort, adminPort)
|
||||
}
|
||||
|
||||
func TestServeTLSPaths(t *testing.T) {
|
||||
certPath, keyPath, _, _ := testhelpers.GenerateTLSCertificateFilesForTests(t)
|
||||
|
||||
publicPort, adminPort := testhelpers.StartE2EServerOnly(t,
|
||||
"./stub/kratos.yml",
|
||||
true,
|
||||
testhelpers.ConfigOptions{
|
||||
"serve.public.tls.key.path": keyPath,
|
||||
"serve.public.tls.cert.path": certPath,
|
||||
"serve.admin.tls.key.path": keyPath,
|
||||
"serve.admin.tls.cert.path": certPath,
|
||||
},
|
||||
)
|
||||
testhelpers.CheckE2EServerOnHTTPS(t, publicPort, adminPort)
|
||||
}
|
||||
49
cmd/serve/stub/identity.schema.json
Normal file
49
cmd/serve/stub/identity.schema.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"$id": "https://schemas.ory.sh/presets/kratos/quickstart/email-password/identity.schema.json",
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "Person",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"title": "E-Mail",
|
||||
"minLength": 3,
|
||||
"ory.sh/kratos": {
|
||||
"credentials": {
|
||||
"password": {
|
||||
"identifier": true
|
||||
}
|
||||
},
|
||||
"verification": {
|
||||
"via": "email"
|
||||
},
|
||||
"recovery": {
|
||||
"via": "email"
|
||||
}
|
||||
}
|
||||
},
|
||||
"name": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"first": {
|
||||
"title": "First Name",
|
||||
"type": "string"
|
||||
},
|
||||
"last": {
|
||||
"title": "Last Name",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"email"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
81
cmd/serve/stub/kratos.yml
Normal file
81
cmd/serve/stub/kratos.yml
Normal file
@@ -0,0 +1,81 @@
|
||||
dsn: memory
|
||||
|
||||
serve:
|
||||
public:
|
||||
base_url: http://127.0.0.1:4433/
|
||||
cors:
|
||||
enabled: true
|
||||
admin:
|
||||
base_url: http://kratos:4434/
|
||||
|
||||
selfservice:
|
||||
default_browser_return_url: http://127.0.0.1:4455/
|
||||
allowed_return_urls:
|
||||
- http://127.0.0.1:4455
|
||||
|
||||
methods:
|
||||
password:
|
||||
enabled: true
|
||||
|
||||
flows:
|
||||
error:
|
||||
ui_url: http://127.0.0.1:4455/error
|
||||
|
||||
settings:
|
||||
ui_url: http://127.0.0.1:4455/settings
|
||||
privileged_session_max_age: 15m
|
||||
|
||||
recovery:
|
||||
enabled: true
|
||||
ui_url: http://127.0.0.1:4455/recovery
|
||||
|
||||
verification:
|
||||
enabled: true
|
||||
ui_url: http://127.0.0.1:4455/verify
|
||||
after:
|
||||
default_browser_return_url: http://127.0.0.1:4455/
|
||||
|
||||
logout:
|
||||
after:
|
||||
default_browser_return_url: http://127.0.0.1:4455/auth/login
|
||||
|
||||
login:
|
||||
ui_url: http://127.0.0.1:4455/auth/login
|
||||
lifespan: 10m
|
||||
|
||||
registration:
|
||||
lifespan: 10m
|
||||
ui_url: http://127.0.0.1:4455/auth/registration
|
||||
after:
|
||||
password:
|
||||
hooks:
|
||||
-
|
||||
hook: session
|
||||
|
||||
log:
|
||||
level: debug
|
||||
format: text
|
||||
leak_sensitive_values: true
|
||||
|
||||
secrets:
|
||||
cookie:
|
||||
- PLEASE-CHANGE-ME-I-AM-VERY-INSECURE
|
||||
pagination:
|
||||
- "test pagination secret"
|
||||
|
||||
hashers:
|
||||
argon2:
|
||||
parallelism: 1
|
||||
memory: 128MB
|
||||
iterations: 2
|
||||
salt_length: 16
|
||||
key_length: 16
|
||||
|
||||
identity:
|
||||
schemas:
|
||||
- id: default
|
||||
url: file://stub/identity.schema.json
|
||||
|
||||
courier:
|
||||
smtp:
|
||||
connection_uri: smtps://test:test@mailslurper:1025/?skip_ssl_verify=true&legacy_ssl=true
|
||||
Reference in New Issue
Block a user