diff --git a/.github/workflows/build-frontend.yml b/.github/workflows/build-frontend.yml index e6958d548..5de6f4045 100644 --- a/.github/workflows/build-frontend.yml +++ b/.github/workflows/build-frontend.yml @@ -15,7 +15,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: v18.14.2 + node-version: v20.16.0 - name: Install dependencies working-directory: ./frontend diff --git a/.github/workflows/generate-config-docs.yml b/.github/workflows/generate-config-docs.yml new file mode 100644 index 000000000..e3164d7c7 --- /dev/null +++ b/.github/workflows/generate-config-docs.yml @@ -0,0 +1,56 @@ +name: Generate config reference markdown + +on: + workflow_dispatch: + +jobs: + config: + runs-on: ubuntu-latest + steps: + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.20' + + - uses: actions/setup-node@v4 + with: + node-version: '20.16.0' + registry-url: https://registry.npmjs.org/ + + - name: Checkout backend + uses: actions/checkout@v4 + with: + path: hanko + + - name: Checkout backend wiki + uses: actions/checkout@v4 + with: + repository: ${{github.repository}}.wiki + path: wiki + + - name: Generate config docs + working-directory: ./hanko/backend + run: | + go generate ./... + go run main.go schema json2md + + - name: Clean md file endings + working-directory: ./hanko/backend + run: | + find ./docs/.generated/config/md -type f -name "*.md" -exec sed -i "s/\.md//g" "{}" \; + + - name: Copy generated files + working-directory: ./hanko/backend + run: | + mkdir -p $GITHUB_WORKSPACE/wiki/reference/config + rm $GITHUB_WORKSPACE/wiki/reference/config/*.md 2>/dev/null || true + cp ./docs/.generated/config/md/*.md $GITHUB_WORKSPACE/wiki/reference/config + + - name: Commit and push to wiki + working-directory: ./wiki + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git add . + git commit -m "action: Autogenerate config reference docs" + git push origin HEAD diff --git a/.gitignore b/.gitignore index 777cad7b1..b4a853138 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ # Output of the go coverage tool, specifically when used with LiteIDE *.out +# Generated files +.generated + # MacOS .DS_Store diff --git a/backend/Dockerfile b/backend/Dockerfile index e7844ffa1..cc1b3cd6a 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -30,6 +30,8 @@ COPY template template/ COPY utils utils/ COPY mapper mapper/ COPY webhooks webhooks/ +COPY flow_api flow_api/ +COPY flowpilot flowpilot/ # Build RUN go generate ./... diff --git a/backend/Dockerfile.debug b/backend/Dockerfile.debug index 24b8da58d..646b519a5 100644 --- a/backend/Dockerfile.debug +++ b/backend/Dockerfile.debug @@ -27,6 +27,8 @@ COPY rate_limiter rate_limiter/ COPY thirdparty thirdparty/ COPY build_info build_info/ COPY middleware middleware/ +COPY flow_api flow_api/ +COPY flowpilot flowpilot/ COPY template template/ COPY utils utils/ COPY mapper mapper/ diff --git a/backend/audit_log/logger.go b/backend/audit_log/logger.go index c4714d16c..687b1ae0d 100644 --- a/backend/audit_log/logger.go +++ b/backend/audit_log/logger.go @@ -1,23 +1,22 @@ package auditlog import ( - "fmt" "github.com/gobuffalo/pop/v6" - "github.com/gofrs/uuid" "github.com/labstack/echo/v4" zeroLog "github.com/rs/zerolog" zeroLogger "github.com/rs/zerolog/log" "github.com/teamhanko/hanko/backend/config" "github.com/teamhanko/hanko/backend/persistence" "github.com/teamhanko/hanko/backend/persistence/models" + "github.com/teamhanko/hanko/backend/utils" "os" "strconv" "time" ) type Logger interface { - Create(echo.Context, models.AuditLogType, *models.User, error) error - CreateWithConnection(*pop.Connection, echo.Context, models.AuditLogType, *models.User, error) error + Create(echo.Context, models.AuditLogType, *models.User, error, ...DetailOption) error + CreateWithConnection(*pop.Connection, echo.Context, models.AuditLogType, *models.User, error, ...DetailOption) error } type logger struct { @@ -25,6 +24,7 @@ type logger struct { storageEnabled bool logger zeroLog.Logger consoleLoggingEnabled bool + mustMask bool } func NewLogger(persister persistence.Persister, cfg config.AuditLog) Logger { @@ -43,78 +43,108 @@ func NewLogger(persister persistence.Persister, cfg config.AuditLog) Logger { storageEnabled: cfg.Storage.Enabled, logger: zeroLog.New(loggerOutput), consoleLoggingEnabled: cfg.ConsoleOutput.Enabled, + mustMask: cfg.Mask, } } -func (l *logger) Create(context echo.Context, auditLogType models.AuditLogType, user *models.User, logError error) error { - return l.CreateWithConnection(l.persister.GetConnection(), context, auditLogType, user, logError) -} +type DetailOption func(map[string]interface{}) -func (l *logger) CreateWithConnection(tx *pop.Connection, context echo.Context, auditLogType models.AuditLogType, user *models.User, logError error) error { - if l.storageEnabled { - err := l.store(tx, context, auditLogType, user, logError) - if err != nil { - return err +func Detail(key string, value interface{}) DetailOption { + return func(d map[string]interface{}) { + if value != "" || value != nil { + d[key] = value } } +} - if l.consoleLoggingEnabled { - l.logToConsole(context, auditLogType, user, logError) - } - - return nil +func (l *logger) Create(context echo.Context, auditLogType models.AuditLogType, user *models.User, logError error, detailOpts ...DetailOption) error { + return l.CreateWithConnection(l.persister.GetConnection(), context, auditLogType, user, logError, detailOpts...) } -func (l *logger) store(tx *pop.Connection, context echo.Context, auditLogType models.AuditLogType, user *models.User, logError error) error { - id, err := uuid.NewV4() +func (l *logger) CreateWithConnection(tx *pop.Connection, context echo.Context, auditLogType models.AuditLogType, user *models.User, logError error, detailOpts ...DetailOption) error { + details := make(map[string]interface{}) + for _, detailOpt := range detailOpts { + detailOpt(details) + } + + auditLog, err := models.NewAuditLog(auditLogType, l.getRequestMeta(context), details, user, logError) if err != nil { - return fmt.Errorf("failed to create id: %w", err) + return err } - al := models.AuditLog{ - ID: id, - Type: auditLogType, - Error: nil, - MetaHttpRequestId: context.Response().Header().Get(echo.HeaderXRequestID), - MetaUserAgent: context.Request().UserAgent(), - MetaSourceIp: context.RealIP(), - ActorUserId: nil, - ActorEmail: nil, + if l.mustMask { + auditLog = l.mask(auditLog) } - if user != nil { - al.ActorUserId = &user.ID - if e := user.Emails.GetPrimary(); e != nil { - al.ActorEmail = &e.Address + if l.storageEnabled { + err = l.store(tx, auditLog) + if err != nil { + return err } } - if logError != nil { - // check if error is not nil, because else the string (formatted with fmt.Sprintf) would not be empty but look like this: `%!s()` - tmp := fmt.Sprintf("%s", logError) - al.Error = &tmp + + if l.consoleLoggingEnabled { + l.logToConsole(auditLog) } - return l.persister.GetAuditLogPersisterWithConnection(tx).Create(al) + return nil } -func (l *logger) logToConsole(context echo.Context, auditLogType models.AuditLogType, user *models.User, logError error) { +func (l *logger) store(tx *pop.Connection, auditLog models.AuditLog) error { + return l.persister.GetAuditLogPersisterWithConnection(tx).Create(auditLog) +} + +func (l *logger) logToConsole(auditLog models.AuditLog) { + var err string + if auditLog.Error != nil { + err = *auditLog.Error + } + now := time.Now() loggerEvent := zeroLogger.Log(). Str("audience", "audit"). - Str("type", string(auditLogType)). - AnErr("error", logError). - Str("http_request_id", context.Response().Header().Get(echo.HeaderXRequestID)). - Str("source_ip", context.RealIP()). - Str("user_agent", context.Request().UserAgent()). + Str("type", string(auditLog.Type)). + Str("error", err). + Str("http_request_id", auditLog.MetaHttpRequestId). + Str("source_ip", auditLog.MetaSourceIp). + Str("user_agent", auditLog.MetaUserAgent). + Any("details", auditLog.Details). Str("time", now.Format(time.RFC3339Nano)). Str("time_unix", strconv.FormatInt(now.Unix(), 10)) - if user != nil { - loggerEvent.Str("user_id", user.ID.String()) - if e := user.Emails.GetPrimary(); e != nil { - loggerEvent.Str("user_email", e.Address) + if auditLog.ActorUserId != nil { + loggerEvent.Str("user_id", auditLog.ActorUserId.String()) + if auditLog.ActorEmail != nil { + loggerEvent.Str("user_email", *auditLog.ActorEmail) } } loggerEvent.Send() } + +func (l *logger) getRequestMeta(c echo.Context) models.RequestMeta { + return models.RequestMeta{ + HttpRequestId: c.Response().Header().Get(echo.HeaderXRequestID), + UserAgent: c.Request().UserAgent(), + SourceIp: c.RealIP(), + } +} + +func (l *logger) mask(auditLog models.AuditLog) models.AuditLog { + if auditLog.ActorEmail != nil && *auditLog.ActorEmail != "" { + email := utils.MaskEmail(*auditLog.ActorEmail) + auditLog.ActorEmail = &email + } + + for key, value := range auditLog.Details { + if key == "username" { + auditLog.Details[key] = utils.MaskUsername(value.(string)) + } + + if key == "email" { + auditLog.Details[key] = utils.MaskEmail(value.(string)) + } + } + + return auditLog +} diff --git a/backend/cmd/root.go b/backend/cmd/root.go index 308a4c2b9..72f0bc46c 100644 --- a/backend/cmd/root.go +++ b/backend/cmd/root.go @@ -9,6 +9,7 @@ import ( "github.com/teamhanko/hanko/backend/cmd/jwk" "github.com/teamhanko/hanko/backend/cmd/jwt" "github.com/teamhanko/hanko/backend/cmd/migrate" + "github.com/teamhanko/hanko/backend/cmd/schema" "github.com/teamhanko/hanko/backend/cmd/serve" "github.com/teamhanko/hanko/backend/cmd/siwa" "github.com/teamhanko/hanko/backend/cmd/user" @@ -29,6 +30,7 @@ func NewRootCmd() *cobra.Command { version.RegisterCommands(cmd) user.RegisterCommands(cmd) siwa.RegisterCommands(cmd) + schema.RegisterCommands(cmd) return cmd } diff --git a/backend/cmd/schema/json2md.go b/backend/cmd/schema/json2md.go new file mode 100644 index 000000000..9adce836d --- /dev/null +++ b/backend/cmd/schema/json2md.go @@ -0,0 +1,74 @@ +package schema + +import ( + "encoding/json" + "errors" + "fmt" + "github.com/invopop/jsonschema" + "github.com/spf13/cobra" + "github.com/teamhanko/hanko/backend/config" + "log" + "os" + "os/exec" +) + +func NewJson2MdCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "json2md", + Short: "Generate markdown from JSONSchema", + Run: func(cmd *cobra.Command, args []string) { + r := new(jsonschema.Reflector) + r.DoNotReference = true + if err := r.AddGoComments("github.com/teamhanko/hanko/backend", "./config"); err != nil { + log.Fatal(err) + } + + if err := r.AddGoComments("github.com/teamhanko/hanko/backend", "./ee"); err != nil { + log.Fatal(err) + } + + s := r.Reflect(&config.Config{}) + s.Title = "Config" + + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + log.Fatal(err) + } + + outPath := "./docs/.generated/config" + if _, err := os.Stat(outPath); errors.Is(err, os.ErrNotExist) { + err := os.MkdirAll(outPath, 0750) + if err != nil { + log.Fatal(err) + } + } + + err = os.WriteFile(fmt.Sprintf("%s/hanko.config.json", outPath), data, 0600) + if err != nil { + log.Fatal(err) + } + + out, err := exec.Command("npx", + "@adobe/jsonschema2md", + "--input=docs/.generated/config", + "--out=docs/.generated/config/md", + "--schema-extension=config.json", + "--example-format=yaml", + "--header=false", + "--skip=definedinfact", + "--skip=typesection", + "--schema-out=-", + "--properties=format", + "--no-readme=true"). + CombinedOutput() + + if err != nil { + log.Fatal(err) + } + + fmt.Println(string(out)) + }, + } + + return cmd +} diff --git a/backend/cmd/schema/root.go b/backend/cmd/schema/root.go new file mode 100644 index 000000000..ba7be7317 --- /dev/null +++ b/backend/cmd/schema/root.go @@ -0,0 +1,17 @@ +package schema + +import "github.com/spf13/cobra" + +func NewSchemaCommand() *cobra.Command { + return &cobra.Command{ + Use: "schema", + Short: "JSONSchema related commands", + Long: ``, + } +} + +func RegisterCommands(parent *cobra.Command) { + cmd := NewSchemaCommand() + parent.AddCommand(cmd) + cmd.AddCommand(NewJson2MdCommand()) +} diff --git a/backend/config/config.go b/backend/config/config.go index 8fe1fd177..d0489919d 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -3,11 +3,15 @@ package config import ( "errors" "fmt" + "github.com/invopop/jsonschema" + orderedmap "github.com/wk8/go-ordered-map/v2" "log" "strings" "time" "github.com/fatih/structs" + "github.com/go-webauthn/webauthn/protocol" + webauthnLib "github.com/go-webauthn/webauthn/webauthn" "github.com/gobwas/glob" "github.com/kelseyhightower/envconfig" "github.com/knadh/koanf" @@ -20,24 +24,56 @@ import ( // Config is the central configuration type type Config struct { - Server Server `yaml:"server" json:"server,omitempty" koanf:"server"` - Webauthn WebauthnSettings `yaml:"webauthn" json:"webauthn,omitempty" koanf:"webauthn"` - Smtp SMTP `yaml:"smtp" json:"smtp,omitempty" koanf:"smtp"` - EmailDelivery EmailDelivery `yaml:"email_delivery" json:"email_delivery,omitempty" koanf:"email_delivery" split_words:"true"` - Passcode Passcode `yaml:"passcode" json:"passcode" koanf:"passcode"` - Password Password `yaml:"password" json:"password,omitempty" koanf:"password"` - Database Database `yaml:"database" json:"database" koanf:"database"` - Secrets Secrets `yaml:"secrets" json:"secrets" koanf:"secrets"` - Service Service `yaml:"service" json:"service" koanf:"service"` - Session Session `yaml:"session" json:"session,omitempty" koanf:"session"` - AuditLog AuditLog `yaml:"audit_log" json:"audit_log,omitempty" koanf:"audit_log" split_words:"true"` - Emails Emails `yaml:"emails" json:"emails,omitempty" koanf:"emails"` - RateLimiter RateLimiter `yaml:"rate_limiter" json:"rate_limiter,omitempty" koanf:"rate_limiter" split_words:"true"` - ThirdParty ThirdParty `yaml:"third_party" json:"third_party,omitempty" koanf:"third_party" split_words:"true"` - Log LoggerConfig `yaml:"log" json:"log,omitempty" koanf:"log"` - Account Account `yaml:"account" json:"account,omitempty" koanf:"account"` - Saml config.Saml `yaml:"saml" json:"saml,omitempty" koanf:"saml"` - Webhooks WebhookSettings `yaml:"webhooks" json:"webhooks,omitempty" koanf:"webhooks"` + // `account` configures settings related to user accounts. + Account Account `yaml:"account" json:"account,omitempty" koanf:"account" jsonschema:"title=account"` + // `audit_log` configures output and storage modalities of audit logs. + AuditLog AuditLog `yaml:"audit_log" json:"audit_log,omitempty" koanf:"audit_log" split_words:"true" jsonschema:"title=audit_log"` + // `convert_legacy_config`, if set to `true`, automatically copies the set values of deprecated configuration + // options, to new ones. If set to `false`, these values have to be set manually if non-default values should be + // used. + ConvertLegacyConfig bool `yaml:"convert_legacy_config" json:"convert_legacy_config,omitempty" koanf:"convert_legacy_config" split_words:"true" jsonschema:"default=false"` + // `database configures database connection settings. + Database Database `yaml:"database" json:"database,omitempty" koanf:"database" jsonschema:"title=database"` + // `debug`, if set to `true`, adds additional debugging information to flow API responses. + Debug bool `yaml:"debug" json:"debug,omitempty" koanf:"debug"` + // `email` configures how email addresses of user accounts are acquired and used. + Email Email `yaml:"email" json:"email,omitempty" koanf:"email" jsonschema:"title=email"` + // `email_delivery` configures how outgoing mails are delivered. + EmailDelivery EmailDelivery `yaml:"email_delivery" json:"email_delivery,omitempty" koanf:"email_delivery" split_words:"true" jsonschema:"title=email_delivery"` + // Deprecated. See child properties for suggested replacements. + Emails Emails `yaml:"emails" json:"emails,omitempty" koanf:"emails" jsonschema:"title=emails"` + // `log` configures application logging. + Log LoggerConfig `yaml:"log" json:"log,omitempty" koanf:"log" jsonschema:"title=log"` + // Deprecated. See child properties for suggested replacements. + Passcode Passcode `yaml:"passcode" json:"passcode,omitempty" koanf:"passcode" jsonschema:"title=passcode"` + // `passkey` configures how passkeys are acquired and used. + Passkey Passkey `yaml:"passkey" json:"passkey,omitempty" koanf:"passkey" jsonschema:"title=passkey"` + // `password` configures how passwords are acquired and used. + Password Password `yaml:"password" json:"password,omitempty" koanf:"password" jsonschema:"title=password"` + // `rate_limiter` configures rate limits for rate limited API operations and storage modalities for rate limit data. + RateLimiter RateLimiter `yaml:"rate_limiter" json:"rate_limiter,omitempty" koanf:"rate_limiter" split_words:"true" jsonschema:"title=rate_limiter"` + // `saml` configures modalities of SAML (Security Assertion Markup Language) SSO authentication and SAML identity + // providers. + Saml config.Saml `yaml:"saml" json:"saml,omitempty" koanf:"saml" jsonschema:"title=saml"` + // `secrets` configures the keys used for cryptographically signing tokens issued by the API. + Secrets Secrets `yaml:"secrets" json:"secrets,omitempty" koanf:"secrets" jsonschema:"title=secrets"` + // `server` configures address and CORS settings of the public and admin API. + Server Server `yaml:"server" json:"server,omitempty" koanf:"server" jsonschema:"title=server"` + // `service` configures general service information. + Service Service `yaml:"service" json:"service,omitempty" koanf:"service" jsonschema:"title=service"` + // `session` configures settings for session JWTs and Cookies issued by the API. + Session Session `yaml:"session" json:"session,omitempty" koanf:"session" jsonschema:"title=session"` + // Deprecated. Use `email_delivery.smtp` instead. + Smtp SMTP `yaml:"smtp" json:"smtp,omitempty" koanf:"smtp" jsonschema:"title=smtp"` + // `third_party` configures the modalities of third party OAuth/OIDC based authentication and available identity + // providers. + ThirdParty ThirdParty `yaml:"third_party" json:"third_party,omitempty" koanf:"third_party" split_words:"true" jsonschema:"title=third_party"` + // `username` configures how usernames of user accounts are acquired and used. + Username Username `yaml:"username" json:"username,omitempty" koanf:"username" jsonschema:"title=username"` + // `webauthn` configures general settings for communication with the WebAuthentication API. + Webauthn WebauthnSettings `yaml:"webauthn" json:"webauthn,omitempty" koanf:"webauthn" jsonschema:"title=webauthn"` + // `webhooks` configures HTTP-based callbacks for specific events occurring in the system. + Webhooks WebhookSettings `yaml:"webhooks" json:"webhooks,omitempty" koanf:"webhooks" jsonschema:"title=webhooks"` } var ( @@ -88,8 +124,6 @@ func Load(cfgFile *string) (*Config, error) { return nil, fmt.Errorf("failed to post process config: %w", err) } - c.arrangeSmtpSettings() - if err = c.Validate(); err != nil { return nil, fmt.Errorf("failed to validate config: %s", err) } @@ -97,107 +131,6 @@ func Load(cfgFile *string) (*Config, error) { return c, nil } -func DefaultConfig() *Config { - return &Config{ - Server: Server{ - Public: ServerSettings{ - Address: ":8000", - }, - Admin: ServerSettings{ - Address: ":8001", - }, - }, - Webauthn: WebauthnSettings{ - RelyingParty: RelyingParty{ - Id: "localhost", - DisplayName: "Hanko Authentication Service", - Origins: []string{"http://localhost:8888"}, - }, - UserVerification: "preferred", - Timeout: 60000, - }, - Smtp: SMTP{ - Port: "465", - }, - EmailDelivery: EmailDelivery{ - Enabled: true, - }, - Passcode: Passcode{ - TTL: 300, - Email: Email{ - FromAddress: "passcode@hanko.io", - FromName: "Hanko", - }, - Smtp: SMTP{ - Port: "465", - }, - }, - Password: Password{ - MinPasswordLength: 8, - }, - Database: Database{ - Database: "hanko", - }, - Session: Session{ - Lifespan: "1h", - Cookie: Cookie{ - HttpOnly: true, - SameSite: "strict", - Secure: true, - }, - }, - AuditLog: AuditLog{ - ConsoleOutput: AuditLogConsole{ - Enabled: true, - OutputStream: OutputStreamStdOut, - }, - }, - Emails: Emails{ - RequireVerification: true, - MaxNumOfAddresses: 5, - }, - RateLimiter: RateLimiter{ - Enabled: true, - Store: RATE_LIMITER_STORE_IN_MEMORY, - PasswordLimits: RateLimits{ - Tokens: 5, - Interval: 1 * time.Minute, - }, - PasscodeLimits: RateLimits{ - Tokens: 3, - Interval: 1 * time.Minute, - }, - TokenLimits: RateLimits{ - Tokens: 3, - Interval: 1 * time.Minute, - }, - }, - Account: Account{ - AllowDeletion: false, - AllowSignup: true, - }, - ThirdParty: ThirdParty{ - Providers: ThirdPartyProviders{ - Google: ThirdPartyProvider{ - AllowLinking: true, - }, - GitHub: ThirdPartyProvider{ - AllowLinking: true, - }, - Apple: ThirdPartyProvider{ - AllowLinking: true, - }, - Discord: ThirdPartyProvider{ - AllowLinking: true, - }, - Microsoft: ThirdPartyProvider{ - AllowLinking: true, - }, - }, - }, - } -} - func (c *Config) Validate() error { err := c.Server.Validate() if err != nil { @@ -252,10 +185,11 @@ func (c *Config) Validate() error { return nil } -// Server contains the setting for the public and admin server type Server struct { - Public ServerSettings `yaml:"public" json:"public,omitempty" koanf:"public"` - Admin ServerSettings `yaml:"admin" json:"admin,omitempty" koanf:"admin"` + // `public` contains the server configuration for the public API. + Public ServerSettings `yaml:"public" json:"public,omitempty" koanf:"public" jsonschema:"title=public"` + // `admin` contains the server configuration for the admin API. + Admin ServerSettings `yaml:"admin" json:"admin,omitempty" koanf:"admin" jsonschema:"title=admin"` } func (s *Server) Validate() error { @@ -271,7 +205,9 @@ func (s *Server) Validate() error { } type Service struct { - Name string `yaml:"name" json:"name" koanf:"name"` + // `name` determines the name of the service. + // This value is used, e.g. in the subject header of outgoing emails. + Name string `yaml:"name" json:"name,omitempty" koanf:"name"` } func (s *Service) Validate() error { @@ -282,16 +218,71 @@ func (s *Service) Validate() error { } type Password struct { - Enabled bool `yaml:"enabled" json:"enabled,omitempty" koanf:"enabled" jsonschema:"default=false"` - MinPasswordLength int `yaml:"min_password_length" json:"min_password_length,omitempty" koanf:"min_password_length" split_words:"true" jsonschema:"default=8"` + // `acquire_on_registration` configures how users are prompted creating a password on registration. + AcquireOnRegistration string `yaml:"acquire_on_registration" json:"acquire_on_registration,omitempty" koanf:"acquire_on_registration" split_words:"true" jsonschema:"default=never,enum=always,enum=conditional,enum=never"` + // `acquire_on_login` configures how users are prompted creating a password on login. + AcquireOnLogin string `yaml:"acquire_on_login" json:"acquire_on_login,omitempty" koanf:"acquire_on_login" split_words:"true" jsonschema:"default=always,enum=always,enum=conditional,enum=never"` + // `enabled` determines whether passwords are enabled or disabled. + Enabled bool `yaml:"enabled" json:"enabled,omitempty" koanf:"enabled" jsonschema:"default=false"` + // `min_length` determines the minimum password length. + MinLength int `yaml:"min_length" json:"min_length,omitempty" koanf:"min_length" split_words:"true" jsonschema:"default=8"` + // Deprecated. Use `min_length` instead. + MinPasswordLength int `yaml:"min_password_length" json:"min_password_length,omitempty" koanf:"min_password_length" split_words:"true" jsonschema:"default=8"` + // `optional` determines whether users must set a password when prompted. The password cannot be deleted if + // passwords are required (`optional: false`). + // + // It also takes part in determining the order of password and passkey acquisition + // on login and registration (see also `acquire_on_login` and `acquire_on_registration`): if one credential type is + // required (`optional: false`) then that one takes precedence, i.e. is acquired first. + Optional bool `yaml:"optional" json:"optional,omitempty" koanf:"optional" jsonschema:"default=false"` + // `recovery` determines whether users can start a recovery process, e.g. in case of a forgotten password. + Recovery bool `yaml:"recovery" json:"recovery,omitempty" koanf:"recovery" jsonschema:"default=true"` +} + +func (Password) JSONSchemaExtend(schema *jsonschema.Schema) { + acquireOnRegistration, _ := schema.Properties.Get("acquire_on_registration") + acquireOnRegistration.Extras = map[string]any{"meta:enum": map[string]string{ + "always": "Indicates that users are always prompted to create a password on registration.", + "conditional": `Indicates that users are prompted to create a password on registration as long as the user does + not have a passkey. + + If passkeys are also conditionally acquired on registration, then users are given a choice as + to what type of credential to register.`, + "never": "Indicates that users are never prompted to create a password on registration.", + }} + + acquireOnLogin, _ := schema.Properties.Get("acquire_on_login") + acquireOnLogin.Extras = map[string]any{"meta:enum": map[string]string{ + "always": `Indicates that users are always prompted to create a password on login + provided that they do not already have a password.`, + "conditional": `Indicates that users are prompted to create a password on login provided that + they do not already have a password and do not have a passkey. + + If passkeys are also conditionally acquired on login then users are given a choice as to what + type of credential to register.`, + "never": "Indicates that users are never prompted to create a password on login.", + }} } type Cookie struct { - Name string `yaml:"name" json:"name,omitempty" koanf:"name" jsonschema:"default=hanko"` - Domain string `yaml:"domain" json:"domain,omitempty" koanf:"domain"` - HttpOnly bool `yaml:"http_only" json:"http_only,omitempty" koanf:"http_only" split_words:"true"` - SameSite string `yaml:"same_site" json:"same_site,omitempty" koanf:"same_site" split_words:"true"` - Secure bool `yaml:"secure" json:"secure,omitempty" koanf:"secure"` + // `domain` is the domain the cookie will be bound to. Works for subdomains, but not cross-domain. + // See the `session.enable_auth_token_header` configuration instead if the API and the client application run on + // different domains. + Domain string `yaml:"domain" json:"domain,omitempty" koanf:"domain" jsonschema:"default=hanko"` + // `http_only` determines whether cookies are HTTP only or accessible by Javascript. + HttpOnly bool `yaml:"http_only" json:"http_only,omitempty" koanf:"http_only" split_words:"true" jsonschema:"default=true"` + // `name` is the name of the cookie. + Name string `yaml:"name" json:"name,omitempty" koanf:"name" jsonschema:"default=hanko"` + // `same_site` controls whether a cookie is sent with cross-site requests. + // See [here](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value) for + // more details. + SameSite string `yaml:"same_site" json:"same_site,omitempty" koanf:"same_site" split_words:"true" jsonschema:"default=strict,enum=strict,enum=lax,enum=none"` + // `secure` indicates whether the cookie is sent to the server only when a request is made with the https: scheme + // (except on localhost). + // + // NOTE: `secure` must be set to `false` when working on `localhost` and with the Safari browser because it does + // not store secure cookies on `localhost`. + Secure bool `yaml:"secure" json:"secure,omitempty" koanf:"secure" jsonschema:"default=true"` } func (c *Cookie) GetName() string { @@ -303,27 +294,30 @@ func (c *Cookie) GetName() string { } type ServerSettings struct { - // The Address to listen on in the form of host:port - // See net.Dial for details of the address format. + // `address` is the address of the server to listen on in the form of host:port. + // + // See [net.Dial](https://pkg.go.dev/net#Dial) for details of the address format. Address string `yaml:"address" json:"address,omitempty" koanf:"address"` - Cors Cors `yaml:"cors" json:"cors,omitempty" koanf:"cors"` + // `cors` contains configuration options regarding Cross-Origin-Resource-Sharing. + Cors Cors `yaml:"cors" json:"cors,omitempty" koanf:"cors" jsonschema:"title=cors"` } type Cors struct { - // AllowOrigins determines the value of the Access-Control-Allow-Origin - // response header. This header defines a list of origins that may access the - // resource. The wildcard characters '*' and '?' are supported and are - // converted to regex fragments '.*' and '.' accordingly. - AllowOrigins []string `yaml:"allow_origins" json:"allow_origins" koanf:"allow_origins" split_words:"true"` - - // UnsafeWildcardOriginWithAllowCredentials UNSAFE/INSECURE: allows wildcard '*' origin to be used with AllowCredentials - // flag. In that case we consider any origin allowed and send it back to the client with `Access-Control-Allow-Origin` header. + // `allow_origins` determines the value of the Access-Control-Allow-Origin + // response header. This header defines a list of [origins](https://developer.mozilla.org/en-US/docs/Glossary/Origin) + // that may access the resource. + // + // The wildcard characters `*` and `?` are supported and are converted to regex fragments `.*` and `.` accordingly. + AllowOrigins []string `yaml:"allow_origins" json:"allow_origins,omitempty" koanf:"allow_origins" split_words:"true" jsonschema:"title=allow_origins,default=http://localhost:8888"` + + // `unsafe_wildcard_origin_allowed` allows a wildcard `*` origin to be used with AllowCredentials + // flag. In that case we consider any origin allowed and send it back to the client in an `Access-Control-Allow-Origin` header. // // This is INSECURE and potentially leads to [cross-origin](https://portswigger.net/research/exploiting-cors-misconfigurations-for-bitcoins-and-bounties) - // attacks. See: https://github.com/labstack/echo/issues/2400 for discussion on the subject. + // attacks. See also https://github.com/labstack/echo/issues/2400 for discussion on the subject. // - // Optional. Default value is false. - UnsafeWildcardOriginAllowed bool `yaml:"unsafe_wildcard_origin_allowed" json:"unsafe_wildcard_origin_allowed,omitempty" koanf:"unsafe_wildcard_origin_allowed" split_words:"true" jsonschema:"default=false"` + // Optional. Default value is `false`. + UnsafeWildcardOriginAllowed bool `yaml:"unsafe_wildcard_origin_allowed" json:"unsafe_wildcard_origin_allowed,omitempty" koanf:"unsafe_wildcard_origin_allowed" split_words:"true" jsonschema:"title=unsafe_wildcard_origin_allowed,default=false"` } func (cors *Cors) Validate() error { @@ -346,11 +340,61 @@ func (s *ServerSettings) Validate() error { return nil } +type WebauthnTimeouts struct { + // `registration` determines the time, in milliseconds, that the client is willing to wait for the credential + // creation request to the WebAuthn API to complete. + Registration int `yaml:"registration" json:"registration,omitempty" koanf:"registration" jsonschema:"default=60000"` + // `login` determines the time, in milliseconds, that the client is willing to wait for the credential + // request to the WebAuthn API to complete. + Login int `yaml:"login" json:"login,omitempty" koanf:"login" jsonschema:"default=60000"` +} + // WebauthnSettings defines the settings for the webauthn authentication mechanism type WebauthnSettings struct { - RelyingParty RelyingParty `yaml:"relying_party" json:"relying_party,omitempty" koanf:"relying_party" split_words:"true"` - Timeout int `yaml:"timeout" json:"timeout,omitempty" koanf:"timeout" jsonschema:"default=60000"` - UserVerification string `yaml:"user_verification" json:"user_verification,omitempty" koanf:"user_verification" split_words:"true" jsonschema:"default=preferred,enum=required,enum=preferred,enum=discouraged"` + RelyingParty RelyingParty `yaml:"relying_party" json:"relying_party,omitempty" koanf:"relying_party" split_words:"true" jsonschema:"title=relying_party"` + // Deprecated, use `timeouts` instead. + Timeout int `yaml:"timeout" json:"timeout,omitempty" koanf:"timeout" jsonschema:"default=60000"` + // `timeouts` specifies the timeouts for passkey/WebAuthn registration and login. + Timeouts WebauthnTimeouts `yaml:"timeouts" json:"timeouts,omitempty" koanf:"timeouts" split_words:"true" jsonschema:"title=timeouts"` + // Deprecated, use `passkey.user_verification` instead + UserVerification string `yaml:"user_verification" json:"user_verification,omitempty" koanf:"user_verification" split_words:"true" jsonschema:"default=preferred,enum=required,enum=preferred,enum=discouraged"` + Handler *webauthnLib.WebAuthn `jsonschema:"-"` +} + +func (r *WebauthnSettings) PostProcess() error { + requireResidentKey := false + + config := &webauthnLib.Config{ + RPID: r.RelyingParty.Id, + RPDisplayName: r.RelyingParty.DisplayName, + RPOrigins: r.RelyingParty.Origins, + AttestationPreference: protocol.PreferNoAttestation, + AuthenticatorSelection: protocol.AuthenticatorSelection{ + RequireResidentKey: &requireResidentKey, + ResidentKey: protocol.ResidentKeyRequirementDiscouraged, + UserVerification: protocol.VerificationRequired, + }, + Debug: false, + Timeouts: webauthnLib.TimeoutsConfig{ + Login: webauthnLib.TimeoutConfig{ + Enforce: true, + Timeout: time.Duration(r.Timeouts.Login) * time.Millisecond, + }, + Registration: webauthnLib.TimeoutConfig{ + Enforce: true, + Timeout: time.Duration(r.Timeouts.Registration) * time.Millisecond, + }, + }, + } + + handler, err := webauthnLib.New(config) + if err != nil { + return err + } + + r.Handler = handler + + return nil } // Validate does not need to validate the config, because the library does this already @@ -364,16 +408,27 @@ func (r *WebauthnSettings) Validate() error { // RelyingParty webauthn settings for your application using hanko. type RelyingParty struct { - Id string `yaml:"id" json:"id,omitempty" koanf:"id" jsonschema:"default=localhost"` - DisplayName string `yaml:"display_name" json:"display_name,omitempty" koanf:"display_name" split_words:"true" jsonschema:"default=Hanko Authentication Service"` - Icon string `yaml:"icon" json:"icon,omitempty" koanf:"icon"` - Origins []string `yaml:"origins" json:"origins,omitempty" koanf:"origins" jsonschema:"minItems=1,default=http://localhost:8888"` + // `display_name` is the service's name that some WebAuthn Authenticators will display to the user during registration + // and authentication ceremonies. + DisplayName string `yaml:"display_name" json:"display_name,omitempty" koanf:"display_name" split_words:"true" jsonschema:"default=Hanko Authentication Service"` + Icon string `yaml:"icon" json:"icon,omitempty" koanf:"icon" jsonschema:"-"` + // `id` is the [effective domain](https://html.spec.whatwg.org/multipage/browsers.html#concept-origin-effective-domain) + // the passkey/WebAuthn credentials will be bound to. + Id string `yaml:"id" json:"id,omitempty" koanf:"id" jsonschema:"default=localhost,examples=localhost,example.com,subdomain.example.com"` + // `origins` is a list of origins for which passkeys/WebAuthn credentials will be accepted by the server. Must + // include the protocol and can only be the effective domain, or a registrable domain suffix of the effective + // domain, as specified in the [`id`](#id). Except for `localhost`, the protocol **must** always be `https` for + // passkeys/WebAuthn to work. IP Addresses will not work. + // + // For an Android application the origin must be the base64 url encoded SHA256 fingerprint of the signing + // certificate. + Origins []string `yaml:"origins" json:"origins,omitempty" koanf:"origins" jsonschema:"minItems=1,default=http://localhost:8888,examples=android:apk-key-hash:nLSu7wVTbnMOxLgC52f2faTnvCbXQrUn_wF9aCrr-l0,https://login.example.com"` } // SMTP Server Settings for sending passcodes type SMTP struct { - Host string `yaml:"host" json:"host" koanf:"host"` - Port string `yaml:"port" json:"port,omitempty" koanf:"port" jsonschema:"default=465,oneof_type=string;integer"` + Host string `yaml:"host" json:"host,omitempty" koanf:"host"` + Port string `yaml:"port" json:"port,omitempty" koanf:"port" jsonschema:"default=465"` User string `yaml:"user" json:"user,omitempty" koanf:"user"` Password string `yaml:"password" json:"password,omitempty" koanf:"password"` } @@ -388,16 +443,14 @@ func (s *SMTP) Validate() error { return nil } -type EmailDelivery struct { - Enabled bool `yaml:"enabled" json:"enabled" koanf:"enabled" jsonschema:"default=true"` -} - -type Email struct { +type PasscodeEmail struct { + // Deprecated. Use `email_delivery.from_address` instead. FromAddress string `yaml:"from_address" json:"from_address,omitempty" koanf:"from_address" split_words:"true" jsonschema:"default=passcode@hanko.io"` - FromName string `yaml:"from_name" json:"from_name,omitempty" koanf:"from_name" split_words:"true" jsonschema:"default=Hanko"` + // Deprecated. Use `email_delivery.from_name` instead. + FromName string `yaml:"from_name" json:"from_name,omitempty" koanf:"from_name" split_words:"true" jsonschema:"default=Hanko"` } -func (e *Email) Validate() error { +func (e *PasscodeEmail) Validate() error { if len(strings.TrimSpace(e.FromAddress)) == 0 { return errors.New("from_address must not be empty") } @@ -405,9 +458,11 @@ func (e *Email) Validate() error { } type Passcode struct { - Email Email `yaml:"email" json:"email,omitempty" koanf:"email"` - TTL int `yaml:"ttl" json:"ttl,omitempty" koanf:"ttl" jsonschema:"default=300"` - //Deprecated: Use root level Smtp instead + // Deprecated. See child properties for suggested replacements. + Email PasscodeEmail `yaml:"email" json:"email,omitempty" koanf:"email"` + // Deprecated. Use `email.passcode_ttl` instead. + TTL int `yaml:"ttl" json:"ttl,omitempty" koanf:"ttl" jsonschema:"default=300"` + // Deprecated. Use `email_delivery.smtp` instead. Smtp SMTP `yaml:"smtp" json:"smtp,omitempty" koanf:"smtp,omitempty" required:"false" envconfig:"smtp,omitempty"` } @@ -419,15 +474,24 @@ func (p *Passcode) Validate() error { return nil } -// Database connection settings type Database struct { - Database string `yaml:"database" json:"database,omitempty" koanf:"database" jsonschema:"default=hanko" jsonschema:"oneof_required=config"` - User string `yaml:"user" json:"user,omitempty" koanf:"user" jsonschema:"oneof_required=config"` - Password string `yaml:"password" json:"password,omitempty" koanf:"password" jsonschema:"oneof_required=config"` - Host string `yaml:"host" json:"host,omitempty" koanf:"host" jsonschema:"oneof_required=config"` - Port string `yaml:"port" json:"port,omitempty" koanf:"port" jsonschema:"oneof_required=config,oneof_type=string;integer"` - Dialect string `yaml:"dialect" json:"dialect,omitempty" koanf:"dialect" jsonschema:"oneof_required=config,enum=postgres,enum=mysql,enum=cockroach"` - Url string `yaml:"url" json:"url,omitempty" koanf:"url" jsonschema:"oneof_required=url"` + // `database` determines the name of the database schema to use. + Database string `yaml:"database" json:"database,omitempty" koanf:"database" jsonschema:"default=hanko"` + // `dialect` is the name of the database system to use. + Dialect string `yaml:"dialect" json:"dialect,omitempty" koanf:"dialect" jsonschema:"default=postgres,enum=postgres,enum=mysql,enum=mariadb,enum=cockroach"` + // `host` is the host the database system is running on. + Host string `yaml:"host" json:"host,omitempty" koanf:"host" jsonschema:"default=localhost"` + // `password` is the password for the database user to use for connecting to the database. + Password string `yaml:"password" json:"password,omitempty" koanf:"password" jsonschema:"default=hanko"` + // `port` is the port the database system is running on. + Port string `yaml:"port" json:"port,omitempty" koanf:"port" jsonschema:"default=5432"` + // `url` is a datasource connection string. It can be used instead of the rest of the database configuration + // options. If this `url` is set then it is prioritized, i.e. the rest of the options, if set, have no effect. + // + // Schema: `dialect://username:password@host:port/database` + Url string `yaml:"url" json:"url,omitempty" koanf:"url" jsonschema:"example=postgres://hanko:hanko@localhost:5432/hanko"` + // `user` is the database user to use for connecting to the database. + User string `yaml:"user" json:"user,omitempty" koanf:"user" jsonschema:"default=hanko"` } func (d *Database) Validate() error { @@ -453,7 +517,7 @@ func (d *Database) Validate() error { } type Secrets struct { - // Keys secrets are used to en- and decrypt the JWKs which get used to sign the JWTs. + // `keys` are used to en- and decrypt the JWKs which get used to sign the JWTs issued by the API. // For every key a JWK is generated, encrypted with the key and persisted in the database. // // You can use this list for key rotation: add a new key to the beginning of the list and the corresponding @@ -461,9 +525,17 @@ type Secrets struct { // be valid until they expire. Removing a key from the list does not remove the corresponding // database record. If you remove a key, you also have to remove the database record, otherwise // application startup will fail. - // - // Each key must be at least 16 characters long. - Keys []string `yaml:"keys" json:"keys" koanf:"keys" jsonschema:"minItems=1"` + Keys []string `yaml:"keys" json:"keys,omitempty" koanf:"keys" jsonschema:"minItems=1"` +} + +func (Secrets) JSONSchemaExtend(schema *jsonschema.Schema) { + keys, _ := schema.Properties.Get("keys") + var keysItemsMinLength uint64 = 16 + keys.Items = &jsonschema.Schema{ + Type: "string", + Title: "keys", + MinLength: &keysItemsMinLength, + } } func (s *Secrets) Validate() error { @@ -474,17 +546,23 @@ func (s *Secrets) Validate() error { } type Session struct { + // `audience` is a list of strings that identifies the recipients that the JWT is intended for. + // The audiences are placed in the `aud` claim of the JWT. + // If not set, it defaults to the value of the`webauthn.relying_party.id` configuration parameter. + Audience []string `yaml:"audience" json:"audience,omitempty" koanf:"audience"` + // `cookie` contains configuration for the session cookie issued on successful registration or login. + Cookie Cookie `yaml:"cookie" json:"cookie,omitempty" koanf:"cookie"` + // `enable_auth_token_header` determines whether a session token (JWT) is returned in an `X-Auth-Token` + // header after a successful authentication. This option should be set to `true` if API and client applications + // run on different domains. EnableAuthTokenHeader bool `yaml:"enable_auth_token_header" json:"enable_auth_token_header,omitempty" koanf:"enable_auth_token_header" split_words:"true" jsonschema:"default=false"` - // Lifespan, possibly signed sequence of decimal numbers, each with optional fraction and a unit suffix, - // such as "300ms", "-1.5h" or "2h45m". Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h". - Lifespan string `yaml:"lifespan" json:"lifespan,omitempty" koanf:"lifespan" jsonschema:"default=1h"` - Cookie Cookie `yaml:"cookie" json:"cookie,omitempty" koanf:"cookie"` - - // Issuer optional string to be used in the jwt iss claim. + // `issuer` is a string that identifies the principal (human user, an organization, or a service) + // that issued the JWT. Its value is set in the `iss` claim of a JWT. Issuer string `yaml:"issuer" json:"issuer,omitempty" koanf:"issuer"` - - // Audience optional []string containing strings which get put into the aud claim. If not set default to Webauthn.RelyingParty.Id config parameter. - Audience []string `yaml:"audience" json:"audience,omitempty" koanf:"audience"` + // `lifespan` determines how long a session token (JWT) is valid. It must be a (possibly signed) sequence of decimal + // numbers, each with optional fraction and a unit suffix, such as "300ms", "-1.5h" or "2h45m". + // Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h". + Lifespan string `yaml:"lifespan" json:"lifespan,omitempty" koanf:"lifespan" jsonschema:"default=1h"` } func (s *Session) Validate() error { @@ -496,22 +574,33 @@ func (s *Session) Validate() error { } type AuditLog struct { - ConsoleOutput AuditLogConsole `yaml:"console_output" json:"console_output,omitempty" koanf:"console_output" split_words:"true"` - Storage AuditLogStorage `yaml:"storage" json:"storage,omitempty" koanf:"storage"` + // `console_output` controls audit log console output. + ConsoleOutput AuditLogConsole `yaml:"console_output" json:"console_output,omitempty" koanf:"console_output" split_words:"true" jsonschema:"title=console_output"` + // `mask` determines whether sensitive information (usernames, emails) should be masked in the audit log output. + // + // This configuration applies to logs written to the console as well as persisted logs. + Mask bool `yaml:"mask" json:"mask,omitempty" koanf:"mask" jsonschema:"default=true"` + // `storage` controls audit log retention. + Storage AuditLogStorage `yaml:"storage" json:"storage,omitempty" koanf:"storage"` } type AuditLogStorage struct { + // `enabled` controls whether audit log should be retained (i.e. persisted). Enabled bool `yaml:"enabled" json:"enabled,omitempty" koanf:"enabled" jsonschema:"default=false"` } type AuditLogConsole struct { - Enabled bool `yaml:"enabled" json:"enabled,omitempty" koanf:"enabled" jsonschema:"default=true"` + // `enabled` controls whether audit log output on the console is enabled or disabled. + Enabled bool `yaml:"enabled" json:"enabled,omitempty" koanf:"enabled" jsonschema:"default=true"` + // `output` determines the output stream audit logs are sent to. OutputStream OutputStream `yaml:"output" json:"output,omitempty" koanf:"output" split_words:"true" jsonschema:"default=stdout,enum=stdout,enum=stderr"` } type Emails struct { + // Deprecated. Use `email.require_verification` instead. RequireVerification bool `yaml:"require_verification" json:"require_verification,omitempty" koanf:"require_verification" split_words:"true" jsonschema:"default=true"` - MaxNumOfAddresses int `yaml:"max_num_of_addresses" json:"max_num_of_addresses,omitempty" koanf:"max_num_of_addresses" split_words:"true" jsonschema:"default=5"` + // Deprecated. Use `email.limit` instead. + MaxNumOfAddresses int `yaml:"max_num_of_addresses" json:"max_num_of_addresses,omitempty" koanf:"max_num_of_addresses" split_words:"true" jsonschema:"default=5"` } type OutputStream string @@ -522,17 +611,30 @@ var ( ) type RateLimiter struct { - Enabled bool `yaml:"enabled" json:"enabled,omitempty" koanf:"enabled" jsonschema:"default=true"` - Store RateLimiterStoreType `yaml:"store" json:"store,omitempty" koanf:"store" jsonschema:"default=in_memory,enum=in_memory,enum=redis"` - Redis *RedisConfig `yaml:"redis_config" json:"redis_config,omitempty" koanf:"redis_config"` - PasscodeLimits RateLimits `yaml:"passcode_limits" json:"passcode_limits,omitempty" koanf:"passcode_limits" split_words:"true"` - PasswordLimits RateLimits `yaml:"password_limits" json:"password_limits,omitempty" koanf:"password_limits" split_words:"true"` - TokenLimits RateLimits `yaml:"token_limits" json:"token_limits,omitempty" koanf:"token_limits" split_words:"true"` + // `enabled` controls whether rate limiting is enabled or disabled. + Enabled bool `yaml:"enabled" json:"enabled,omitempty" koanf:"enabled" jsonschema:"default=true"` + // `store` sets the store for the rate limiter. When you have multiple instances of Hanko running, it is recommended to use + // the `redis` store because otherwise your instances each have their own states. + Store RateLimiterStoreType `yaml:"store" json:"store,omitempty" koanf:"store" jsonschema:"default=in_memory,enum=in_memory,enum=redis"` + // `redis_config` configures connection to a redis instance. + // Required if `store` is set to `redis` + Redis *RedisConfig `yaml:"redis_config" json:"redis_config,omitempty" koanf:"redis_config"` + // `passcode_limits` controls rate limits for passcode operations. + PasscodeLimits RateLimits `yaml:"passcode_limits" json:"passcode_limits,omitempty" koanf:"passcode_limits" split_words:"true"` + // `password_limits` controls rate limits for password login operations. + PasswordLimits RateLimits `yaml:"password_limits" json:"password_limits,omitempty" koanf:"password_limits" split_words:"true"` + // `token_limits` controls rate limits for token exchange operations. + TokenLimits RateLimits `yaml:"token_limits" json:"token_limits,omitempty" koanf:"token_limits" split_words:"true" jsonschema:"default=token=3;interval=1m"` } type RateLimits struct { - Tokens uint64 `yaml:"tokens" json:"tokens" koanf:"tokens"` - Interval time.Duration `yaml:"interval" json:"interval" koanf:"interval"` + // `tokens` determines how many operations/requests can occur in the given `interval`. + Tokens uint64 `yaml:"tokens" json:"tokens" koanf:"tokens" jsonschema:"default=3"` + // `interval` determines when to reset the token interval. + // It must be a (possibly signed) sequence of decimal + // numbers, each with optional fraction and a unit suffix, such as "300ms", "-1.5h" or "2h45m". + // Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h". + Interval time.Duration `yaml:"interval" json:"interval" koanf:"interval" jsonschema:"default=1m,type=string"` } type RateLimiterStoreType string @@ -562,15 +664,51 @@ func (r *RateLimiter) Validate() error { } type RedisConfig struct { - // Address of redis in the form of host[:port][/database] - Address string `yaml:"address" json:"address" koanf:"address"` + // `address` is the address of the redis instance in the form of `host[:port][/database]`. + Address string `yaml:"address" json:"address" koanf:"address"` + // `password` is the password for the redis instance. Password string `yaml:"password" json:"password,omitempty" koanf:"password"` } type ThirdParty struct { - Providers ThirdPartyProviders `yaml:"providers" json:"providers,omitempty" koanf:"providers"` - RedirectURL string `yaml:"redirect_url" json:"redirect_url,omitempty" koanf:"redirect_url" split_words:"true"` - ErrorRedirectURL string `yaml:"error_redirect_url" json:"error_redirect_url,omitempty" koanf:"error_redirect_url" split_words:"true"` + // `providers` contains the configurations for the available OAuth/OIDC identity providers. + Providers ThirdPartyProviders `yaml:"providers" json:"providers,omitempty" koanf:"providers" jsonschema:"title=providers,uniqueItems=true"` + // `redirect_url` is the URL the third party provider redirects to with an authorization code. Must consist of the base URL + // of your running Hanko backend instance and the `callback` endpoint of the API, + // i.e. `{YOUR_BACKEND_INSTANCE}/thirdparty/callback.` + // + // Required if any of the [`providers`](#providers) are `enabled`. + RedirectURL string `yaml:"redirect_url" json:"redirect_url,omitempty" koanf:"redirect_url" split_words:"true" jsonschema:"example=https://yourinstance.com/thirdparty/callback"` + // `error_redirect_url` is the URL the backend redirects to if an error occurs during third party sign-in. + // Errors are provided as 'error' and 'error_description' query params in the redirect location URL. + // + // When using the Hanko web components it should be the URL of the page that embeds the web component such that + // errors can be processed properly by the web component. + // + // You do not have to add this URL to the 'allowed_redirect_urls', it is automatically included when validating + // redirect URLs. + // + // Required if any of the [`providers`](#providers) are `enabled`. Must not have trailing slash. + ErrorRedirectURL string `yaml:"error_redirect_url" json:"error_redirect_url,omitempty" koanf:"error_redirect_url" split_words:"true"` + // `default_redirect_url` is the URL the backend redirects to after it successfully verified + // the response from any third party provider. + // + // Must not have trailing slash. + DefaultRedirectURL string `yaml:"default_redirect_url" json:"default_redirect_url,omitempty" koanf:"default_redirect_url" split_words:"true"` + // `allowed_redirect_urls` is a list of URLs the backend is allowed to redirect to after third party sign-in was + // successful. + // + // Supports wildcard matching through globbing. e.g. `https://*.example.com` will allow `https://foo.example.com` + // and `https://bar.example.com` to be accepted. + // + // Globbing is also supported for paths, e.g. `https://foo.example.com/*` will match `https://foo.example.com/page1` + // and `https://foo.example.com/page2`. + // + // A double asterisk (`**`) acts as a "super"-wildcard/match-all. + // + // See [here](https://pkg.go.dev/github.com/gobwas/glob#Compile) for more on globbing. + // + // Must not be empty if any of the [`providers`](#providers) are `enabled`. URLs in the list must not have a trailing slash. AllowedRedirectURLS []string `yaml:"allowed_redirect_urls" json:"allowed_redirect_urls,omitempty" koanf:"allowed_redirect_urls" split_words:"true"` AllowedRedirectURLMap map[string]glob.Glob `jsonschema:"-"` } @@ -590,6 +728,9 @@ func (t *ThirdParty) Validate() error { } urls := append(t.AllowedRedirectURLS, t.ErrorRedirectURL) + if t.DefaultRedirectURL != "" { + urls = append(urls, t.DefaultRedirectURL) + } for _, u := range urls { if strings.HasSuffix(u, "/") { return fmt.Errorf("redirect url %s must not have trailing slash", u) @@ -620,10 +761,37 @@ func (t *ThirdParty) PostProcess() error { } type ThirdPartyProvider struct { - Enabled bool `yaml:"enabled" json:"enabled" koanf:"enabled"` - ClientID string `yaml:"client_id" json:"client_id" koanf:"client_id" split_words:"true"` - Secret string `yaml:"secret" json:"secret" koanf:"secret"` - AllowLinking bool `yaml:"allow_linking" json:"allow_linking" koanf:"allow_linking" split_words:"true"` + // `allow_linking` indicates whether existing accounts can be automatically linked with this provider. + // + // Linking is based on matching one of the email addresses of an existing user account with the (primary) + // email address of the third party provider account. + AllowLinking bool `yaml:"allow_linking" json:"allow_linking,omitempty" koanf:"allow_linking" split_words:"true"` + // `client_id` is the ID of the OAuth/OIDC client. Must be obtained from the provider. + // + // Required if the provider is `enabled`. + ClientID string `yaml:"client_id" json:"client_id,omitempty" koanf:"client_id" split_words:"true"` + DisplayName string `jsonschema:"-" yaml:"-" json:"-" koanf:"-"` + // `enabled` determines whether this provider is enabled. + Enabled bool `yaml:"enabled" json:"enabled,omitempty" koanf:"enabled" jsonschema:"default=false"` + // `secret` is the client secret for the OAuth/OIDC client. Must be obtained from the provider. + // + // Required if the provider is `enabled`. + Secret string `yaml:"secret" json:"secret,omitempty" koanf:"secret"` +} + +func (ThirdPartyProvider) JSONSchemaExtend(schema *jsonschema.Schema) { + schema.Title = "provider" + + enabledTrue := &jsonschema.Schema{Properties: orderedmap.New[string, *jsonschema.Schema]()} + enabledTrue.Properties.Set("enabled", &jsonschema.Schema{Const: true}) + + schema.If = enabledTrue + schema.Then = &jsonschema.Schema{ + Required: []string{"client_id", "secret"}, + } + schema.Else = &jsonschema.Schema{ + Required: []string{"enabled"}, + } } func (p *ThirdPartyProvider) Validate() error { @@ -639,12 +807,18 @@ func (p *ThirdPartyProvider) Validate() error { } type ThirdPartyProviders struct { - Google ThirdPartyProvider `yaml:"google" json:"google,omitempty" koanf:"google"` - GitHub ThirdPartyProvider `yaml:"github" json:"github,omitempty" koanf:"github"` - Apple ThirdPartyProvider `yaml:"apple" json:"apple,omitempty" koanf:"apple"` - Discord ThirdPartyProvider `yaml:"discord" json:"discord,omitempty" koanf:"discord"` + // `apple` contains the provider configuration for Apple. + Apple ThirdPartyProvider `yaml:"apple" json:"apple,omitempty" koanf:"apple"` + // `discord` contains the provider configuration for Discord. + Discord ThirdPartyProvider `yaml:"discord" json:"discord,omitempty" koanf:"discord"` + // `github` contains the provider configuration for GitHub. + GitHub ThirdPartyProvider `yaml:"github" json:"github,omitempty" koanf:"github"` + // `google` contains the provider configuration for Google. + Google ThirdPartyProvider `yaml:"google" json:"google,omitempty" koanf:"google"` + // `linkedin` contains the provider configuration for LinkedIn. + LinkedIn ThirdPartyProvider `yaml:"linkedin" json:"linkedin,omitempty" koanf:"linkedin"` + // `microsoft` contains the provider configuration for Microsoft. Microsoft ThirdPartyProvider `yaml:"microsoft" json:"microsoft,omitempty" koanf:"microsoft"` - LinkedIn ThirdPartyProvider `yaml:"linkedin" json:"linkedin,omitempty" koanf:"linkedin"` } func (p *ThirdPartyProviders) Validate() error { @@ -671,6 +845,19 @@ func (p *ThirdPartyProviders) HasEnabled() bool { return false } +func (p *ThirdPartyProviders) GetEnabled() []ThirdPartyProvider { + s := structs.New(p) + var enabledProviders []ThirdPartyProvider + for _, field := range s.Fields() { + provider := field.Value().(ThirdPartyProvider) + if provider.Enabled { + enabledProviders = append(enabledProviders, provider) + } + } + + return enabledProviders +} + func (p *ThirdPartyProviders) Get(provider string) *ThirdPartyProvider { s := structs.New(p) for _, field := range s.Fields() { @@ -683,19 +870,46 @@ func (p *ThirdPartyProviders) Get(provider string) *ThirdPartyProvider { return nil } +func (c *Config) convertLegacyConfig() { + c.Email.Limit = c.Emails.MaxNumOfAddresses + c.Email.RequireVerification = c.Emails.RequireVerification + c.Email.PasscodeTtl = c.Passcode.TTL + + c.EmailDelivery.SMTP = c.Smtp + c.EmailDelivery.FromName = c.Passcode.Email.FromName + c.EmailDelivery.FromAddress = c.Passcode.Email.FromAddress + + c.Password.MinLength = c.Password.MinPasswordLength + + c.Passkey.UserVerification = c.Webauthn.UserVerification + + c.Webauthn.Timeouts.Login = c.Webauthn.Timeout + c.Webauthn.Timeouts.Registration = c.Webauthn.Timeout +} + func (c *Config) PostProcess() error { + c.arrangeSmtpSettings() + + if c.ConvertLegacyConfig { + c.convertLegacyConfig() + } + err := c.ThirdParty.PostProcess() if err != nil { return fmt.Errorf("failed to post process third party settings: %w", err) } + err = c.Webauthn.PostProcess() + if err != nil { + return fmt.Errorf("failed to post process webauthn settings: %w", err) + } + err = c.Saml.PostProcess() if err != nil { return fmt.Errorf("failed to post process saml settings: %w", err) } return nil - } func (c *Config) arrangeSmtpSettings() { @@ -713,11 +927,144 @@ func (c *Config) arrangeSmtpSettings() { } type LoggerConfig struct { + // `log_health_and_metrics` determines whether requests of the `/health` and `/metrics` endpoints are logged. LogHealthAndMetrics bool `yaml:"log_health_and_metrics,omitempty" json:"log_health_and_metrics" koanf:"log_health_and_metrics" jsonschema:"default=true"` } type Account struct { - // Allow Deletion indicates if a user can perform self-service deletion + // `allow_deletion` determines whether users can delete their accounts. AllowDeletion bool `yaml:"allow_deletion" json:"allow_deletion,omitempty" koanf:"allow_deletion" jsonschema:"default=false"` - AllowSignup bool `yaml:"allow_signup" json:"allow_signup,omitempty" koanf:"allow_signup" jsonschema:"default=true"` + // `allow_signup` determines whether users are able to create new accounts. + AllowSignup bool `yaml:"allow_signup" json:"allow_signup,omitempty" koanf:"allow_signup" jsonschema:"default=true"` +} + +type Passkey struct { + // `acquire_on_registration` configures how users are prompted creating a passkey on registration. + AcquireOnRegistration string `yaml:"acquire_on_registration" json:"acquire_on_registration,omitempty" koanf:"acquire_on_registration" split_words:"true" jsonschema:"default=always,enum=always,enum=conditional,enum=never"` + // `acquire_on_login` configures how users are prompted creating a passkey on login. + AcquireOnLogin string `yaml:"acquire_on_login" json:"acquire_on_login,omitempty" koanf:"acquire_on_login" split_words:"true" jsonschema:"default=always,enum=always,enum=conditional,enum=never"` + // `attestation_preference` is used to specify the preference regarding attestation conveyance during + // credential generation. + AttestationPreference string `yaml:"attestation_preference" json:"attestation_preference,omitempty" koanf:"attestation_preference" split_words:"true" jsonschema:"default=direct,enum=direct,enum=indirect,enum=none"` + // `enabled` determines whether users can create or authenticate with passkeys. + Enabled bool `yaml:"enabled" json:"enabled,omitempty" koanf:"enabled" jsonschema:"default=true"` + // `limit` defines the maximum number of passkeys a user can have. + Limit int `yaml:"limit" json:"limit,omitempty" koanf:"limit" jsonschema:"default=100"` + // `optional` determines whether users must create a passkey when prompted. The last remaining passkey cannot be + // deleted if passkeys are required (`optional: false`). + // + // It also takes part in determining the order of password and passkey acquisition + // on login and registration (see also `acquire_on_login` and `acquire_on_registration`): if one credential type is + // required (`optional: false`) then that one takes precedence, i.e. is acquired first. + Optional bool `yaml:"optional" json:"optional,omitempty" koanf:"optional" jsonschema:"default=true"` + // `user_verification` specifies the requirements regarding local authorization with an authenticator through + // various authorization gesture modalities; for example, through a touch plus pin code, + // password entry, or biometric recognition. + // + // The setting applies to both WebAuthn registration and authentication ceremonies. + UserVerification string `yaml:"user_verification" json:"user_verification,omitempty" koanf:"user_verification" split_words:"true" jsonschema:"default=preferred,enum=required,enum=preferred,enum=discouraged"` +} + +func (Passkey) JSONSchemaExtend(schema *jsonschema.Schema) { + acquireOnRegistration, _ := schema.Properties.Get("acquire_on_registration") + acquireOnRegistration.Extras = map[string]any{"meta:enum": map[string]string{ + "always": "Indicates that users are always prompted to create a passkey on registration.", + "conditional": `Indicates that users are prompted to create a passkey on registration as long as the user does + not have a password. + + If passwords are also conditionally acquired on registration, then users are given a choice as + to what type of credential to create.`, + "never": "Indicates that users are never prompted to create a passkey on registration.", + }} + + acquireOnLogin, _ := schema.Properties.Get("acquire_on_login") + acquireOnLogin.Extras = map[string]any{"meta:enum": map[string]string{ + "always": `Indicates that users are always prompted to create a passkey on login + provided that they do not already have a passkey.`, + "conditional": `Indicates that users are prompted to create a passkey on login provided that + they do not already have a passkey and do not have a password. + + If passkeys are also conditionally acquired on login then users are given a choice as to what + type of credential to register.`, + "never": "Indicates that users are never prompted to create a passkey on login.", + }} + + userVerification, _ := schema.Properties.Get("user_verification") + userVerification.Extras = map[string]any{"meta:enum": map[string]string{ + "required": "Indicates that user verification is always required.", + "preferred": `Indicates that user verification is preferred but will not fail the operation if no + user verification was performed.`, + "discouraged": "Indicates that no user verification should be performed.", + }} + + attestationPreference, _ := schema.Properties.Get("attestation_preference") + attestationPreference.Extras = map[string]any{"meta:enum": map[string]string{ + "direct": `Indicates that the Relying Party wants to receive the attestation statement as generated by + the authenticator.`, + "indirect": `Indicates that the Relying Party prefers an attestation conveyance yielding verifiable + attestation statements, but allows the client to decide how to obtain such attestation statements.`, + "none": `Indicates that the Relying Party is not interested in authenticator attestation.`, + }} + +} + +type EmailDelivery struct { + // `enabled` determines whether the API delivers emails. + // Disable if you want to send the emails yourself. To do so you must subscribe to the `email.create` webhook event. + Enabled bool `yaml:"enabled" json:"enabled,omitempty" koanf:"enabled" jsonschema:"default=true"` + // `from_address` configures the sender address of emails sent to users. + FromAddress string `yaml:"from_address" json:"from_address,omitempty" koanf:"from_address" split_words:"true" jsonschema:"default=noreply@hanko.io"` + // `from_name` configures the sender name of emails sent to users. + FromName string `yaml:"from_name" json:"from_name,omitempty" koanf:"from_name" split_words:"true" jsonschema:"default=Hanko"` + // `SMTP` contains the SMTP server settings for sending mails. + SMTP SMTP `yaml:"smtp" json:"smtp,omitempty" koanf:"smtp" jsonschema:"title=smtp"` +} + +type Email struct { + // `acquire_on_login` determines whether users, provided that they do not already have registered an email, + // are prompted to provide an email on login. + AcquireOnLogin bool `yaml:"acquire_on_login" json:"acquire_on_login,omitempty" koanf:"acquire_on_login" split_words:"true" jsonschema:"default=false"` + // `acquire_on_registration` determines whether users are prompted to provide an email on registration. + AcquireOnRegistration bool `yaml:"acquire_on_registration" json:"acquire_on_registration,omitempty" koanf:"acquire_on_registration" split_words:"true" jsonschema:"default=true"` + // `enabled` determines whether emails are enabled. + Enabled bool `yaml:"enabled" json:"enabled,omitempty" koanf:"enabled" jsonschema:"default=true"` + // 'limit' determines the maximum number of emails a user can register. + Limit int `yaml:"limit" json:"limit,omitempty" koanf:"limit" jsonschema:"default=100"` + // `max_length` specifies the maximum allowed length of an email address. + MaxLength int `yaml:"max_length" json:"max_length,omitempty" koanf:"max_length" jsonschema:"default=100"` + // `optional` determines whether users must provide an email when prompted. + // There must always be at least one email address associated with an account. The primary email address cannot be + // deleted if emails are required (`optional`: false`). + Optional bool `yaml:"optional" json:"optional,omitempty" koanf:"optional" jsonschema:"default=true"` + // `passcode_ttl` specifies, in seconds, how long a passcode is valid for. + PasscodeTtl int `yaml:"passcode_ttl" json:"passcode_ttl,omitempty" koanf:"passcode_ttl" jsonschema:"default=300"` + // `require_verification` determines whether newly created emails must be verified by providing a passcode sent + // to respective address. + RequireVerification bool `yaml:"require_verification" json:"require_verification,omitempty" koanf:"require_verification" split_words:"true" jsonschema:"default=true"` + // `use_as_login_identifier` determines whether emails can be used as an identifier on login. + UseAsLoginIdentifier bool `yaml:"use_as_login_identifier" json:"use_as_login_identifier,omitempty" koanf:"use_as_login_identifier" jsonschema:"default=true"` + // `user_for_authentication` determines whether users can log in by providing an email address and subsequently + // providing a passcode sent to the given email address. + UseForAuthentication bool `yaml:"use_for_authentication" json:"use_for_authentication,omitempty" koanf:"use_for_authentication" jsonschema:"default=true"` +} + +type Username struct { + // `acquire_on_login` determines whether users, provided that they do not already have set a username, + // are prompted to provide a username on login. + AcquireOnLogin bool `yaml:"acquire_on_login" json:"acquire_on_login,omitempty" koanf:"acquire_on_login" split_words:"true" jsonschema:"default=false"` + // `acquire_on_registration` determines whether users are prompted to provide a username on registration. + AcquireOnRegistration bool `yaml:"acquire_on_registration" json:"acquire_on_registration,omitempty" koanf:"acquire_on_registration" split_words:"true" jsonschema:"default=false"` + // `enabled` determines whether users can set a unique username. + // + // Usernames can contain letters (a-z,A-Z), numbers (0-9), and underscores. + Enabled bool `yaml:"enabled" json:"enabled,omitempty" koanf:"enabled" jsonschema:"default=true"` + // `max_length` specifies the maximum allowed length of a username. + MaxLength int `yaml:"max_length" json:"max_length,omitempty" koanf:"max_length" jsonschema:"default=100"` + // `min_length` specifies the minimum length of a username. + MinLength int `yaml:"min_length" json:"min_length,omitempty" koanf:"min_length" split_words:"true" jsonschema:"default=8"` + // `optional` determines whether users must provide a username when prompted. The username can only be changed but + // not deleted if usernames are required (`optional: false`). + Optional bool `yaml:"optional" json:"optional,omitempty" koanf:"optional" jsonschema:"default=true"` + // `use_as_login_identifier` determines whether usernames, if enabled, can be used for logging in. + UseAsLoginIdentifier bool `yaml:"use_as_login_identifier" json:"use_as_login_identifier,omitempty" koanf:"use_as_login_identifier" jsonschema:"default=true"` } diff --git a/backend/config/config.yaml b/backend/config/config.yaml index 9098b7d93..f5bb819d5 100644 --- a/backend/config/config.yaml +++ b/backend/config/config.yaml @@ -2,12 +2,17 @@ database: user: hanko password: hanko host: localhost - port: 5432 + port: "5432" dialect: postgres smtp: - host: smtp.example.com - user: example - password: example + host: localhost + port: 2500 +server: + public: + cors: + allow_origins: + - http://localhost:63342 + - http://localhost:8000/ passcode: email: from_address: no-reply@hanko.io @@ -16,3 +21,48 @@ secrets: - abcedfghijklmnopqrstuvwxyz service: name: Hanko Authentication Service +webauthn: + relying_party: + id: localhost + origins: + - http://localhost:63342 + - http://localhost:8000 +password: + enabled: true + optional: false + acquire_on_registration: conditional + acquire_on_login: conditional + recovery: true +passkey: + enabled: true + optional: false + acquire_on_registration: conditional + acquire_on_login: conditional + attestation_preference: direct + limit: 100 +email: + enabled: true + optional: false + acquire_on_registration: true + acquire_on_login: true + require_verification: true + limit: 100 + use_as_login_identifier: true + max_length: 100 + use_for_authentication: true + passcode_ttl: 300 +username: + enabled: true + optional: true + acquire_on_registration: true + acquire_on_login: true + use_as_login_identifier: true + max_length: 100 +rate_limiter: + enabled: false +email_delivery: + from_address: no-reply@hanko.io + smtp: + host: localhost + port: 2500 +convert_legacy_config: false diff --git a/backend/config/config_default.go b/backend/config/config_default.go new file mode 100644 index 000000000..9c587ee0c --- /dev/null +++ b/backend/config/config_default.go @@ -0,0 +1,169 @@ +package config + +import "time" + +func DefaultConfig() *Config { + return &Config{ + ConvertLegacyConfig: false, + Service: Service{ + Name: "Hanko Authentication Service", + }, + Secrets: Secrets{ + Keys: []string{"abcedfghijklmnopqrstuvwxyz"}, + }, + Server: Server{ + Public: ServerSettings{ + Address: ":8000", + Cors: Cors{ + AllowOrigins: []string{"http://localhost:8888"}, + UnsafeWildcardOriginAllowed: false, + }, + }, + Admin: ServerSettings{ + Address: ":8001", + }, + }, + Webauthn: WebauthnSettings{ + RelyingParty: RelyingParty{ + Id: "localhost", + DisplayName: "Hanko Authentication Service", + Origins: []string{"http://localhost:8888"}, + }, + UserVerification: "preferred", + Timeout: 60000, + Timeouts: WebauthnTimeouts{ + Registration: 60000, + Login: 60000, + }, + }, + Smtp: SMTP{ + Port: "465", + }, + EmailDelivery: EmailDelivery{ + Enabled: true, + SMTP: SMTP{ + Host: "localhost", + Port: "465", + }, + FromAddress: "noreply@hanko.io", + FromName: "Hanko", + }, + Passcode: Passcode{ + TTL: 300, + Email: PasscodeEmail{ + FromAddress: "passcode@hanko.io", + FromName: "Hanko", + }, + Smtp: SMTP{ + Host: "localhost", + Port: "465", + }, + }, + Password: Password{ + Enabled: false, + Optional: false, + AcquireOnRegistration: "always", + AcquireOnLogin: "never", + Recovery: true, + MinLength: 8, + }, + Database: Database{ + Database: "hanko", + User: "hanko", + Password: "hanko", + Port: "5432", + Dialect: "postgres", + Host: "localhost", + }, + Session: Session{ + Lifespan: "1h", + Cookie: Cookie{ + HttpOnly: true, + SameSite: "strict", + Secure: true, + }, + }, + AuditLog: AuditLog{ + ConsoleOutput: AuditLogConsole{ + Enabled: true, + OutputStream: OutputStreamStdOut, + }, + Mask: true, + }, + Emails: Emails{ + RequireVerification: true, + MaxNumOfAddresses: 5, + }, + RateLimiter: RateLimiter{ + Enabled: true, + Store: RATE_LIMITER_STORE_IN_MEMORY, + PasswordLimits: RateLimits{ + Tokens: 5, + Interval: 1 * time.Minute, + }, + PasscodeLimits: RateLimits{ + Tokens: 3, + Interval: 1 * time.Minute, + }, + TokenLimits: RateLimits{ + Tokens: 3, + Interval: 1 * time.Minute, + }, + }, + Account: Account{ + AllowDeletion: false, + AllowSignup: true, + }, + ThirdParty: ThirdParty{ + Providers: ThirdPartyProviders{ + Google: ThirdPartyProvider{ + DisplayName: "Google", + AllowLinking: true, + }, + GitHub: ThirdPartyProvider{ + DisplayName: "GitHub", + AllowLinking: true, + }, + Apple: ThirdPartyProvider{ + DisplayName: "Apple", + AllowLinking: true, + }, + Discord: ThirdPartyProvider{ + DisplayName: "Discord", + AllowLinking: true, + }, + }, + }, + Passkey: Passkey{ + Enabled: true, + Optional: true, + AcquireOnRegistration: "always", + AcquireOnLogin: "always", + UserVerification: "preferred", + AttestationPreference: "direct", + Limit: 100, + }, + Email: Email{ + Enabled: true, + Optional: false, + AcquireOnRegistration: true, + AcquireOnLogin: true, + RequireVerification: true, + Limit: 5, + UseAsLoginIdentifier: true, + MaxLength: 120, + UseForAuthentication: true, + PasscodeTtl: 300, + }, + Username: Username{ + Enabled: false, + Optional: true, + AcquireOnRegistration: true, + AcquireOnLogin: true, + UseAsLoginIdentifier: false, + MinLength: 3, + MaxLength: 40, + }, + Debug: false, + } +} diff --git a/backend/config/webhook_config.go b/backend/config/webhook_config.go index b2621c0a8..2af078712 100644 --- a/backend/config/webhook_config.go +++ b/backend/config/webhook_config.go @@ -3,15 +3,24 @@ package config import ( "encoding/json" "fmt" + "github.com/invopop/jsonschema" "github.com/teamhanko/hanko/backend/webhooks/events" "net/url" "strings" ) type WebhookSettings struct { - Enabled bool `yaml:"enabled" json:"enabled,omitempty" koanf:"enabled" jsonschema:"default=false"` - AllowTimeExpiration bool `yaml:"allow_time_expiration" json:"allow_time_expiration,omitempty" koanf:"allow_time_expiration" jsonschema:"default=false"` - Hooks Webhooks `yaml:"hooks" json:"hooks,omitempty" koanf:"hooks"` + // `allow_time_expiration` determines whether webhooks are disabled when unused for 30 days + // (only for database webhooks). + AllowTimeExpiration bool `yaml:"allow_time_expiration" json:"allow_time_expiration,omitempty" koanf:"allow_time_expiration" jsonschema:"default=false"` + // `enabled` enables the webhook feature. + Enabled bool `yaml:"enabled" json:"enabled,omitempty" koanf:"enabled" jsonschema:"default=false"` + // `hooks` is a list of Webhook configurations. + // + // When using environment variables the value for the `WEBHOOKS_HOOKS` key must be specified in the following + // format: + // `{"callback":"http://app.com/usercb","events":["user"]};{"callback":"http://app.com/emailcb","events":["email.send"]}` + Hooks Webhooks `yaml:"hooks" json:"hooks,omitempty" koanf:"hooks" jsonschema:"title=hooks"` } func (ws *WebhookSettings) Validate() error { @@ -49,8 +58,45 @@ func (wd *Webhooks) Decode(value string) error { } type Webhook struct { - Callback string `yaml:"callback" json:"callback,omitempty" koanf:"callback"` - Events events.Events `yaml:"events" json:"events,omitempty" koanf:"events"` + // `callback` specifies the URL to which the change data will be sent. + Callback string `yaml:"callback" json:"callback,omitempty" koanf:"callback"` + // `events` is a list of events this hook listens for. + Events events.Events `yaml:"events" json:"events,omitempty" koanf:"events" jsonschema:"title=events"` +} + +func (Webhook) JSONSchemaExtend(schema *jsonschema.Schema) { + schema.Title = "hooks" + evts, _ := schema.Properties.Get("events") + + // If the jsonschema.Reflector is configured with the DoNotReference option set to true, then the items property + // in the schema is nil, hence we simply create a jsonschema.Schema manually, otherwise we'd get a nil pointer + // exception. + if evts.Items == nil { + evts.Items = &jsonschema.Schema{Type: "string"} + } + evts.Items.Title = "events" + evts.Items.Enum = []any{ + "user", + "user.create", + "user.delete", + "user.update", + "user.update.email", + "user.update.email.create", + "user.update.email.delete", + "user.update.email.primary", + "email.send", + } + evts.Items.Extras = map[string]any{"meta:enum": map[string]string{ + "user": "Triggers on: user creation, user deletion, user update, email creation, email deletion, change of primary email", + "user.create": "Triggers on: user creation", + "user.delete": "Triggers on: user deletion", + "user.update": "Triggers on: user update, email creation, email deletion, change of primary email", + "user.update.email": "Triggers on: email creation, email deletion, change of primary email", + "user.update.email.create": "Triggers on: email creation", + "user.update.email.delete": "Triggers on: email deletion", + "user.update.email.primary": "Triggers on: change of primary email", + "email.send": "Triggers on: an email was sent or should be sent", + }} } func (w *Webhook) Validate() error { diff --git a/backend/docs/Config.md b/backend/docs/Config.md index 3b38fab85..3ccbf9078 100644 --- a/backend/docs/Config.md +++ b/backend/docs/Config.md @@ -485,6 +485,14 @@ third_party: error_redirect_url: "CHANGE_ME" ## # + # Optional URL where the backend redirects to after the backend successfully verified the response from any third party provider. + # This URL is not used when the HTTP 'Referer' Header is set. + # + # NOTE: MUST NOT have trailing slash + # + default_redirect_url: "CHANGE_ME" + ## + # # The third party provider configurations. Unknown providers will be ignored. # providers: diff --git a/backend/dto/config.go b/backend/dto/config.go index a1efe0497..ced547131 100644 --- a/backend/dto/config.go +++ b/backend/dto/config.go @@ -8,21 +8,45 @@ import ( // PublicConfig is the part of the configuration that will be shared with the frontend type PublicConfig struct { - Password config.Password `json:"password"` - Emails config.Emails `json:"emails"` - Providers []string `json:"providers"` - Account config.Account `json:"account"` - UseEnterpriseConnection bool `json:"use_enterprise"` + Password Password `json:"password"` + Emails Emails `json:"emails"` + Providers []string `json:"providers"` + Account Account `json:"account"` + UseEnterpriseConnection bool `json:"use_enterprise"` +} + +type Password struct { + Enabled bool `json:"enabled"` + MinLength int `json:"min_password_length"` +} + +type Emails struct { + RequireVerification bool `json:"require_verification"` + MaxNumOfAddresses int `json:"max_num_of_addresses"` +} + +type Account struct { + AllowDeletion bool `json:"allow_deletion"` + AllowSignup bool `json:"allow_signup"` } // FromConfig Returns a PublicConfig from the Application configuration -func FromConfig(config config.Config) PublicConfig { +func FromConfig(cfg config.Config) PublicConfig { return PublicConfig{ - Password: config.Password, - Emails: config.Emails, - Providers: GetEnabledProviders(config.ThirdParty.Providers), - Account: config.Account, - UseEnterpriseConnection: UseEnterpriseConnection(&config.Saml), + Password: Password{ + Enabled: cfg.Password.Enabled, + MinLength: cfg.Password.MinLength, + }, + Emails: Emails{ + RequireVerification: cfg.Email.RequireVerification, + MaxNumOfAddresses: cfg.Email.Limit, + }, + Providers: GetEnabledProviders(cfg.ThirdParty.Providers), + Account: Account{ + AllowDeletion: cfg.Account.AllowDeletion, + AllowSignup: cfg.Account.AllowSignup, + }, + UseEnterpriseConnection: UseEnterpriseConnection(&cfg.Saml), } } diff --git a/backend/dto/profile.go b/backend/dto/profile.go new file mode 100644 index 000000000..9a3ffa507 --- /dev/null +++ b/backend/dto/profile.go @@ -0,0 +1,39 @@ +package dto + +import ( + "github.com/gofrs/uuid" + "github.com/teamhanko/hanko/backend/persistence/models" + "time" +) + +type ProfileData struct { + UserID uuid.UUID `json:"user_id"` + WebauthnCredentials []WebauthnCredentialResponse `json:"passkeys,omitempty"` + Emails []EmailResponse `json:"emails,omitempty"` + Username *Username `json:"username,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func ProfileDataFromUserModel(user *models.User) *ProfileData { + var webauthnCredentials []WebauthnCredentialResponse + for _, webauthnCredentialModel := range user.WebauthnCredentials { + webauthnCredential := FromWebauthnCredentialModel(&webauthnCredentialModel) + webauthnCredentials = append(webauthnCredentials, *webauthnCredential) + } + + var emails []EmailResponse + for _, emailModel := range user.Emails { + email := FromEmailModel(&emailModel) + emails = append(emails, *email) + } + + return &ProfileData{ + UserID: user.ID, + WebauthnCredentials: webauthnCredentials, + Emails: emails, + Username: FromUsernameModel(user.Username), + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + } +} diff --git a/backend/dto/username.go b/backend/dto/username.go new file mode 100644 index 000000000..9d05b1804 --- /dev/null +++ b/backend/dto/username.go @@ -0,0 +1,26 @@ +package dto + +import ( + "github.com/gofrs/uuid" + "github.com/teamhanko/hanko/backend/persistence/models" + "time" +) + +type Username struct { + ID uuid.UUID `json:"id"` + Username string `json:"username"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func FromUsernameModel(u *models.Username) *Username { + if u == nil { + return nil + } + return &Username{ + ID: u.ID, + Username: u.Username, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + } +} diff --git a/backend/ee/saml/config/saml.go b/backend/ee/saml/config/saml.go index bb8706f3b..4d0774f42 100644 --- a/backend/ee/saml/config/saml.go +++ b/backend/ee/saml/config/saml.go @@ -9,34 +9,81 @@ import ( ) type Saml struct { - Enabled bool `yaml:"enabled" json:"enabled,omitempty" koanf:"enabled" jsonschema:"default=false"` - Endpoint string `yaml:"endpoint_url" json:"endpoint_url,omitempty" koanf:"endpoint_url"` - AudienceUri string `yaml:"audience_uri" json:"audience_uri,omitempty" koanf:"audience_uri"` - DefaultRedirectUrl string `yaml:"default_redirect_url" json:"default_redirect_url,omitempty" koanf:"default_redirect_url"` + // `enabled` determines whether the SAML API endpoints are available. + Enabled bool `yaml:"enabled" json:"enabled,omitempty" koanf:"enabled" jsonschema:"default=false"` + // `endpoint` is URL at which the SAML endpoints like metadata, callback, etc. are available + // (e.g. `{YOUR_BACKEND_INSTANCE}/api`). + // + // Will be provided as metadata for IdP. + Endpoint string `yaml:"endpoint_url" json:"endpoint_url,omitempty" koanf:"endpoint_url"` + // `audience_uri` determines the intended recipient or audience for the SAML Assertion. + AudienceUri string `yaml:"audience_uri" json:"audience_uri,omitempty" koanf:"audience_uri"` + // `default_redirect_url` is the URL to redirect to in case of errors or when no `allowed_redirect_url` is provided. + DefaultRedirectUrl string `yaml:"default_redirect_url" json:"default_redirect_url,omitempty" koanf:"default_redirect_url"` + // `allowed_redirect_urls` is a list of URLs the backend is allowed to redirect to after third party sign-in was + // successful. + // + // Supports wildcard matching through globbing. e.g. `https://*.example.com` will allow `https://foo.example.com` + // and `https://bar.example.com` to be accepted. + // + // Globbing is also supported for paths, e.g. `https://foo.example.com/*` will match `https://foo.example.com/page1` + // and `https://foo.example.com/page2`. + // + // A double asterisk (`**`) acts as a "super"-wildcard/match-all. + // + // See [here](https://pkg.go.dev/github.com/gobwas/glob#Compile) for more on globbinh. AllowedRedirectURLS []string `yaml:"allowed_redirect_urls" json:"allowed_redirect_urls,omitempty" koanf:"allowed_redirect_urls" split_words:"true"` AllowedRedirectURLMap map[string]glob.Glob `jsonschema:"-"` - Options Options `yaml:"options" json:"options,omitempty" koanf:"options"` + // `options` allows setting optional features for service provider operations. + Options Options `yaml:"options" json:"options,omitempty" koanf:"options" jsonschema:"title=options"` + // `identity_providers` is a list of SAML identity providers. IdentityProviders []IdentityProvider `yaml:"identity_providers" json:"identity_providers,omitempty" koanf:"identity_providers"` } +func (s Saml) GetProviderByDomain(domain string) *IdentityProvider { + for _, ip := range s.IdentityProviders { + if ip.Domain == domain { + return &ip + } + } + + return nil +} + type Options struct { + // `sign_authn_requests` determines whether initial requests should be signed. SignAuthnRequests bool `yaml:"sign_authn_requests" json:"sign_authn_requests,omitempty" koanf:"sign_authn_requests" jsonschema:"default=true"` - // Forces the IDP to show login window every time - ForceLogin bool `yaml:"force_login" json:"force_login,omitempty" koanf:"force_login" jsonschema:"default=false"` + // `force_login` forces the IdP to always show a login (even if there is an active session with the IdP). + ForceLogin bool `yaml:"force_login" json:"force_login,omitempty" koanf:"force_login" jsonschema:"default=false"` + // `validate_encryption_cert` determines whether the certificate used for the encryption of the IdP responses should + // be checked for validity. ValidateEncryptionCertificate bool `yaml:"validate_encryption_cert" json:"validate_encryption_cert,omitempty" koanf:"validate_encryption_cert" jsonschema:"default=true"` - SkipSignatureValidation bool `yaml:"skip_signature_validation" json:"skip_signature_validation,omitempty" koanf:"skip_signature_validation" jsonschema:"default=false"` - AllowMissingAttributes bool `yaml:"allow_missing_attributes" json:"allow_missing_attributes,omitempty" koanf:"allow_missing_attributes" jsonschema:"default=false"` + // `skip_signature_validation` determines whether the validity check of an IdP response's signature + // should be skipped. + SkipSignatureValidation bool `yaml:"skip_signature_validation" json:"skip_signature_validation,omitempty" koanf:"skip_signature_validation" jsonschema:"default=false"` + // `allow_missing_attributes` determines whether missing attributes are allowed (e.g. the IdP specifies a phone + // attribute in the metadata but does not send it with a SAML Assertion Response). + AllowMissingAttributes bool `yaml:"allow_missing_attributes" json:"allow_missing_attributes,omitempty" koanf:"allow_missing_attributes" jsonschema:"default=false"` } type IdentityProvider struct { - Enabled bool `yaml:"enabled" json:"enabled,omitempty" koanf:"enabled" jsonschema:"default=false"` - Name string `yaml:"name" json:"name,omitempty" koanf:"name"` - Domain string `yaml:"domain" json:"domain,omitempty" koanf:"domain"` - MetadataUrl string `yaml:"metadata_url" json:"metadata_url,omitempty" koanf:"metadata_url"` - SkipEmailVerification bool `yaml:"skip_email_verification" json:"skip_email_verification,omitempty" koanf:"skip_email_verification"` - AttributeMap AttributeMap `yaml:"attribute_map" json:"attribute_map,omitempty" koanf:"attribute_map"` + // `enabled` activates or deactivates the identity provider. + Enabled bool `yaml:"enabled" json:"enabled,omitempty" koanf:"enabled" jsonschema:"default=false"` + // `name` is the name given for the identity provider. + Name string `yaml:"name" json:"name,omitempty" koanf:"name"` + // At login the domain will be extracted from the users email address and then used to identify the idp to use. + // This tag defines for which domain the idp is used. + Domain string `yaml:"domain" json:"domain,omitempty" koanf:"domain"` + // `metadata_url` is the URL the API can retrieve IdP metadata from. + MetadataUrl string `yaml:"metadata_url" json:"metadata_url,omitempty" koanf:"metadata_url"` + // `skip_email_verification` determines whether the check if the `email_verified` attribute in the IdP response + // will be skipped. + SkipEmailVerification bool `yaml:"skip_email_verification" json:"skip_email_verification,omitempty" koanf:"skip_email_verification"` + // `attribute_map` is a map of attributes used to map attributes in IdP response to custom attributes at + // Hanko. + AttributeMap AttributeMap `yaml:"attribute_map" json:"attribute_map,omitempty" koanf:"attribute_map" jsonschema:"title=attribute_map"` } type AttributeMap struct { diff --git a/backend/ee/saml/handler.go b/backend/ee/saml/handler.go index 5f4310297..a8a534711 100644 --- a/backend/ee/saml/handler.go +++ b/backend/ee/saml/handler.go @@ -7,11 +7,8 @@ import ( "github.com/labstack/echo/v4" saml2 "github.com/russellhaering/gosaml2" auditlog "github.com/teamhanko/hanko/backend/audit_log" - "github.com/teamhanko/hanko/backend/config" "github.com/teamhanko/hanko/backend/ee/saml/dto" "github.com/teamhanko/hanko/backend/ee/saml/provider" - samlUtils "github.com/teamhanko/hanko/backend/ee/saml/utils" - "github.com/teamhanko/hanko/backend/persistence" "github.com/teamhanko/hanko/backend/persistence/models" "github.com/teamhanko/hanko/backend/session" "github.com/teamhanko/hanko/backend/thirdparty" @@ -21,77 +18,34 @@ import ( "strings" ) -type SamlHandler struct { +type Handler struct { auditLogger auditlog.Logger - config *config.Config - persister persistence.Persister sessionManager session.Manager - providers []provider.ServiceProvider + samlService Service } -func NewSamlHandler(cfg *config.Config, persister persistence.Persister, sessionManager session.Manager, auditLogger auditlog.Logger) *SamlHandler { - providers := make([]provider.ServiceProvider, 0) - for _, idpConfig := range cfg.Saml.IdentityProviders { - if idpConfig.Enabled { - hostName := "" - hostName, err := parseProviderFromMetadataUrl(idpConfig.MetadataUrl) - if err != nil { - fmt.Printf("failed to parse provider '%s' from metadata url: %v\n", idpConfig.Name, err) - continue - } - - newProvider, err := provider.GetProvider(hostName, cfg, idpConfig, persister.GetSamlCertificatePersister()) - if err != nil { - fmt.Printf("failed to initialize provider '%s': %v\n", idpConfig.Name, err) - continue - } - - providers = append(providers, newProvider) - } - } - - return &SamlHandler{ +func NewSamlHandler(sessionManager session.Manager, auditLogger auditlog.Logger, samlService Service) *Handler { + return &Handler{ auditLogger: auditLogger, - config: cfg, - persister: persister, sessionManager: sessionManager, - providers: providers, - } -} - -func parseProviderFromMetadataUrl(idpUrlString string) (string, error) { - idpUrl, err := url.Parse(idpUrlString) - if err != nil { - return "", err - } - - return idpUrl.Host, nil -} - -func (handler *SamlHandler) getProviderByDomain(domain string) (provider.ServiceProvider, error) { - for _, availableProvider := range handler.providers { - if availableProvider.GetDomain() == domain { - return availableProvider, nil - } + samlService: samlService, } - - return nil, fmt.Errorf("unknown provider for domain %s", domain) } -func (handler *SamlHandler) Metadata(c echo.Context) error { +func (handler *Handler) Metadata(c echo.Context) error { var request dto.SamlMetadataRequest err := c.Bind(&request) if err != nil { return c.JSON(http.StatusBadRequest, thirdparty.ErrorInvalidRequest("domain is missing")) } - foundProvider, err := handler.getProviderByDomain(request.Domain) + foundProvider, err := handler.samlService.GetProviderByDomain(request.Domain) if err != nil { return c.NoContent(http.StatusNotFound) } if request.CertOnly { - cert, err := handler.persister.GetSamlCertificatePersister().GetFirst() + cert, err := handler.samlService.Persister().GetSamlCertificatePersister().GetFirst() if err != nil { return c.JSON(http.StatusInternalServerError, thirdparty.ErrorServer("unable to provide metadata").WithCause(err)) } @@ -100,7 +54,7 @@ func (handler *SamlHandler) Metadata(c echo.Context) error { return c.NoContent(http.StatusNotFound) } - c.Response().Header().Set(echo.HeaderContentDisposition, fmt.Sprintf("attachment; filename=%s-service-provider.pem", handler.config.Service.Name)) + c.Response().Header().Set(echo.HeaderContentDisposition, fmt.Sprintf("attachment; filename=%s-service-provider.pem", handler.samlService.Config().Service.Name)) return c.Blob(http.StatusOK, echo.MIMEOctetStream, []byte(cert.CertData)) } @@ -109,14 +63,14 @@ func (handler *SamlHandler) Metadata(c echo.Context) error { return c.JSON(http.StatusInternalServerError, thirdparty.ErrorServer("unable to provide metadata").WithCause(err)) } - c.Response().Header().Set(echo.HeaderContentDisposition, fmt.Sprintf("attachment; filename=%s-metadata.xml", handler.config.Service.Name)) + c.Response().Header().Set(echo.HeaderContentDisposition, fmt.Sprintf("attachment; filename=%s-metadata.xml", handler.samlService.Config().Service.Name)) return c.Blob(http.StatusOK, echo.MIMEOctetStream, xmlMetadata) } -func (handler *SamlHandler) Auth(c echo.Context) error { +func (handler *Handler) Auth(c echo.Context) error { errorRedirectTo := c.Request().Header.Get("Referer") if errorRedirectTo == "" { - errorRedirectTo = handler.config.Saml.DefaultRedirectUrl + errorRedirectTo = handler.samlService.Config().Saml.DefaultRedirectUrl } var request dto.SamlAuthRequest @@ -130,26 +84,12 @@ func (handler *SamlHandler) Auth(c echo.Context) error { return handler.redirectError(c, thirdparty.ErrorInvalidRequest(err.Error()).WithCause(err), errorRedirectTo) } - if ok := samlUtils.IsAllowedRedirect(handler.config.Saml, request.RedirectTo); !ok { - return handler.redirectError(c, thirdparty.ErrorInvalidRequest(fmt.Sprintf("redirect to '%s' not allowed", request.RedirectTo)), errorRedirectTo) - } - - foundProvider, err := handler.getProviderByDomain(request.Domain) + foundProvider, err := handler.samlService.GetProviderByDomain(request.Domain) if err != nil { return handler.redirectError(c, thirdparty.ErrorInvalidRequest(err.Error()).WithCause(err), errorRedirectTo) } - state, err := GenerateState( - handler.config, - handler.persister.GetSamlStatePersister(), - request.Domain, - request.RedirectTo) - - if err != nil { - return handler.redirectError(c, thirdparty.ErrorServer("could not generate state").WithCause(err), errorRedirectTo) - } - - redirectUrl, err := foundProvider.GetService().BuildAuthURL(string(state)) + redirectUrl, err := handler.samlService.GetAuthUrl(foundProvider, request.RedirectTo, false) if err != nil { return handler.redirectError(c, thirdparty.ErrorServer("could not generate auth url").WithCause(err), errorRedirectTo) } @@ -157,18 +97,18 @@ func (handler *SamlHandler) Auth(c echo.Context) error { return c.Redirect(http.StatusTemporaryRedirect, redirectUrl) } -func (handler *SamlHandler) CallbackPost(c echo.Context) error { - state, samlError := VerifyState(handler.config, handler.persister.GetSamlStatePersister(), c.FormValue("RelayState")) +func (handler *Handler) CallbackPost(c echo.Context) error { + state, samlError := VerifyState(handler.samlService.Config(), handler.samlService.Persister().GetSamlStatePersister(), c.FormValue("RelayState")) if samlError != nil { return handler.redirectError( c, thirdparty.ErrorInvalidRequest(samlError.Error()).WithCause(samlError), - handler.config.Saml.DefaultRedirectUrl, + handler.samlService.Config().Saml.DefaultRedirectUrl, ) } if strings.TrimSpace(state.RedirectTo) == "" { - state.RedirectTo = handler.config.Saml.DefaultRedirectUrl + state.RedirectTo = handler.samlService.Config().Saml.DefaultRedirectUrl } redirectTo, samlError := url.Parse(state.RedirectTo) @@ -176,11 +116,11 @@ func (handler *SamlHandler) CallbackPost(c echo.Context) error { return handler.redirectError( c, thirdparty.ErrorServer("unable to parse redirect url").WithCause(samlError), - handler.config.Saml.DefaultRedirectUrl, + handler.samlService.Config().Saml.DefaultRedirectUrl, ) } - foundProvider, samlError := handler.getProviderByDomain(state.Provider) + foundProvider, samlError := handler.samlService.GetProviderByDomain(state.Provider) if samlError != nil { return handler.redirectError( c, @@ -210,36 +150,41 @@ func (handler *SamlHandler) CallbackPost(c echo.Context) error { return c.Redirect(http.StatusFound, redirectUrl.String()) } -func (handler *SamlHandler) linkAccount(c echo.Context, redirectTo *url.URL, state *State, provider provider.ServiceProvider, assertionInfo *saml2.AssertionInfo) (*url.URL, error) { +func (handler *Handler) linkAccount(c echo.Context, redirectTo *url.URL, state *State, provider provider.ServiceProvider, assertionInfo *saml2.AssertionInfo) (*url.URL, error) { var accountLinkingResult *thirdparty.AccountLinkingResult var samlError error - samlError = handler.persister.Transaction(func(tx *pop.Connection) error { + samlError = handler.samlService.Persister().Transaction(func(tx *pop.Connection) error { userdata := provider.GetUserData(assertionInfo) - linkResult, samlError := thirdparty.LinkAccount(tx, handler.config, handler.persister, userdata, state.Provider, true) - if samlError != nil { - return samlError + linkResult, samlErrorTx := thirdparty.LinkAccount(tx, handler.samlService.Config(), handler.samlService.Persister(), userdata, state.Provider, true, state.IsFlow) + if samlErrorTx != nil { + return samlErrorTx } + accountLinkingResult = linkResult - token, samlError := handler.createHankoToken(linkResult, tx) - if samlError != nil { - return samlError + emailModel := linkResult.User.Emails.GetEmailByAddress(userdata.Metadata.Email) + identityModel := emailModel.Identities.GetIdentity(provider.GetDomain(), userdata.Metadata.Subject) + + token, tokenError := models.NewToken( + linkResult.User.ID, + models.TokenWithIdentityID(identityModel.ID), + models.TokenForFlowAPI(state.IsFlow), + models.TokenUserCreated(linkResult.UserCreated)) + if tokenError != nil { + return thirdparty.ErrorServer("could not create token").WithCause(tokenError) + } + + tokenError = handler.samlService.Persister().GetTokenPersisterWithConnection(tx).Create(*token) + if tokenError != nil { + return thirdparty.ErrorServer("could not save token to db").WithCause(tokenError) } query := redirectTo.Query() query.Add(utils.HankoTokenQuery, token.Value) redirectTo.RawQuery = query.Encode() - cookie := utils.GenerateStateCookie(handler.config, utils.HankoThirdpartyStateCookie, "", utils.CookieOptions{ - MaxAge: -1, - Path: "/", - SameSite: http.SameSiteLaxMode, - }) - c.SetCookie(cookie) - return nil - }) if samlError != nil { @@ -255,21 +200,7 @@ func (handler *SamlHandler) linkAccount(c echo.Context, redirectTo *url.URL, sta return redirectTo, nil } -func (handler *SamlHandler) createHankoToken(linkResult *thirdparty.AccountLinkingResult, tx *pop.Connection) (*models.Token, error) { - token, tokenError := models.NewToken(linkResult.User.ID) - if tokenError != nil { - return nil, thirdparty.ErrorServer("could not create token").WithCause(tokenError) - } - - tokenError = handler.persister.GetTokenPersisterWithConnection(tx).Create(*token) - if tokenError != nil { - return nil, thirdparty.ErrorServer("could not save token to db").WithCause(tokenError) - } - - return token, nil -} - -func (handler *SamlHandler) parseSamlResponse(provider provider.ServiceProvider, samlResponse string) (*saml2.AssertionInfo, error) { +func (handler *Handler) parseSamlResponse(provider provider.ServiceProvider, samlResponse string) (*saml2.AssertionInfo, error) { assertionInfo, err := provider.GetService().RetrieveAssertionInfo(samlResponse) if err != nil { return nil, thirdparty.ErrorServer("unable to parse SAML response").WithCause(err) @@ -286,7 +217,9 @@ func (handler *SamlHandler) parseSamlResponse(provider provider.ServiceProvider, return assertionInfo, nil } -func (handler *SamlHandler) redirectError(c echo.Context, error error, to string) error { +func (handler *Handler) redirectError(c echo.Context, error error, to string) error { + c.Logger().Error(error) + err := handler.auditError(c, error) if err != nil { error = err @@ -296,7 +229,7 @@ func (handler *SamlHandler) redirectError(c echo.Context, error error, to string return c.Redirect(http.StatusSeeOther, redirectURL) } -func (handler *SamlHandler) auditError(c echo.Context, err error) error { +func (handler *Handler) auditError(c echo.Context, err error) error { var e *thirdparty.ThirdPartyError ok := errors.As(err, &e) @@ -307,14 +240,14 @@ func (handler *SamlHandler) auditError(c echo.Context, err error) error { return auditLogError } -func (handler *SamlHandler) GetProvider(c echo.Context) error { +func (handler *Handler) GetProvider(c echo.Context) error { var request dto.SamlRequest err := c.Bind(&request) if err != nil { return c.JSON(http.StatusBadRequest, err) } - foundProvider, err := handler.getProviderByDomain(request.Domain) + foundProvider, err := handler.samlService.GetProviderByDomain(request.Domain) if err != nil { return c.NoContent(http.StatusNotFound) } diff --git a/backend/ee/saml/router.go b/backend/ee/saml/router.go index 7f45ccacc..e1bd58a06 100644 --- a/backend/ee/saml/router.go +++ b/backend/ee/saml/router.go @@ -3,13 +3,11 @@ package saml import ( "github.com/labstack/echo/v4" auditlog "github.com/teamhanko/hanko/backend/audit_log" - "github.com/teamhanko/hanko/backend/config" - "github.com/teamhanko/hanko/backend/persistence" "github.com/teamhanko/hanko/backend/session" ) -func CreateSamlRoutes(e *echo.Echo, cfg *config.Config, persister persistence.Persister, sessionManager session.Manager, auditLogger auditlog.Logger) { - handler := NewSamlHandler(cfg, persister, sessionManager, auditLogger) +func CreateSamlRoutes(e *echo.Echo, sessionManager session.Manager, auditLogger auditlog.Logger, samlService Service) { + handler := NewSamlHandler(sessionManager, auditLogger, samlService) routingGroup := e.Group("saml") routingGroup.GET("/provider", handler.GetProvider) routingGroup.GET("/metadata", handler.Metadata) diff --git a/backend/ee/saml/service.go b/backend/ee/saml/service.go new file mode 100644 index 000000000..fe1b70784 --- /dev/null +++ b/backend/ee/saml/service.go @@ -0,0 +1,108 @@ +package saml + +import ( + "fmt" + "github.com/teamhanko/hanko/backend/config" + "github.com/teamhanko/hanko/backend/ee/saml/provider" + samlUtils "github.com/teamhanko/hanko/backend/ee/saml/utils" + "github.com/teamhanko/hanko/backend/persistence" + "github.com/teamhanko/hanko/backend/thirdparty" + "net/url" +) + +type Service interface { + Config() *config.Config + Persister() persistence.Persister + Providers() []provider.ServiceProvider + GetProviderByDomain(domain string) (provider.ServiceProvider, error) + GetAuthUrl(provider provider.ServiceProvider, redirectTo string, isFlow bool) (string, error) +} + +type defaultService struct { + config *config.Config + persister persistence.Persister + providers []provider.ServiceProvider +} + +func NewSamlService(cfg *config.Config, persister persistence.Persister) Service { + providers := make([]provider.ServiceProvider, 0) + for _, idpConfig := range cfg.Saml.IdentityProviders { + if idpConfig.Enabled { + hostName := "" + hostName, err := parseProviderFromMetadataUrl(idpConfig.MetadataUrl) + if err != nil { + fmt.Printf("failed to parse provider '%s' from metadata url: %v\n", idpConfig.Name, err) + continue + } + + newProvider, err := provider.GetProvider(hostName, cfg, idpConfig, persister.GetSamlCertificatePersister()) + if err != nil { + fmt.Printf("failed to initialize provider '%s': %v\n", idpConfig.Name, err) + continue + } + + providers = append(providers, newProvider) + } + } + + return &defaultService{ + config: cfg, + persister: persister, + providers: providers, + } +} + +func parseProviderFromMetadataUrl(idpUrlString string) (string, error) { + idpUrl, err := url.Parse(idpUrlString) + if err != nil { + return "", err + } + + return idpUrl.Host, nil +} + +func (s *defaultService) Config() *config.Config { + return s.config +} + +func (s *defaultService) Persister() persistence.Persister { + return s.persister +} + +func (s *defaultService) Providers() []provider.ServiceProvider { + return s.providers +} + +func (s *defaultService) GetProviderByDomain(domain string) (provider.ServiceProvider, error) { + for _, availableProvider := range s.providers { + if availableProvider.GetDomain() == domain { + return availableProvider, nil + } + } + + return nil, fmt.Errorf("unknown provider for domain %s", domain) +} + +func (s *defaultService) GetAuthUrl(provider provider.ServiceProvider, redirectTo string, isFlow bool) (string, error) { + if ok := samlUtils.IsAllowedRedirect(s.config.Saml, redirectTo); !ok { + return "", thirdparty.ErrorInvalidRequest(fmt.Sprintf("redirect to '%s' not allowed", redirectTo)) + } + + state, err := GenerateState( + s.config, + s.persister.GetSamlStatePersister(), + provider.GetDomain(), + redirectTo, + GenerateStateForFlowAPI(isFlow)) + + if err != nil { + return "", thirdparty.ErrorServer("could not generate state").WithCause(err) + } + + redirectUrl, err := provider.GetService().BuildAuthURL(string(state)) + if err != nil { + return "", thirdparty.ErrorServer("could not generate auth url").WithCause(err) + } + + return redirectUrl, nil +} diff --git a/backend/ee/saml/state.go b/backend/ee/saml/state.go index 9685ce76f..9999553f4 100644 --- a/backend/ee/saml/state.go +++ b/backend/ee/saml/state.go @@ -19,9 +19,16 @@ type State struct { IssuedAt time.Time `json:"issued_at"` ExpiresAt time.Time `json:"expires_at"` Nonce string `json:"nonce"` + IsFlow bool `json:"is_flow"` } -func GenerateState(config *config.Config, persister persistence.SamlStatePersister, provider string, redirectTo string) ([]byte, error) { +func GenerateStateForFlowAPI(isFlow bool) func(*State) { + return func(state *State) { + state.IsFlow = isFlow + } +} + +func GenerateState(config *config.Config, persister persistence.SamlStatePersister, provider string, redirectTo string, options ...func(*State)) ([]byte, error) { if strings.TrimSpace(provider) == "" { return nil, errors.New("provider must be present") } @@ -44,6 +51,10 @@ func GenerateState(config *config.Config, persister persistence.SamlStatePersist Nonce: nonce, } + for _, option := range options { + option(&state) + } + stateJson, err := json.Marshal(state) aes, err := aes_gcm.NewAESGCM(config.Secrets.Keys) diff --git a/backend/flow_api/flow/capabilities/action_send_capabilities.go b/backend/flow_api/flow/capabilities/action_send_capabilities.go new file mode 100644 index 000000000..ca9a9af45 --- /dev/null +++ b/backend/flow_api/flow/capabilities/action_send_capabilities.go @@ -0,0 +1,44 @@ +package capabilities + +import ( + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flowpilot" +) + +type RegisterClientCapabilities struct { + shared.Action +} + +func (a RegisterClientCapabilities) GetName() flowpilot.ActionName { + return shared.ActionRegisterClientCapabilities +} + +func (a RegisterClientCapabilities) GetDescription() string { + return "Send the computers capabilities." +} + +func (a RegisterClientCapabilities) Initialize(c flowpilot.InitializationContext) { + c.AddInputs(flowpilot.BooleanInput("webauthn_conditional_mediation_available").Hidden(true)) + c.AddInputs(flowpilot.BooleanInput("webauthn_available").Required(true).Hidden(true)) +} + +func (a RegisterClientCapabilities) Execute(c flowpilot.ExecutionContext) error { + if valid := c.ValidateInputData(); !valid { + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + webauthnAvailable := c.Input().Get("webauthn_available").Bool() + + err := c.Stash().Set(shared.StashPathWebauthnAvailable, webauthnAvailable) + if err != nil { + return err + } + + conditionalMediationAvailable := c.Input().Get("webauthn_conditional_mediation_available").Bool() + err = c.Stash().Set(shared.StashPathWebauthnConditionalMediationAvailable, conditionalMediationAvailable) + if err != nil { + return err + } + + return c.Continue() +} diff --git a/backend/flow_api/flow/credential_onboarding/action_continue_to_passkey.go b/backend/flow_api/flow/credential_onboarding/action_continue_to_passkey.go new file mode 100644 index 000000000..ce68d1349 --- /dev/null +++ b/backend/flow_api/flow/credential_onboarding/action_continue_to_passkey.go @@ -0,0 +1,24 @@ +package credential_onboarding + +import ( + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flowpilot" +) + +type ContinueToPasskey struct { + shared.Action +} + +func (a ContinueToPasskey) GetName() flowpilot.ActionName { + return shared.ActionContinueToPasskeyRegistration +} + +func (a ContinueToPasskey) GetDescription() string { + return "Register a WebAuthn credential" +} + +func (a ContinueToPasskey) Initialize(_ flowpilot.InitializationContext) {} + +func (a ContinueToPasskey) Execute(c flowpilot.ExecutionContext) error { + return c.Continue(shared.StateOnboardingCreatePasskey) +} diff --git a/backend/flow_api/flow/credential_onboarding/action_continue_to_password.go b/backend/flow_api/flow/credential_onboarding/action_continue_to_password.go new file mode 100644 index 000000000..183e57b0e --- /dev/null +++ b/backend/flow_api/flow/credential_onboarding/action_continue_to_password.go @@ -0,0 +1,24 @@ +package credential_onboarding + +import ( + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flowpilot" +) + +type ContinueToPassword struct { + shared.Action +} + +func (a ContinueToPassword) GetName() flowpilot.ActionName { + return shared.ActionContinueToPasswordRegistration +} + +func (a ContinueToPassword) GetDescription() string { + return "Register a password credential" +} + +func (a ContinueToPassword) Initialize(_ flowpilot.InitializationContext) {} + +func (a ContinueToPassword) Execute(c flowpilot.ExecutionContext) error { + return c.Continue(shared.StatePasswordCreation) +} diff --git a/backend/flow_api/flow/credential_onboarding/action_register_password.go b/backend/flow_api/flow/credential_onboarding/action_register_password.go new file mode 100644 index 000000000..765b5b14a --- /dev/null +++ b/backend/flow_api/flow/credential_onboarding/action_register_password.go @@ -0,0 +1,71 @@ +package credential_onboarding + +import ( + "errors" + "fmt" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flowpilot" + "golang.org/x/crypto/bcrypt" + "unicode/utf8" +) + +type RegisterPassword struct { + shared.Action +} + +func (a RegisterPassword) GetName() flowpilot.ActionName { + return shared.ActionRegisterPassword +} + +func (a RegisterPassword) GetDescription() string { + return "Submit a new password." +} + +func (a RegisterPassword) Initialize(c flowpilot.InitializationContext) { + deps := a.GetDeps(c) + + input := flowpilot.PasswordInput("new_password"). + Required(!deps.Cfg.Password.Optional). + MinLength(deps.Cfg.Password.MinLength) + + c.AddInputs(input) +} + +func (a RegisterPassword) Execute(c flowpilot.ExecutionContext) error { + deps := a.GetDeps(c) + + if valid := c.ValidateInputData(); !valid { + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + newPassword := c.Input().Get("new_password").String() + newPasswordBytes := []byte(newPassword) + + if utf8.RuneCountInString(newPassword) < deps.Cfg.Password.MinLength { + c.Input().SetError("new_password", flowpilot.ErrorValueInvalid) + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + hashedPassword, err := bcrypt.GenerateFromPassword(newPasswordBytes, 12) + if err != nil { + if errors.Is(err, bcrypt.ErrPasswordTooLong) { + c.Input().SetError("new_password", flowpilot.ErrorValueTooLong) + return c.Error(flowpilot.ErrorFormDataInvalid) + } + return fmt.Errorf("failed to hash password: %w", err) + } + + err = c.Stash().Set(shared.StashPathNewPassword, string(hashedPassword)) + if err != nil { + return fmt.Errorf("failed to set new_password to stash: %w", err) + } + + err = c.Stash().Set(shared.StashPathUserHasPassword, true) + if err != nil { + return fmt.Errorf("failed to set user_has_password to the stash: %w", err) + } + + c.PreventRevert() + + return c.Continue() +} diff --git a/backend/flow_api/flow/credential_onboarding/action_skip_method_chooser.go b/backend/flow_api/flow/credential_onboarding/action_skip_method_chooser.go new file mode 100644 index 000000000..0540e6a60 --- /dev/null +++ b/backend/flow_api/flow/credential_onboarding/action_skip_method_chooser.go @@ -0,0 +1,38 @@ +package credential_onboarding + +import ( + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flowpilot" +) + +type SkipCredentialOnboardingMethodChooser struct { + shared.Action +} + +func (a SkipCredentialOnboardingMethodChooser) GetName() flowpilot.ActionName { + return shared.ActionSkip +} + +func (a SkipCredentialOnboardingMethodChooser) GetDescription() string { + return "Skip" +} + +func (a SkipCredentialOnboardingMethodChooser) Initialize(c flowpilot.InitializationContext) { + deps := a.GetDeps(c) + emailExists := c.Stash().Get(shared.StashPathEmail).Exists() + + if c.GetFlowName() == shared.FlowRegistration && + !(deps.Cfg.Email.UseForAuthentication && emailExists) { + c.SuspendAction() + } + + if !deps.Cfg.Password.Optional && !deps.Cfg.Passkey.Optional { + c.SuspendAction() + } +} + +func (a SkipCredentialOnboardingMethodChooser) Execute(c flowpilot.ExecutionContext) error { + c.PreventRevert() + + return c.Continue() +} diff --git a/backend/flow_api/flow/credential_onboarding/action_skip_passkey.go b/backend/flow_api/flow/credential_onboarding/action_skip_passkey.go new file mode 100644 index 000000000..6ba435f9e --- /dev/null +++ b/backend/flow_api/flow/credential_onboarding/action_skip_passkey.go @@ -0,0 +1,74 @@ +package credential_onboarding + +import ( + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flowpilot" +) + +type SkipPasskey struct { + shared.Action +} + +func (a SkipPasskey) GetName() flowpilot.ActionName { + return shared.ActionSkip +} + +func (a SkipPasskey) GetDescription() string { + return "Skip" +} + +func (a SkipPasskey) Initialize(c flowpilot.InitializationContext) { + deps := a.GetDeps(c) + emailExists := c.Stash().Get(shared.StashPathEmail).Exists() + canLoginWithEmail := emailExists && + deps.Cfg.Email.Enabled && + deps.Cfg.Email.UseForAuthentication && + deps.Cfg.Email.UseAsLoginIdentifier + + if !deps.Cfg.Passkey.Optional { + c.SuspendAction() + } + + if c.IsPreviousState(shared.StateCredentialOnboardingChooser) { + c.SuspendAction() + } + + if c.IsPreviousState(shared.StatePasswordCreation) && + !c.Stash().Get(shared.StashPathUserHasPassword).Bool() && + !canLoginWithEmail { + c.SuspendAction() + } + + if (c.IsPreviousState(shared.StatePasscodeConfirmation) || c.IsPreviousState(shared.StateRegistrationInit)) && + a.acquirePassword(c, "never") && + !canLoginWithEmail { + c.SuspendAction() + } + +} +func (a SkipPasskey) Execute(c flowpilot.ExecutionContext) error { + if a.acquirePassword(c, "conditional") && + !c.Stash().Get(shared.StashPathUserHasPassword).Bool() { + return c.Continue(shared.StatePasswordCreation) + } + + return c.Continue() +} + +func (a SkipPasskey) acquirePassword(c flowpilot.Context, acquireType string) bool { + deps := a.GetDeps(c) + + if !deps.Cfg.Password.Enabled { + return false + } + + if c.IsFlow(shared.FlowLogin) && deps.Cfg.Password.AcquireOnLogin == acquireType { + return true + } + + if c.IsFlow(shared.FlowRegistration) && deps.Cfg.Password.AcquireOnRegistration == acquireType { + return true + } + + return false +} diff --git a/backend/flow_api/flow/credential_onboarding/action_skip_password.go b/backend/flow_api/flow/credential_onboarding/action_skip_password.go new file mode 100644 index 000000000..5237f5658 --- /dev/null +++ b/backend/flow_api/flow/credential_onboarding/action_skip_password.go @@ -0,0 +1,75 @@ +package credential_onboarding + +import ( + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flowpilot" +) + +type SkipPassword struct { + shared.Action +} + +func (a SkipPassword) GetName() flowpilot.ActionName { + return shared.ActionSkip +} + +func (a SkipPassword) GetDescription() string { + return "Skip" +} + +func (a SkipPassword) Initialize(c flowpilot.InitializationContext) { + deps := a.GetDeps(c) + emailExists := c.Stash().Get(shared.StashPathEmail).Exists() + canLoginWithEmail := emailExists && + deps.Cfg.Email.Enabled && + deps.Cfg.Email.UseForAuthentication && + deps.Cfg.Email.UseAsLoginIdentifier + + if !deps.Cfg.Password.Optional { + c.SuspendAction() + } + + if c.IsPreviousState(shared.StateCredentialOnboardingChooser) { + c.SuspendAction() + } + + if c.IsPreviousState(shared.StateOnboardingCreatePasskey) && + !c.Stash().Get(shared.StashPathUserHasWebauthnCredential).Bool() && + !canLoginWithEmail { + c.SuspendAction() + } + + if (c.IsPreviousState(shared.StatePasscodeConfirmation) || c.IsPreviousState(shared.StateRegistrationInit)) && + a.acquirePasskey(c, "never") && + !canLoginWithEmail { + c.SuspendAction() + } +} + +func (a SkipPassword) Execute(c flowpilot.ExecutionContext) error { + if a.acquirePasskey(c, "conditional") && + !c.Stash().Get(shared.StashPathUserHasWebauthnCredential).Bool() && + c.Stash().Get(shared.StashPathWebauthnAvailable).Bool() { + return c.Continue(shared.StateOnboardingCreatePasskey) + } + + return c.Continue() +} + +func (a SkipPassword) acquirePasskey(c flowpilot.Context, acquireType string) bool { + deps := a.GetDeps(c) + + if !deps.Cfg.Passkey.Enabled { + return false + } + + if c.IsFlow(shared.FlowLogin) && deps.Cfg.Passkey.AcquireOnLogin == acquireType { + return true + } + + if c.IsFlow(shared.FlowRegistration) && deps.Cfg.Passkey.AcquireOnRegistration == acquireType { + return true + } + + return false +} diff --git a/backend/flow_api/flow/credential_onboarding/action_webauthn_generate_creation_options.go b/backend/flow_api/flow/credential_onboarding/action_webauthn_generate_creation_options.go new file mode 100644 index 000000000..93c72669a --- /dev/null +++ b/backend/flow_api/flow/credential_onboarding/action_webauthn_generate_creation_options.go @@ -0,0 +1,76 @@ +package credential_onboarding + +import ( + "errors" + "fmt" + "github.com/gofrs/uuid" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flow_api/services" + "github.com/teamhanko/hanko/backend/flowpilot" +) + +type WebauthnGenerateCreationOptions struct { + shared.Action +} + +func (a WebauthnGenerateCreationOptions) GetName() flowpilot.ActionName { + return shared.ActionWebauthnGenerateCreationOptions +} + +func (a WebauthnGenerateCreationOptions) GetDescription() string { + return "Get creation options to create a webauthn credential." +} + +func (a WebauthnGenerateCreationOptions) Initialize(c flowpilot.InitializationContext) { + if !c.Stash().Get(shared.StashPathWebauthnAvailable).Bool() { + c.SuspendAction() + } +} + +func (a WebauthnGenerateCreationOptions) Execute(c flowpilot.ExecutionContext) error { + deps := a.GetDeps(c) + + if valid := c.ValidateInputData(); !valid { + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + if !c.Stash().Get(shared.StashPathUserID).Exists() { + return errors.New("user_id does not exist in the stash") + } + + if !c.Stash().Get(shared.StashPathEmail).Exists() && !c.Stash().Get(shared.StashPathUsername).Exists() { + return errors.New("either email or username must exist in the stash") + } + + userID, err := uuid.FromString(c.Stash().Get(shared.StashPathUserID).String()) + if err != nil { + return fmt.Errorf("failed to parse user id as a uuid: %w", err) + } + + email := c.Stash().Get(shared.StashPathEmail).String() + username := c.Stash().Get(shared.StashPathUsername).String() + + params := services.GenerateCreationOptionsParams{ + Tx: deps.Tx, + UserID: userID, + Email: email, + Username: username, + } + + sessionDataModel, creationOptions, err := deps.WebauthnService.GenerateCreationOptions(params) + if err != nil { + return fmt.Errorf("failed to generate webauthn creation options: %w", err) + } + + err = c.Stash().Set(shared.StashPathWebauthnSessionDataID, sessionDataModel.ID) + if err != nil { + return err + } + + err = c.Payload().Set("creation_options", creationOptions) + if err != nil { + return err + } + + return c.Continue(shared.StateOnboardingVerifyPasskeyAttestation) +} diff --git a/backend/flow_api/flow/credential_onboarding/action_webauthn_verify_attestation_response.go b/backend/flow_api/flow/credential_onboarding/action_webauthn_verify_attestation_response.go new file mode 100644 index 000000000..bdbc45c27 --- /dev/null +++ b/backend/flow_api/flow/credential_onboarding/action_webauthn_verify_attestation_response.go @@ -0,0 +1,84 @@ +package credential_onboarding + +import ( + "errors" + "fmt" + "github.com/gofrs/uuid" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flow_api/services" + "github.com/teamhanko/hanko/backend/flowpilot" +) + +type WebauthnVerifyAttestationResponse struct { + shared.Action +} + +func (a WebauthnVerifyAttestationResponse) GetName() flowpilot.ActionName { + return shared.ActionWebauthnVerifyAttestationResponse +} + +func (a WebauthnVerifyAttestationResponse) GetDescription() string { + return "Send the result which was generated by creating a webauthn credential." +} + +func (a WebauthnVerifyAttestationResponse) Initialize(c flowpilot.InitializationContext) { + if !c.Stash().Get(shared.StashPathWebauthnAvailable).Bool() { + c.SuspendAction() + } + + c.AddInputs(flowpilot.JSONInput("public_key")) +} + +func (a WebauthnVerifyAttestationResponse) Execute(c flowpilot.ExecutionContext) error { + deps := a.GetDeps(c) + + if valid := c.ValidateInputData(); !valid { + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + if !c.Stash().Get(shared.StashPathWebauthnSessionDataID).Exists() { + return errors.New("webauthn_session_data_id does not exist in the stash") + } + + sessionDataID, err := uuid.FromString(c.Stash().Get(shared.StashPathWebauthnSessionDataID).String()) + if err != nil { + return fmt.Errorf("failed to parse webauthn_session_data_id: %w", err) + } + + userID, err := uuid.FromString(c.Stash().Get(shared.StashPathUserID).String()) + if err != nil { + return fmt.Errorf("failed to parse user_id into a uuid: %w", err) + } + + params := services.VerifyAttestationResponseParams{ + Tx: deps.Tx, + SessionDataID: sessionDataID, + PublicKey: c.Input().Get("public_key").String(), + UserID: userID, + Email: c.Stash().Get(shared.StashPathEmail).String(), + Username: c.Stash().Get(shared.StashPathUsername).String(), + } + + credential, err := deps.WebauthnService.VerifyAttestationResponse(params) + if err != nil { + if errors.Is(err, services.ErrInvalidWebauthnCredential) { + return c.Error(shared.ErrorPasskeyInvalid.Wrap(err)) + } + + return fmt.Errorf("failed to verify attestation response: %w", err) + } + + err = c.Stash().Set(shared.StashPathWebauthnCredential, credential) + if err != nil { + return fmt.Errorf("failed to set webauthn_credential to the stash: %w", err) + } + + err = c.Stash().Set(shared.StashPathUserHasWebauthnCredential, true) + if err != nil { + return fmt.Errorf("failed to set user_has_webauthn_credential to the stash: %w", err) + } + + c.PreventRevert() + + return c.Continue() +} diff --git a/backend/flow_api/flow/credential_usage/action_continue_to_passcode_confirmation.go b/backend/flow_api/flow/credential_usage/action_continue_to_passcode_confirmation.go new file mode 100644 index 000000000..f366c114c --- /dev/null +++ b/backend/flow_api/flow/credential_usage/action_continue_to_passcode_confirmation.go @@ -0,0 +1,39 @@ +package credential_usage + +import ( + "fmt" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flowpilot" +) + +type ContinueToPasscodeConfirmation struct { + shared.Action +} + +func (a ContinueToPasscodeConfirmation) GetName() flowpilot.ActionName { + return shared.ActionContinueToPasscodeConfirmation +} + +func (a ContinueToPasscodeConfirmation) GetDescription() string { + return "Send a login passcode code via email." +} + +func (a ContinueToPasscodeConfirmation) Initialize(c flowpilot.InitializationContext) {} + +func (a ContinueToPasscodeConfirmation) Execute(c flowpilot.ExecutionContext) error { + if err := c.Stash().Set(shared.StashPathLoginMethod, "passcode"); err != nil { + return fmt.Errorf("failed to set login_method to stash: %w", err) + } + + if len(c.Stash().Get(shared.StashPathUserID).String()) > 0 { + if err := c.Stash().Set(shared.StashPathPasscodeTemplate, "login"); err != nil { + return fmt.Errorf("failed to set passcode_template to the stash: %w", err) + } + } else { + if err := c.Stash().Set(shared.StashPathPasscodeTemplate, "email_login_attempted"); err != nil { + return fmt.Errorf("failed to set passcode_template to the stash: %w", err) + } + } + + return c.Continue(shared.StatePasscodeConfirmation) +} diff --git a/backend/flow_api/flow/credential_usage/action_continue_to_passcode_confirmation_recovery.go b/backend/flow_api/flow/credential_usage/action_continue_to_passcode_confirmation_recovery.go new file mode 100644 index 000000000..d202051e4 --- /dev/null +++ b/backend/flow_api/flow/credential_usage/action_continue_to_passcode_confirmation_recovery.go @@ -0,0 +1,41 @@ +package credential_usage + +import ( + "fmt" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flowpilot" +) + +type ContinueToPasscodeConfirmationRecovery struct { + shared.Action +} + +func (a ContinueToPasscodeConfirmationRecovery) GetName() flowpilot.ActionName { + return shared.ActionContinueToPasscodeConfirmationRecovery +} + +func (a ContinueToPasscodeConfirmationRecovery) GetDescription() string { + return "Send a recovery passcode code via email." +} + +func (a ContinueToPasscodeConfirmationRecovery) Initialize(c flowpilot.InitializationContext) { + deps := a.GetDeps(c) + + if !deps.Cfg.Password.Recovery || len(c.Stash().Get(shared.StashPathEmail).String()) == 0 { + c.SuspendAction() + } +} + +func (a ContinueToPasscodeConfirmationRecovery) Execute(c flowpilot.ExecutionContext) error { + if len(c.Stash().Get(shared.StashPathUserID).String()) > 0 { + if err := c.Stash().Set(shared.StashPathPasscodeTemplate, "recovery"); err != nil { + return fmt.Errorf("failed to set passcode_template to the stash: %w", err) + } + } else { + if err := c.Stash().Set(shared.StashPathPasscodeTemplate, "email_login_attempted"); err != nil { + return fmt.Errorf("failed to set passcode_template to the stash: %w", err) + } + } + + return c.Continue(shared.StatePasscodeConfirmation, shared.StateLoginPasswordRecovery) +} diff --git a/backend/flow_api/flow/credential_usage/action_continue_to_password_login.go b/backend/flow_api/flow/credential_usage/action_continue_to_password_login.go new file mode 100644 index 000000000..a4a91493f --- /dev/null +++ b/backend/flow_api/flow/credential_usage/action_continue_to_password_login.go @@ -0,0 +1,24 @@ +package credential_usage + +import ( + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flowpilot" +) + +type ContinueToPasswordLogin struct { + shared.Action +} + +func (a ContinueToPasswordLogin) GetName() flowpilot.ActionName { + return shared.ActionContinueToPasswordLogin +} + +func (a ContinueToPasswordLogin) GetDescription() string { + return "Continue to the password login." +} + +func (a ContinueToPasswordLogin) Initialize(c flowpilot.InitializationContext) {} + +func (a ContinueToPasswordLogin) Execute(c flowpilot.ExecutionContext) error { + return c.Continue(shared.StateLoginPassword) +} diff --git a/backend/flow_api/flow/credential_usage/action_continue_with_login_identifier.go b/backend/flow_api/flow/credential_usage/action_continue_with_login_identifier.go new file mode 100644 index 000000000..bcfa70502 --- /dev/null +++ b/backend/flow_api/flow/credential_usage/action_continue_with_login_identifier.go @@ -0,0 +1,250 @@ +package credential_usage + +import ( + "errors" + "fmt" + auditlog "github.com/teamhanko/hanko/backend/audit_log" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/persistence/models" + "regexp" + "strings" +) + +type ContinueWithLoginIdentifier struct { + shared.Action +} + +func (a ContinueWithLoginIdentifier) GetName() flowpilot.ActionName { + return shared.ActionContinueWithLoginIdentifier +} + +func (a ContinueWithLoginIdentifier) GetDescription() string { + return "Enter an identifier to login." +} + +func (a ContinueWithLoginIdentifier) Initialize(c flowpilot.InitializationContext) { + deps := a.GetDeps(c) + + emailEnabled := deps.Cfg.Email.Enabled && deps.Cfg.Email.UseAsLoginIdentifier + usernameEnabled := deps.Cfg.Username.Enabled && deps.Cfg.Username.UseAsLoginIdentifier + + var input flowpilot.Input + if usernameEnabled && emailEnabled { + input = flowpilot.StringInput("identifier"). + MaxLength(255) + } else if emailEnabled { + input = flowpilot.EmailInput("email"). + MaxLength(deps.Cfg.Email.MaxLength). + MinLength(3) + } else if usernameEnabled { + input = flowpilot.StringInput("username"). + MaxLength(deps.Cfg.Username.MaxLength). + MinLength(deps.Cfg.Username.MinLength) + } + + if input != nil { + c.AddInputs(input. + Required(true). + TrimSpace(true). + LowerCase(true)) + } + + if !deps.Cfg.Password.Enabled && + !deps.Cfg.Email.UseForAuthentication && + !(emailEnabled && deps.Cfg.Saml.Enabled && len(deps.SamlService.Providers()) > 0) { + c.SuspendAction() + } + + if !emailEnabled && !usernameEnabled { + c.SuspendAction() + } +} + +func (a ContinueWithLoginIdentifier) Execute(c flowpilot.ExecutionContext) error { + deps := a.GetDeps(c) + + if valid := c.ValidateInputData(); !valid { + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + identifierInputName, identifierInputValue, treatIdentifierAsEmail := a.analyzeIdentifierInputs(c) + + if err := c.Stash().Set(shared.StashPathUserIdentification, identifierInputValue); err != nil { + return fmt.Errorf("failed to set user_identification to stash: %w", err) + } + + if len(identifierInputValue) == 0 { + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + var userModel *models.User + + if treatIdentifierAsEmail { + // User has submitted an email address. + + var err error + + userModel, err = deps.Persister.GetUserPersister().GetByEmailAddress(identifierInputValue) + if err != nil { + return err + } + + if err = c.Stash().Set(shared.StashPathEmail, identifierInputValue); err != nil { + return fmt.Errorf("failed to set email to stash: %w", err) + } + + if userModel != nil { + emailModel := userModel.GetEmailByAddress(identifierInputValue) + + if emailModel != nil && emailModel.UserID != nil { + err = c.Stash().Set(shared.StashPathUserID, emailModel.UserID.String()) + if err != nil { + return fmt.Errorf("failed to set user_id to the stash: %w", err) + } + } + } + + if deps.Cfg.Saml.Enabled { + domain := strings.Split(identifierInputValue, "@")[1] + if provider, err := deps.SamlService.GetProviderByDomain(domain); err == nil && provider != nil { + authUrl, err := deps.SamlService.GetAuthUrl(provider, deps.Cfg.Saml.DefaultRedirectUrl, true) + + if err != nil { + return fmt.Errorf("failed to get auth url: %w", err) + } + + _ = c.Payload().Set("redirect_url", authUrl) + + return c.Continue(shared.StateThirdParty) + } + } + } else { + // User has submitted a username. + var err error + + userModel, err = deps.Persister.GetUserPersister().GetByUsername(identifierInputValue) + if err != nil { + return fmt.Errorf("failed to get user by username from db: %w", err) + } + + if userModel == nil { + flowInputError := shared.ErrorUnknownUsername + err = deps.AuditLogger.CreateWithConnection( + deps.Tx, + deps.HttpContext, + models.AuditLogLoginFailure, + nil, + flowInputError, + auditlog.Detail("flow_id", c.GetFlowID())) + + if err != nil { + return fmt.Errorf("could not create audit log: %w", err) + } + + c.Input().SetError(identifierInputName, flowInputError) + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + if err = c.Stash().Set(shared.StashPathUsername, identifierInputValue); err != nil { + return fmt.Errorf("failed to set username to stash: %w", err) + } + + err = c.Stash().Set(shared.StashPathUserID, userModel.ID.String()) + if err != nil { + return fmt.Errorf("failed to set user_id to the stash: %w", err) + } + if primaryEmailModel := userModel.Emails.GetPrimary(); primaryEmailModel != nil { + if err = c.Stash().Set(shared.StashPathEmail, primaryEmailModel.Address); err != nil { + return fmt.Errorf("failed to set email to stash: %w", err) + } + } + } + + if userModel != nil { + _ = c.Stash().Set(shared.StashPathUserHasPassword, userModel.PasswordCredential != nil) + _ = c.Stash().Set(shared.StashPathUserHasWebauthnCredential, len(userModel.WebauthnCredentials) > 0) + _ = c.Stash().Set(shared.StashPathUserHasUsername, len(userModel.GetUsername()) > 0) + _ = c.Stash().Set(shared.StashPathUserHasEmails, len(userModel.Emails) > 0) + } + + if !treatIdentifierAsEmail && userModel != nil && !deps.Cfg.Password.Enabled && userModel.Emails.GetPrimary() == nil { + // The user has entered a username of an existing user, but passwords are disabled, and the user does not have + // an email address to send the passcode. + return c.Error(flowpilot.ErrorFlowDiscontinuity.Wrap(errors.New("user has no email address and passwords are disabled"))) + } + + if deps.Cfg.Email.UseForAuthentication && deps.Cfg.Password.Enabled { + // Both passcode and password authentication are enabled. + if treatIdentifierAsEmail || (!treatIdentifierAsEmail && userModel != nil && userModel.Emails.GetPrimary() != nil) { + // The user has entered either an email address, or a username for an existing user who has an email address. + return c.Continue(shared.StateLoginMethodChooser) + } + + // Either no email was entered or the username does not correspond to an email, passwords are enabled. + return c.Continue(shared.StateLoginPassword) + } + + if deps.Cfg.Email.UseForAuthentication { + // Only passcode authentication is enabled; the user must use a passcode. + + // Set the login method for audit logging purposes. + if err := c.Stash().Set(shared.StashPathLoginMethod, "passcode"); err != nil { + return fmt.Errorf("failed to set login_method to stash: %w", err) + } + + if c.Stash().Get(shared.StashPathUserID).Exists() { + if err := c.Stash().Set(shared.StashPathPasscodeTemplate, "login"); err != nil { + return fmt.Errorf("failed to set passcode_template to the stash: %w", err) + } + } else { + if err := c.Stash().Set(shared.StashPathPasscodeTemplate, "email_login_attempted"); err != nil { + return fmt.Errorf("failed to set passcode_template to the stash: %w", err) + } + } + + return c.Continue(shared.StatePasscodeConfirmation) + } + + if deps.Cfg.Password.Enabled { + // Only password authentication is enabled; the user must use a password. + return c.Continue(shared.StateLoginPassword) + } + + return c.Error(flowpilot.ErrorFlowDiscontinuity.Wrap(errors.New("no authentication method enabled"))) +} + +// analyzeIdentifierInputs determines if an input value has been provided for 'identifier', 'email', or 'username', +// according to the configuration. Also adds an input error to the expected input field, if the value is missing. +// Returns the related input field name, the provided value, and a flag, indicating if the value should be treated as +// an email (and not as a username). +func (a ContinueWithLoginIdentifier) analyzeIdentifierInputs(c flowpilot.ExecutionContext) (name, value string, treatAsEmail bool) { + deps := a.GetDeps(c) + emailPattern := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) + emailEnabled := deps.Cfg.Email.Enabled && deps.Cfg.Email.UseAsLoginIdentifier + usernameEnabled := deps.Cfg.Username.Enabled && deps.Cfg.Username.UseAsLoginIdentifier + + if emailEnabled && usernameEnabled { + // analyze the 'identifier' input field + name = "identifier" + value = c.Input().Get(name).String() + treatAsEmail = emailPattern.MatchString(value) + } else if emailEnabled { + // analyze the 'email' input field + name = "email" + value = c.Input().Get(name).String() + treatAsEmail = true + } else if usernameEnabled { + // analyze the 'username' input field + name = "username" + value = c.Input().Get(name).String() + treatAsEmail = false + } + + // If no value could not be determined, set an error for the missing input + if len(value) == 0 && len(name) > 0 { + c.Input().SetError(name, flowpilot.ErrorValueMissing) + } + + return name, value, treatAsEmail +} diff --git a/backend/flow_api/flow/credential_usage/action_password_login.go b/backend/flow_api/flow/credential_usage/action_password_login.go new file mode 100644 index 000000000..f70f6f8ec --- /dev/null +++ b/backend/flow_api/flow/credential_usage/action_password_login.go @@ -0,0 +1,124 @@ +package credential_usage + +import ( + "errors" + "fmt" + "github.com/gofrs/uuid" + auditlog "github.com/teamhanko/hanko/backend/audit_log" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flow_api/services" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/persistence/models" + "github.com/teamhanko/hanko/backend/rate_limiter" +) + +type PasswordLogin struct { + shared.Action +} + +func (a PasswordLogin) GetName() flowpilot.ActionName { + return shared.ActionPasswordLogin +} + +func (a PasswordLogin) GetDescription() string { + return "Login with a password." +} + +func (a PasswordLogin) Initialize(c flowpilot.InitializationContext) { + deps := a.GetDeps(c) + + c.AddInputs(flowpilot.PasswordInput("password").Required(true)) + + if !deps.Cfg.Password.Enabled { + c.SuspendAction() + } +} + +func (a PasswordLogin) Execute(c flowpilot.ExecutionContext) error { + deps := a.GetDeps(c) + + if valid := c.ValidateInputData(); !valid { + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + if deps.Cfg.RateLimiter.Enabled { + rateLimitKey := rate_limiter.CreateRateLimitPasswordKey(deps.HttpContext.RealIP(), c.Stash().Get(shared.StashPathUserIdentification).String()) + retryAfterSeconds, ok, err := rate_limiter.Limit2(deps.PasswordRateLimiter, rateLimitKey) + if err != nil { + return fmt.Errorf("rate limiter failed: %w", err) + } + + if !ok { + err = c.Payload().Set("retry_after", retryAfterSeconds) + if err != nil { + return fmt.Errorf("failed to set a value for retry_after to the payload: %w", err) + } + return c.Error(shared.ErrorRateLimitExceeded.Wrap(fmt.Errorf("rate limit exceeded for: %s", rateLimitKey))) + } + } + + var userID uuid.UUID + + if c.Stash().Get(shared.StashPathEmail).Exists() { + emailModel, err := deps.Persister.GetEmailPersister().FindByAddress(c.Stash().Get(shared.StashPathEmail).String()) + if err != nil { + return fmt.Errorf("failed to find user by email: %w", err) + } + + if emailModel == nil { + return a.wrongCredentialsError(c) + } + + userID = *emailModel.UserID + } else if c.Stash().Get(shared.StashPathUsername).Exists() { + username := c.Stash().Get(shared.StashPathUsername).String() + userModel, err := deps.Persister.GetUserPersister().GetByUsername(username) + if err != nil { + return fmt.Errorf("failed to find user via username: %w", err) + } + + if userModel == nil { + return a.wrongCredentialsError(c) + } + + userID = userModel.ID + } else { + return a.wrongCredentialsError(c) + } + + err := deps.PasswordService.VerifyPassword(userID, c.Input().Get("password").String()) + if err != nil { + if errors.Is(err, services.ErrorPasswordInvalid) { + err = deps.AuditLogger.CreateWithConnection( + deps.Tx, + deps.HttpContext, + models.AuditLogLoginFailure, + &models.User{ID: userID}, + err, + auditlog.Detail("login_method", "password"), + auditlog.Detail("flow_id", c.GetFlowID())) + if err != nil { + return fmt.Errorf("could not create audit log: %w", err) + } + + return a.wrongCredentialsError(c) + } + + return fmt.Errorf("failed to verify password: %w", err) + } + + // Set only for audit logging purposes. + err = c.Stash().Set(shared.StashPathLoginMethod, "password") + if err != nil { + return fmt.Errorf("failed to set login_method to the stash: %w", err) + } + + c.PreventRevert() + + return c.Continue() +} + +func (a PasswordLogin) wrongCredentialsError(c flowpilot.ExecutionContext) error { + c.Input().SetError("password", flowpilot.ErrorValueInvalid) + return c.Error(flowpilot.ErrorFormDataInvalid.Wrap(errors.New("wrong credentials"))) +} diff --git a/backend/flow_api/flow/credential_usage/action_password_recovery.go b/backend/flow_api/flow/credential_usage/action_password_recovery.go new file mode 100644 index 000000000..402dd42e6 --- /dev/null +++ b/backend/flow_api/flow/credential_usage/action_password_recovery.go @@ -0,0 +1,82 @@ +package credential_usage + +import ( + "errors" + "fmt" + "github.com/gofrs/uuid" + auditlog "github.com/teamhanko/hanko/backend/audit_log" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flow_api/services" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +type PasswordRecovery struct { + shared.Action +} + +func (a PasswordRecovery) GetName() flowpilot.ActionName { + return shared.ActionPasswordRecovery +} + +func (a PasswordRecovery) GetDescription() string { + return "Submit a new password." +} + +func (a PasswordRecovery) Initialize(c flowpilot.InitializationContext) { + deps := a.GetDeps(c) + + c.AddInputs(flowpilot.PasswordInput("new_password"). + Required(true). + MinLength(deps.Cfg.Password.MinLength), + ) + + if !deps.Cfg.Password.Enabled { + c.SuspendAction() + } +} + +func (a PasswordRecovery) Execute(c flowpilot.ExecutionContext) error { + deps := a.GetDeps(c) + + newPassword := c.Input().Get("new_password").String() + + if !c.Stash().Get(shared.StashPathUserID).Exists() { + return c.Error(flowpilot.ErrorOperationNotPermitted.Wrap(errors.New("user_id does not exist"))) + } + + authUserID := c.Stash().Get(shared.StashPathUserID).String() + + err := deps.PasswordService.RecoverPassword(uuid.FromStringOrNil(authUserID), newPassword) + + if err != nil { + if errors.Is(err, services.ErrorPasswordInvalid) { + c.Input().SetError("password", flowpilot.ErrorValueInvalid) + return c.Error(flowpilot.ErrorFormDataInvalid.Wrap(err)) + } + + return fmt.Errorf("could not recover password: %w", err) + } + + err = deps.AuditLogger.CreateWithConnection( + deps.Tx, + deps.HttpContext, + models.AuditLogPasswordChanged, + &models.User{ID: uuid.FromStringOrNil(authUserID)}, + nil, + auditlog.Detail("context", "recovery"), + auditlog.Detail("flow_id", c.GetFlowID())) + + if err != nil { + return fmt.Errorf("could not create audit log: %w", err) + } + + err = c.Stash().Set(shared.StashPathUserHasPassword, true) + if err != nil { + return fmt.Errorf("failed to set user_has_password to the stash: %w", err) + } + + c.PreventRevert() + + return c.Continue() +} diff --git a/backend/flow_api/flow/credential_usage/action_resend_passcode.go b/backend/flow_api/flow/credential_usage/action_resend_passcode.go new file mode 100644 index 000000000..acf63f85f --- /dev/null +++ b/backend/flow_api/flow/credential_usage/action_resend_passcode.go @@ -0,0 +1,69 @@ +package credential_usage + +import ( + "errors" + "fmt" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flow_api/services" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/rate_limiter" +) + +type ReSendPasscode struct { + shared.Action +} + +func (a ReSendPasscode) GetName() flowpilot.ActionName { + return shared.ActionResendPasscode +} + +func (a ReSendPasscode) GetDescription() string { + return "Send the passcode email again." +} + +func (a ReSendPasscode) Initialize(_ flowpilot.InitializationContext) {} + +func (a ReSendPasscode) Execute(c flowpilot.ExecutionContext) error { + deps := a.GetDeps(c) + + if !c.Stash().Get(shared.StashPathEmail).Exists() { + return errors.New("email has not been stashed") + } + + if !c.Stash().Get(shared.StashPathPasscodeTemplate).Exists() { + return errors.New("passcode_template has not been stashed") + } + + if deps.Cfg.RateLimiter.Enabled { + rateLimitKey := rate_limiter.CreateRateLimitPasscodeKey(deps.HttpContext.RealIP(), c.Stash().Get(shared.StashPathEmail).String()) + resendAfterSeconds, ok, err := rate_limiter.Limit2(deps.PasscodeRateLimiter, rateLimitKey) + if err != nil { + return fmt.Errorf("rate limiter failed: %w", err) + } + + if !ok { + err = c.Payload().Set("resend_after", resendAfterSeconds) + if err != nil { + return fmt.Errorf("failed to set a value for resend_after to the payload: %w", err) + } + return c.Error(shared.ErrorRateLimitExceeded.Wrap(fmt.Errorf("rate limit exceeded for: %s", rateLimitKey))) + } + } + + sendParams := services.SendPasscodeParams{ + Template: c.Stash().Get(shared.StashPathPasscodeTemplate).String(), + EmailAddress: c.Stash().Get(shared.StashPathEmail).String(), + Language: deps.HttpContext.Request().Header.Get("Accept-Language"), + } + passcodeID, err := deps.PasscodeService.SendPasscode(sendParams) + if err != nil { + return fmt.Errorf("passcode service failed: %w", err) + } + + err = c.Stash().Set(shared.StashPathPasscodeID, passcodeID) + if err != nil { + return fmt.Errorf("failed to set passcode_id to stash: %w", err) + } + + return c.Continue(c.GetCurrentState()) +} diff --git a/backend/flow_api/flow/credential_usage/action_verify_passcode.go b/backend/flow_api/flow/credential_usage/action_verify_passcode.go new file mode 100644 index 000000000..8f11d6dfa --- /dev/null +++ b/backend/flow_api/flow/credential_usage/action_verify_passcode.go @@ -0,0 +1,110 @@ +package credential_usage + +import ( + "errors" + "fmt" + "github.com/gofrs/uuid" + auditlog "github.com/teamhanko/hanko/backend/audit_log" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flow_api/services" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +type VerifyPasscode struct { + shared.Action +} + +func (a VerifyPasscode) GetName() flowpilot.ActionName { + return shared.ActionVerifyPasscode +} + +func (a VerifyPasscode) GetDescription() string { + return "Enter a passcode." +} + +func (a VerifyPasscode) Initialize(c flowpilot.InitializationContext) { + c.AddInputs(flowpilot.StringInput("code").Required(true)) +} + +func (a VerifyPasscode) Execute(c flowpilot.ExecutionContext) error { + deps := a.GetDeps(c) + + if valid := c.ValidateInputData(); !valid { + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + if !c.Stash().Get(shared.StashPathPasscodeID).Exists() { + return errors.New("passcode_id does not exist in the stash") + } + + passcodeID := uuid.FromStringOrNil(c.Stash().Get(shared.StashPathPasscodeID).String()) + err := deps.PasscodeService.VerifyPasscodeCode(deps.Tx, passcodeID, c.Input().Get("code").String()) + if err != nil { + if errors.Is(err, services.ErrorPasscodeInvalid) || + errors.Is(err, services.ErrorPasscodeNotFound) || + errors.Is(err, services.ErrorPasscodeExpired) { + + if c.Stash().Get(shared.StashPathLoginMethod).Exists() { + err = deps.AuditLogger.CreateWithConnection( + deps.Tx, + deps.HttpContext, + models.AuditLogLoginFailure, + &models.User{ID: uuid.FromStringOrNil(c.Stash().Get(shared.StashPathUserID).String())}, + err, + auditlog.Detail("login_method", "passcode"), + auditlog.Detail("flow_id", c.GetFlowID())) + + if err != nil { + return fmt.Errorf("could not create audit log: %w", err) + } + } + + return c.Error(shared.ErrorPasscodeInvalid) + } + + if errors.Is(err, services.ErrorPasscodeMaxAttemptsReached) { + if c.Stash().Get(shared.StashPathLoginMethod).Exists() { + err = deps.AuditLogger.CreateWithConnection( + deps.Tx, + deps.HttpContext, + models.AuditLogLoginFailure, + &models.User{ID: uuid.FromStringOrNil(c.Stash().Get(shared.StashPathUserID).String())}, + err, + auditlog.Detail("login_method", "passcode"), + auditlog.Detail("flow_id", c.GetFlowID())) + + if err != nil { + return fmt.Errorf("could not create audit log: %w", err) + } + } + + return c.Error(shared.ErrorPasscodeMaxAttemptsReached) + } + + return fmt.Errorf("failed to verify passcode: %w", err) + } + + err = c.Stash().Delete("passcode_id") + if err != nil { + return fmt.Errorf("failed to delete passcode_id from stash: %w", err) + } + + err = c.Stash().Delete("passcode_email") + if err != nil { + return fmt.Errorf("failed to delete passcode_email from stash: %w", err) + } + + if !c.Stash().Get(shared.StashPathUserID).Exists() { + return c.Error(flowpilot.ErrorOperationNotPermitted.Wrap(errors.New("account does not exist"))) + } + + err = c.Stash().Set(shared.StashPathEmailVerified, true) + if err != nil { + return err + } + + c.PreventRevert() + + return c.Continue() +} diff --git a/backend/flow_api/flow/credential_usage/action_webauthn_generate_request_options.go b/backend/flow_api/flow/credential_usage/action_webauthn_generate_request_options.go new file mode 100644 index 000000000..810af9f1a --- /dev/null +++ b/backend/flow_api/flow/credential_usage/action_webauthn_generate_request_options.go @@ -0,0 +1,56 @@ +package credential_usage + +import ( + "fmt" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flow_api/services" + "github.com/teamhanko/hanko/backend/flowpilot" +) + +type WebauthnGenerateRequestOptions struct { + shared.Action +} + +func (a WebauthnGenerateRequestOptions) GetName() flowpilot.ActionName { + return shared.ActionWebauthnGenerateRequestOptions +} + +func (a WebauthnGenerateRequestOptions) GetDescription() string { + return "Get webauthn request options in order to sign in with a webauthn credential." +} + +func (a WebauthnGenerateRequestOptions) Initialize(c flowpilot.InitializationContext) { + deps := a.GetDeps(c) + + if !c.Stash().Get(shared.StashPathWebauthnAvailable).Bool() || !deps.Cfg.Passkey.Enabled { + c.SuspendAction() + } +} + +func (a WebauthnGenerateRequestOptions) Execute(c flowpilot.ExecutionContext) error { + deps := a.GetDeps(c) + + params := services.GenerateRequestOptionsParams{Tx: deps.Tx} + + sessionDataModel, requestOptions, err := deps.WebauthnService.GenerateRequestOptions(params) + if err != nil { + return fmt.Errorf("failed to generate webauthn request options: %w", err) + } + + err = c.Stash().Set(shared.StashPathWebauthnSessionDataID, sessionDataModel.ID) + if err != nil { + return fmt.Errorf("failed to stash webauthn_session_data_id: %w", err) + } + + err = c.Stash().Set(shared.StashPathUserID, sessionDataModel.UserId) + if err != nil { + return fmt.Errorf("failed to stash user_id: %w", err) + } + + err = c.Payload().Set("request_options", requestOptions) + if err != nil { + return fmt.Errorf("failed to set request_options payload: %w", err) + } + + return c.Continue(shared.StateLoginPasskey) +} diff --git a/backend/flow_api/flow/credential_usage/action_webauthn_verify_assertion_response.go b/backend/flow_api/flow/credential_usage/action_webauthn_verify_assertion_response.go new file mode 100644 index 000000000..1d6ac20b0 --- /dev/null +++ b/backend/flow_api/flow/credential_usage/action_webauthn_verify_assertion_response.go @@ -0,0 +1,99 @@ +package credential_usage + +import ( + "errors" + "fmt" + "github.com/gofrs/uuid" + auditlog "github.com/teamhanko/hanko/backend/audit_log" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flow_api/services" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +type WebauthnVerifyAssertionResponse struct { + shared.Action +} + +func (a WebauthnVerifyAssertionResponse) GetName() flowpilot.ActionName { + return shared.ActionWebauthnVerifyAssertionResponse +} + +func (a WebauthnVerifyAssertionResponse) GetDescription() string { + return "Send the result which was generated by using a webauthn credential." +} + +func (a WebauthnVerifyAssertionResponse) Initialize(c flowpilot.InitializationContext) { + deps := a.GetDeps(c) + + if !c.Stash().Get(shared.StashPathWebauthnAvailable).Bool() || !deps.Cfg.Passkey.Enabled { + c.SuspendAction() + } + + c.AddInputs(flowpilot.JSONInput("assertion_response").Required(true)) +} + +func (a WebauthnVerifyAssertionResponse) Execute(c flowpilot.ExecutionContext) error { + deps := a.GetDeps(c) + + if valid := c.ValidateInputData(); !valid { + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + if !c.Stash().Get(shared.StashPathWebauthnSessionDataID).Exists() { + return errors.New("webauthn_session_data_id is not present in the stash") + } + + sessionDataID := uuid.FromStringOrNil(c.Stash().Get(shared.StashPathWebauthnSessionDataID).String()) + assertionResponse := c.Input().Get("assertion_response").String() + + params := services.VerifyAssertionResponseParams{ + Tx: deps.Tx, + SessionDataID: sessionDataID, + AssertionResponse: assertionResponse, + } + + userModel, err := deps.WebauthnService.VerifyAssertionResponse(params) + if err != nil { + if errors.Is(err, services.ErrInvalidWebauthnCredential) { + err = deps.AuditLogger.CreateWithConnection( + deps.Tx, + deps.HttpContext, + models.AuditLogLoginFailure, + userModel, + err, + auditlog.Detail("login_method", "passkey"), + auditlog.Detail("flow_id", c.GetFlowID())) + + if err != nil { + return fmt.Errorf("could not create audit log: %w", err) + } + + return c.Error(shared.ErrorPasskeyInvalid.Wrap(err)) + } + + return fmt.Errorf("failed to verify assertion response: %w", err) + } + + err = c.Stash().Set(shared.StashPathUserID, userModel.ID.String()) + if err != nil { + return fmt.Errorf("failed to set user_id to the stash: %w", err) + } + + // Set only for audit logging purposes. + err = c.Stash().Set(shared.StashPathLoginMethod, "passkey") + if err != nil { + return fmt.Errorf("failed to set login_method to the stash: %w", err) + } + + if userModel != nil { + _ = c.Stash().Set(shared.StashPathUserHasPassword, userModel.PasswordCredential != nil) + _ = c.Stash().Set(shared.StashPathUserHasWebauthnCredential, len(userModel.WebauthnCredentials) > 0) + _ = c.Stash().Set(shared.StashPathUserHasUsername, len(userModel.GetUsername()) > 0) + _ = c.Stash().Set(shared.StashPathUserHasEmails, len(userModel.Emails) > 0) + } + + c.PreventRevert() + + return c.Continue() +} diff --git a/backend/flow_api/flow/credential_usage/hook_send_passcode.go b/backend/flow_api/flow/credential_usage/hook_send_passcode.go new file mode 100644 index 000000000..3947c1bf8 --- /dev/null +++ b/backend/flow_api/flow/credential_usage/hook_send_passcode.go @@ -0,0 +1,86 @@ +package credential_usage + +import ( + "errors" + "fmt" + "github.com/gofrs/uuid" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flow_api/services" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/rate_limiter" +) + +type SendPasscode struct { + shared.Action +} + +func (h SendPasscode) Execute(c flowpilot.HookExecutionContext) error { + deps := h.GetDeps(c) + + if c.GetFlowError() != nil { + return nil + } + + if !c.Stash().Get(shared.StashPathEmail).Exists() { + return errors.New("email has not been stashed") + } + + if !c.Stash().Get(shared.StashPathPasscodeTemplate).Exists() { + return errors.New("passcode_template has not been stashed") + } + + if deps.Cfg.RateLimiter.Enabled { + rateLimitKey := rate_limiter.CreateRateLimitPasscodeKey(deps.HttpContext.RealIP(), c.Stash().Get(shared.StashPathEmail).String()) + resendAfterSeconds, ok, err := rate_limiter.Limit2(deps.PasscodeRateLimiter, rateLimitKey) + if err != nil { + return fmt.Errorf("rate limiter failed: %w", err) + } + + if !ok { + err = c.Payload().Set("resend_after", resendAfterSeconds) + if err != nil { + return fmt.Errorf("failed to set a value for resend_after to the payload: %w", err) + } + + c.SetFlowError(shared.ErrorRateLimitExceeded.Wrap(fmt.Errorf("rate limit exceeded for: %s", rateLimitKey))) + return nil + } + } + + validationParams := services.ValidatePasscodeParams{ + Tx: deps.Tx, + PasscodeID: uuid.FromStringOrNil(c.Stash().Get(shared.StashPathPasscodeID).String()), + } + + passcodeIsValid, err := deps.PasscodeService.ValidatePasscode(validationParams) + if err != nil { + return fmt.Errorf("failed to validate existing passcode: %w", err) + } + + isDifferentEmailAddress := c.Stash().Get(shared.StashPathEmail).String() != c.Stash().Get(shared.StashPathPasscodeEmail).String() + + if !passcodeIsValid || isDifferentEmailAddress { + sendParams := services.SendPasscodeParams{ + Template: c.Stash().Get(shared.StashPathPasscodeTemplate).String(), + EmailAddress: c.Stash().Get(shared.StashPathEmail).String(), + Language: deps.HttpContext.Request().Header.Get("Accept-Language"), + } + + passcodeID, err := deps.PasscodeService.SendPasscode(sendParams) + if err != nil { + return fmt.Errorf("passcode service failed: %w", err) + } + + err = c.Stash().Set(shared.StashPathPasscodeID, passcodeID) + if err != nil { + return fmt.Errorf("failed to set passcode_id to stash: %w", err) + } + + err = c.Stash().Set(shared.StashPathPasscodeEmail, c.Stash().Get(shared.StashPathEmail).String()) + if err != nil { + return fmt.Errorf("failed to set passcode_email to stash: %w", err) + } + } + + return nil +} diff --git a/backend/flow_api/flow/flows.go b/backend/flow_api/flow/flows.go new file mode 100644 index 000000000..cd04a46f8 --- /dev/null +++ b/backend/flow_api/flow/flows.go @@ -0,0 +1,155 @@ +package flow + +import ( + "github.com/teamhanko/hanko/backend/flow_api/flow/capabilities" + "github.com/teamhanko/hanko/backend/flow_api/flow/credential_onboarding" + "github.com/teamhanko/hanko/backend/flow_api/flow/credential_usage" + "github.com/teamhanko/hanko/backend/flow_api/flow/login" + "github.com/teamhanko/hanko/backend/flow_api/flow/profile" + "github.com/teamhanko/hanko/backend/flow_api/flow/registration" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flow_api/flow/user_details" + "github.com/teamhanko/hanko/backend/flowpilot" + "time" +) + +var CapabilitiesSubFlow = flowpilot.NewSubFlow(shared.FlowCapabilities). + State(shared.StatePreflight, capabilities.RegisterClientCapabilities{}). + MustBuild() + +var CredentialUsageSubFlow = flowpilot.NewSubFlow(shared.FlowCredentialUsage). + State(shared.StateLoginInit, + credential_usage.ContinueWithLoginIdentifier{}, + credential_usage.WebauthnGenerateRequestOptions{}, + credential_usage.WebauthnVerifyAssertionResponse{}, + shared.ThirdPartyOAuth{}). + State(shared.StateLoginPasskey, + credential_usage.WebauthnVerifyAssertionResponse{}, + shared.Back{}). + State(shared.StateThirdParty, + shared.ExchangeToken{}). + State(shared.StateLoginMethodChooser, + credential_usage.ContinueToPasswordLogin{}, + credential_usage.ContinueToPasscodeConfirmation{}, + shared.Back{}, + ). + State(shared.StateLoginPassword, + credential_usage.PasswordLogin{}, + credential_usage.ContinueToPasscodeConfirmationRecovery{}, + shared.Back{}, + ). + State(shared.StateLoginPasswordRecovery, + credential_usage.PasswordRecovery{}). + State(shared.StatePasscodeConfirmation, + credential_usage.VerifyPasscode{}, + credential_usage.ReSendPasscode{}, + shared.Back{}). + BeforeState(shared.StatePasscodeConfirmation, + credential_usage.SendPasscode{}). + MustBuild() + +var CredentialOnboardingSubFlow = flowpilot.NewSubFlow(shared.FlowCredentialOnboarding). + State(shared.StateCredentialOnboardingChooser, + credential_onboarding.ContinueToPasskey{}, + credential_onboarding.ContinueToPassword{}, + credential_onboarding.SkipCredentialOnboardingMethodChooser{}, + shared.Back{}). + State(shared.StateOnboardingCreatePasskey, + credential_onboarding.WebauthnGenerateCreationOptions{}, + credential_onboarding.SkipPasskey{}, + shared.Back{}). + State(shared.StateOnboardingVerifyPasskeyAttestation, + credential_onboarding.WebauthnVerifyAttestationResponse{}, + shared.Back{}). + State(shared.StatePasswordCreation, + credential_onboarding.RegisterPassword{}, + credential_onboarding.SkipPassword{}, + shared.Back{}). + MustBuild() + +var UserDetailsSubFlow = flowpilot.NewSubFlow(shared.FlowUserDetails). + State(shared.StateOnboardingUsername, + user_details.UsernameSet{}, + user_details.SkipUsername{}). + State(shared.StateOnboardingEmail, + user_details.EmailAddressSet{}, + user_details.SkipEmail{}). + MustBuild() + +var LoginFlow = flowpilot.NewFlow(shared.FlowLogin). + State(shared.StateSuccess). + InitialState(shared.StatePreflight, shared.StateLoginInit). + ErrorState(shared.StateError). + BeforeState(shared.StateLoginInit, + login.WebauthnGenerateRequestOptionsForConditionalUi{}). + BeforeState(shared.StateSuccess, + shared.IssueSession{}, + shared.GetUserData{}). + AfterState(shared.StateOnboardingVerifyPasskeyAttestation, + shared.WebauthnCredentialSave{}). + AfterState(shared.StatePasscodeConfirmation, + shared.EmailPersistVerifiedStatus{}). + AfterState(shared.StatePasswordCreation, + shared.PasswordSave{}). + AfterState(shared.StateOnboardingEmail, login.CreateEmail{}). + AfterState(shared.StatePasscodeConfirmation, login.CreateEmail{}). + AfterFlow(shared.FlowCredentialUsage, login.ScheduleOnboardingStates{}). + SubFlows( + CapabilitiesSubFlow, + CredentialUsageSubFlow, + CredentialOnboardingSubFlow, + UserDetailsSubFlow). + TTL(24 * time.Hour) + +var RegistrationFlow = flowpilot.NewFlow(shared.FlowRegistration). + State(shared.StateRegistrationInit, + registration.RegisterLoginIdentifier{}, + shared.ThirdPartyOAuth{}). + State(shared.StateThirdParty, + shared.ExchangeToken{}). + State(shared.StateSuccess). + InitialState(shared.StatePreflight, + shared.StateRegistrationInit). + ErrorState(shared.StateError). + BeforeState(shared.StateSuccess, + shared.GetUserData{}, + registration.CreateUser{}, + shared.IssueSession{}). + SubFlows( + CapabilitiesSubFlow, + CredentialUsageSubFlow, + CredentialOnboardingSubFlow, + UserDetailsSubFlow). + TTL(24 * time.Hour) + +var ProfileFlow = flowpilot.NewFlow(shared.FlowProfile). + State(shared.StateProfileInit, + profile.AccountDelete{}, + profile.EmailCreate{}, + profile.EmailDelete{}, + profile.EmailSetPrimary{}, + profile.EmailVerify{}, + profile.PasswordCreate{}, + profile.PasswordUpdate{}, + profile.PasswordDelete{}, + profile.UsernameCreate{}, + profile.UsernameUpdate{}, + profile.UsernameDelete{}, + profile.WebauthnCredentialRename{}, + profile.WebauthnCredentialCreate{}, + profile.WebauthnCredentialDelete{}, + ). + State(shared.StateProfileWebauthnCredentialVerification, + profile.WebauthnVerifyAttestationResponse{}, + shared.Back{}). + State(shared.StateProfileAccountDeleted). + InitialState(shared.StatePreflight, shared.StateProfileInit). + ErrorState(shared.StateError). + BeforeEachAction(profile.RefreshSessionUser{}). + BeforeState(shared.StateProfileInit, profile.GetProfileData{}). + AfterState(shared.StateProfileWebauthnCredentialVerification, shared.WebauthnCredentialSave{}). + AfterState(shared.StatePasscodeConfirmation, shared.EmailPersistVerifiedStatus{}). + SubFlows( + CapabilitiesSubFlow, + CredentialUsageSubFlow). + TTL(24 * time.Hour) diff --git a/backend/flow_api/flow/login/hook_create_email.go b/backend/flow_api/flow/login/hook_create_email.go new file mode 100644 index 000000000..b317d6dda --- /dev/null +++ b/backend/flow_api/flow/login/hook_create_email.go @@ -0,0 +1,45 @@ +package login + +import ( + "fmt" + "github.com/gofrs/uuid" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +type CreateEmail struct { + shared.Action +} + +func (h CreateEmail) Execute(c flowpilot.HookExecutionContext) error { + deps := h.GetDeps(c) + + if !c.Stash().Get(shared.StashPathEmail).Exists() || (deps.Cfg.Email.RequireVerification && !c.Stash().Get(shared.StashPathEmailVerified).Bool()) { + return nil + } + + if !c.Stash().Get(shared.StashPathLoginOnboardingCreateEmail).Bool() { + return nil + } + + if err := c.Stash().Delete(shared.StashPathLoginOnboardingCreateEmail); err != nil { + return fmt.Errorf("failed to delete login_onboarding_create_email from the stash: %w", err) + } + + userID := uuid.FromStringOrNil(c.Stash().Get(shared.StashPathUserID).String()) + emailModel := models.NewEmail(&userID, c.Stash().Get(shared.StashPathEmail).String()) + + err := deps.Persister.GetEmailPersisterWithConnection(deps.Tx).Create(*emailModel) + if err != nil { + return fmt.Errorf("failed to create a new email: %w", err) + } + + primaryEmail := models.NewPrimaryEmail(emailModel.ID, userID) + err = deps.Persister.GetPrimaryEmailPersisterWithConnection(deps.Tx).Create(*primaryEmail) + if err != nil { + return fmt.Errorf("failed to create a new primary email: %w", err) + } + + return nil +} diff --git a/backend/flow_api/flow/login/hook_schedule_onboarding_states.go b/backend/flow_api/flow/login/hook_schedule_onboarding_states.go new file mode 100644 index 000000000..55c410673 --- /dev/null +++ b/backend/flow_api/flow/login/hook_schedule_onboarding_states.go @@ -0,0 +1,133 @@ +package login + +import ( + "fmt" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flowpilot" +) + +type ScheduleOnboardingStates struct { + shared.Action +} + +func (h ScheduleOnboardingStates) Execute(c flowpilot.HookExecutionContext) error { + deps := h.GetDeps(c) + + if c.Stash().Get(shared.StashPathLoginOnboardingScheduled).Bool() { + return nil + } + + if err := c.Stash().Set(shared.StashPathLoginOnboardingScheduled, true); err != nil { + return fmt.Errorf("failed to set login_onboarding_scheduled to the stash: %w", err) + } + + userHasPassword := deps.Cfg.Password.Enabled && c.Stash().Get(shared.StashPathUserHasPassword).Bool() + userHasPasskey := deps.Cfg.Passkey.Enabled && c.Stash().Get(shared.StashPathUserHasWebauthnCredential).Bool() + userHasUsername := deps.Cfg.Username.Enabled && c.Stash().Get(shared.StashPathUserHasUsername).Bool() + userHasEmail := deps.Cfg.Email.Enabled && c.Stash().Get(shared.StashPathUserHasEmails).Bool() + + if err := c.Stash().Set(shared.StashPathUserHasPassword, userHasPassword); err != nil { + return fmt.Errorf("failed to set user_has_password to the stash: %w", err) + } + + if err := c.Stash().Set(shared.StashPathUserHasWebauthnCredential, userHasPasskey); err != nil { + return fmt.Errorf("failed to set user_has_webauthn_credential to the stash: %w", err) + } + + userDetailOnboardingStates := h.determineUserDetailOnboardingStates(c, userHasUsername, userHasEmail) + credentialOnboardingStates := h.determineCredentialOnboardingStates(c, userHasPasskey, userHasPassword) + + c.ScheduleStates(append(userDetailOnboardingStates, append(credentialOnboardingStates, shared.StateSuccess)...)...) + + return nil +} + +func (h ScheduleOnboardingStates) determineCredentialOnboardingStates(c flowpilot.HookExecutionContext, hasPasskey, hasPassword bool) []flowpilot.StateName { + deps := h.GetDeps(c) + cfg := deps.Cfg + result := make([]flowpilot.StateName, 0) + + webauthnAvailable := c.Stash().Get(shared.StashPathWebauthnAvailable).Bool() + passkeyEnabled := webauthnAvailable && deps.Cfg.Passkey.Enabled + passwordEnabled := deps.Cfg.Password.Enabled + passwordAndPasskeyEnabled := passkeyEnabled && passwordEnabled + + alwaysAcquirePasskey := cfg.Passkey.AcquireOnLogin == "always" + alwaysAcquirePassword := cfg.Password.AcquireOnLogin == "always" + conditionalAcquirePasskey := cfg.Passkey.AcquireOnLogin == "conditional" + conditionalAcquirePassword := cfg.Password.AcquireOnLogin == "conditional" + neverAcquirePasskey := cfg.Passkey.AcquireOnLogin == "never" + neverAcquirePassword := cfg.Password.AcquireOnLogin == "never" + + if passwordAndPasskeyEnabled { + if alwaysAcquirePasskey && alwaysAcquirePassword { + if !hasPasskey && !hasPassword { + if !cfg.Password.Optional && cfg.Passkey.Optional { + result = append(result, shared.StatePasswordCreation, shared.StateOnboardingCreatePasskey) + } else { + result = append(result, shared.StateOnboardingCreatePasskey, shared.StatePasswordCreation) + } + } else if hasPasskey && !hasPassword { + result = append(result, shared.StatePasswordCreation) + } else if !hasPasskey && hasPassword { + result = append(result, shared.StateOnboardingCreatePasskey) + } + } else if alwaysAcquirePasskey && conditionalAcquirePassword { + if !hasPasskey && !hasPassword { + result = append(result, shared.StateOnboardingCreatePasskey) // skip should lead to password onboarding + } else if !hasPasskey && hasPassword { + result = append(result, shared.StateOnboardingCreatePasskey) + } + } else if conditionalAcquirePasskey && alwaysAcquirePassword { + if !hasPasskey && !hasPassword { + result = append(result, shared.StatePasswordCreation) // skip should lead to passkey onboarding + } else if hasPasskey && !hasPassword { + result = append(result, shared.StatePasswordCreation) + } + } else if conditionalAcquirePasskey && conditionalAcquirePassword { + if !hasPasskey && !hasPassword { + result = append(result, shared.StateCredentialOnboardingChooser) // credential_onboarding_chooser can be skipped + } + } else if conditionalAcquirePasskey && neverAcquirePassword { + if !hasPasskey && !hasPassword { + result = append(result, shared.StateOnboardingCreatePasskey) + } + } else if neverAcquirePasskey && conditionalAcquirePassword { + if !hasPasskey && !hasPassword { + result = append(result, shared.StatePasswordCreation) + } + } else if neverAcquirePasskey && alwaysAcquirePassword { + if !hasPassword { + result = append(result, shared.StatePasswordCreation) + } + } else if alwaysAcquirePasskey && neverAcquirePassword { + if !hasPasskey { + result = append(result, shared.StateOnboardingCreatePasskey) + } + } + } else if passkeyEnabled && (alwaysAcquirePasskey || conditionalAcquirePasskey) { + result = append(result, shared.StateOnboardingCreatePasskey) + } else if passwordEnabled && (alwaysAcquirePassword || conditionalAcquirePassword) { + result = append(result, shared.StatePasswordCreation) + } + + return result +} + +func (h ScheduleOnboardingStates) determineUserDetailOnboardingStates(c flowpilot.HookExecutionContext, userHasUsername, userHasEmail bool) []flowpilot.StateName { + deps := h.GetDeps(c) + cfg := deps.Cfg + result := make([]flowpilot.StateName, 0) + acquireUsername := !userHasUsername && cfg.Username.Enabled && cfg.Username.AcquireOnLogin + acquireEmail := !userHasEmail && cfg.Email.Enabled && cfg.Email.AcquireOnLogin + + if acquireUsername && acquireEmail { + result = append(result, shared.StateOnboardingUsername, shared.StateOnboardingEmail) + } else if acquireUsername { + result = append(result, shared.StateOnboardingUsername) + } else if acquireEmail { + result = append(result, shared.StateOnboardingEmail) + } + + return result +} diff --git a/backend/flow_api/flow/login/hook_webauthn_generate_request_options_cond.go b/backend/flow_api/flow/login/hook_webauthn_generate_request_options_cond.go new file mode 100644 index 000000000..0274e63b1 --- /dev/null +++ b/backend/flow_api/flow/login/hook_webauthn_generate_request_options_cond.go @@ -0,0 +1,47 @@ +package login + +import ( + "fmt" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flow_api/services" + "github.com/teamhanko/hanko/backend/flowpilot" +) + +type WebauthnGenerateRequestOptionsForConditionalUi struct { + shared.Action +} + +func (a WebauthnGenerateRequestOptionsForConditionalUi) Execute(c flowpilot.HookExecutionContext) error { + if !c.Stash().Get(shared.StashPathWebauthnAvailable).Bool() { + return nil + } + + if !c.Stash().Get(shared.StashPathWebauthnConditionalMediationAvailable).Bool() { + return nil + } + + deps := a.GetDeps(c) + + if !deps.Cfg.Passkey.Enabled { + return nil + } + + params := services.GenerateRequestOptionsParams{Tx: deps.Tx} + + sessionDataModel, requestOptions, err := deps.WebauthnService.GenerateRequestOptions(params) + if err != nil { + return fmt.Errorf("failed to generate webauthn request options: %w", err) + } + + err = c.Stash().Set(shared.StashPathWebauthnSessionDataID, sessionDataModel.ID) + if err != nil { + return fmt.Errorf("failed to stash webauthn_session_data_id: %w", err) + } + + err = c.Payload().Set("request_options", requestOptions) + if err != nil { + return fmt.Errorf("failed to set request_options payload: %w", err) + } + + return nil +} diff --git a/backend/flow_api/flow/profile/action_account_delete.go b/backend/flow_api/flow/profile/action_account_delete.go new file mode 100644 index 000000000..1e3418c8c --- /dev/null +++ b/backend/flow_api/flow/profile/action_account_delete.go @@ -0,0 +1,64 @@ +package profile + +import ( + "fmt" + auditlog "github.com/teamhanko/hanko/backend/audit_log" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +type AccountDelete struct { + shared.Action +} + +func (a AccountDelete) GetName() flowpilot.ActionName { + return shared.ActionAccountDelete +} + +func (a AccountDelete) GetDescription() string { + return "Delete an account." +} + +func (a AccountDelete) Initialize(c flowpilot.InitializationContext) { + deps := a.GetDeps(c) + + if !deps.Cfg.Account.AllowDeletion { + c.SuspendAction() + } +} + +func (a AccountDelete) Execute(c flowpilot.ExecutionContext) error { + deps := a.GetDeps(c) + + userModel, ok := c.Get("session_user").(*models.User) + if !ok { + return c.Error(flowpilot.ErrorOperationNotPermitted) + } + + err := deps.Persister.GetUserPersisterWithConnection(deps.Tx).Delete(*userModel) + if err != nil { + return fmt.Errorf("could not delete user: %w", err) + } + + err = deps.AuditLogger.CreateWithConnection( + deps.Tx, + deps.HttpContext, + models.AuditLogUserDeleted, + &models.User{ID: userModel.ID}, + nil, + auditlog.Detail("flow_id", c.GetFlowID())) + + if err != nil { + return fmt.Errorf("could not create audit log: %w", err) + } + + cookie, err := deps.SessionManager.DeleteCookie() + if err != nil { + return fmt.Errorf("could not delete cookie: %w", err) + } + + deps.HttpContext.SetCookie(cookie) + + return c.Continue(shared.StateProfileAccountDeleted) +} diff --git a/backend/flow_api/flow/profile/action_email_create.go b/backend/flow_api/flow/profile/action_email_create.go new file mode 100644 index 000000000..5af4878bb --- /dev/null +++ b/backend/flow_api/flow/profile/action_email_create.go @@ -0,0 +1,129 @@ +package profile + +import ( + "fmt" + auditlog "github.com/teamhanko/hanko/backend/audit_log" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +type EmailCreate struct { + shared.Action +} + +func (a EmailCreate) GetName() flowpilot.ActionName { + return shared.ActionEmailCreate +} + +func (a EmailCreate) GetDescription() string { + return "Create an email address for the current session user." +} + +func (a EmailCreate) Initialize(c flowpilot.InitializationContext) { + deps := a.GetDeps(c) + + userModel, ok := c.Get("session_user").(*models.User) + + if !deps.Cfg.Email.Enabled || (ok && len(userModel.Emails) >= deps.Cfg.Email.Limit) { + c.SuspendAction() + } else { + c.AddInputs(flowpilot.EmailInput("email").Required(true).MaxLength(deps.Cfg.Email.MaxLength).TrimSpace(true).LowerCase(true)) + } +} + +func (a EmailCreate) Execute(c flowpilot.ExecutionContext) error { + deps := a.GetDeps(c) + + if valid := c.ValidateInputData(); !valid { + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + userModel, ok := c.Get("session_user").(*models.User) + if !ok { + return c.Error(flowpilot.ErrorOperationNotPermitted) + } + + newEmailAddress := c.Input().Get("email").String() + + existingEmailModel, err := deps.Persister.GetEmailPersisterWithConnection(deps.Tx).FindByAddress(newEmailAddress) + if err != nil { + return fmt.Errorf("could not fetch email: %w", err) + } + + if existingEmailModel != nil { + if (existingEmailModel.UserID != nil && existingEmailModel.UserID.String() == userModel.ID.String()) || !deps.Cfg.Email.RequireVerification { + c.Input().SetError("email", shared.ErrorEmailAlreadyExists) + return c.Error(flowpilot.ErrorFormDataInvalid) + } else { + err = c.CopyInputValuesToStash("email") + if err != nil { + return fmt.Errorf("failed to copy email to stash: %w", err) + } + + err = c.Stash().Set(shared.StashPathUserID, userModel.ID.String()) + if err != nil { + return fmt.Errorf("failed to set user_id to stash: %w", err) + } + + err = c.Stash().Set(shared.StashPathPasscodeTemplate, "email_registration_attempted") + if err != nil { + return fmt.Errorf("failed to set passcode_template to the stash: %w", err) + } + + return c.Continue(shared.StatePasscodeConfirmation) + } + } else if deps.Cfg.Email.RequireVerification { + err = c.CopyInputValuesToStash("email") + if err != nil { + return fmt.Errorf("failed to copy email to stash: %w", err) + } + + err = c.Stash().Set(shared.StashPathUserID, userModel.ID.String()) + if err != nil { + return fmt.Errorf("failed to set user_id to stash: %w", err) + } + + err = c.Stash().Set(shared.StashPathPasscodeTemplate, "email_verification") + if err != nil { + return fmt.Errorf("failed to set passcode_template to the stash: %w", err) + } + + return c.Continue(shared.StatePasscodeConfirmation, shared.StateProfileInit) + } else { + emailModel := models.NewEmail(&userModel.ID, newEmailAddress) + + err = deps.Persister.GetEmailPersisterWithConnection(deps.Tx).Create(*emailModel) + if err != nil { + return fmt.Errorf("could not save email: %w", err) + } + + if len(userModel.Emails) == 0 { + // The user has only one 1 email and it is the email we just added. It makes sense then, + // to automatically set this as the primary email. + primaryEmailModel := models.NewPrimaryEmail(emailModel.ID, userModel.ID) + err = deps.Persister.GetPrimaryEmailPersisterWithConnection(deps.Tx).Create(*primaryEmailModel) + if err != nil { + return fmt.Errorf("could not save primary email: %w", err) + } + emailModel.PrimaryEmail = primaryEmailModel + } + + err = deps.AuditLogger.CreateWithConnection( + deps.Tx, + deps.HttpContext, + models.AuditLogEmailCreated, + &models.User{ID: userModel.ID}, + nil, + auditlog.Detail("email", emailModel.Address), + auditlog.Detail("flow_id", c.GetFlowID())) + + if err != nil { + return fmt.Errorf("could not create audit log: %w", err) + } + + userModel.Emails = append(userModel.Emails, *emailModel) + + return c.Continue(shared.StateProfileInit) + } +} diff --git a/backend/flow_api/flow/profile/action_email_delete.go b/backend/flow_api/flow/profile/action_email_delete.go new file mode 100644 index 000000000..3f6c9c1e1 --- /dev/null +++ b/backend/flow_api/flow/profile/action_email_delete.go @@ -0,0 +1,120 @@ +package profile + +import ( + "errors" + "fmt" + "github.com/gofrs/uuid" + auditlog "github.com/teamhanko/hanko/backend/audit_log" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flow_api/services" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +type EmailDelete struct { + shared.Action +} + +func (a EmailDelete) GetName() flowpilot.ActionName { + return shared.ActionEmailDelete +} + +func (a EmailDelete) GetDescription() string { + return "Delete an email address." +} + +func (a EmailDelete) Initialize(c flowpilot.InitializationContext) { + deps := a.GetDeps(c) + userModel, ok := c.Get("session_user").(*models.User) + if !ok { + c.SuspendAction() + return + } + + input := flowpilot.StringInput("email_id").Required(true).Hidden(true) + + lastEmail := len(userModel.Emails) == 1 + + canDoWebauthn := deps.Cfg.Passkey.Enabled && len(userModel.WebauthnCredentials) > 0 + canDoPWLogin := deps.Cfg.Password.Enabled && userModel.PasswordCredential != nil + canDoPasscode := deps.Cfg.Email.Enabled && deps.Cfg.Email.UseForAuthentication + + for _, email := range userModel.Emails { + if email.IsPrimary() { + canDoPWLoginWithUsername := canDoPWLogin && deps.Cfg.Username.UseAsLoginIdentifier && len(userModel.GetUsername()) > 0 + if lastEmail && deps.Cfg.Email.Optional && (canDoWebauthn || canDoPWLoginWithUsername) { + input.AllowedValue(email.Address, email.ID.String()) + } + } else { + if !canDoWebauthn && !canDoPWLogin && !canDoPasscode { + for _, otherEmail := range userModel.Emails { + if otherEmail.ID.String() == email.ID.String() { + continue + } + + if services.UserCanDoThirdParty(deps.Cfg, otherEmail.Identities) || + services.UserCanDoSaml(deps.Cfg, otherEmail.Identities) { + input.AllowedValue(email.Address, email.ID.String()) + break + } + } + } else { + input.AllowedValue(email.Address, email.ID.String()) + } + } + } + + c.AddInputs(input) +} + +func (a EmailDelete) Execute(c flowpilot.ExecutionContext) error { + deps := a.GetDeps(c) + + if valid := c.ValidateInputData(); !valid { + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + userModel, ok := c.Get("session_user").(*models.User) + if !ok { + return c.Error(flowpilot.ErrorOperationNotPermitted) + } + + emailToBeDeletedId := uuid.FromStringOrNil(c.Input().Get("email_id").String()) + emailToBeDeletedModel := userModel.GetEmailById(emailToBeDeletedId) + if emailToBeDeletedModel == nil { + return c.Error(flowpilot.ErrorFormDataInvalid.Wrap(errors.New("unknown email"))) + } + + if emailToBeDeletedModel.IsPrimary() { + if !deps.Cfg.Email.Optional { + return c.Error(flowpilot.ErrorOperationNotPermitted.Wrap(errors.New("cannot delete primary email"))) + } else { + err := deps.Persister.GetPrimaryEmailPersisterWithConnection(deps.Tx).Delete(*emailToBeDeletedModel.PrimaryEmail) + if err != nil { + return fmt.Errorf("could not delete primary email: %w", err) + } + } + } + + err := deps.Persister.GetEmailPersisterWithConnection(deps.Tx).Delete(*emailToBeDeletedModel) + if err != nil { + return fmt.Errorf("could not delete email: %w", err) + } + + err = deps.AuditLogger.CreateWithConnection( + deps.Tx, + deps.HttpContext, + models.AuditLogEmailDeleted, + &models.User{ID: userModel.ID}, + nil, + auditlog.Detail("email", emailToBeDeletedModel.Address), + auditlog.Detail("flow_id", c.GetFlowID())) + + if err != nil { + return fmt.Errorf("could not create audit log: %w", err) + } + + userModel.DeleteEmail(*emailToBeDeletedModel) + + return c.Continue(shared.StateProfileInit) +} diff --git a/backend/flow_api/flow/profile/action_email_set_primary.go b/backend/flow_api/flow/profile/action_email_set_primary.go new file mode 100644 index 000000000..517a7bec2 --- /dev/null +++ b/backend/flow_api/flow/profile/action_email_set_primary.go @@ -0,0 +1,109 @@ +package profile + +import ( + "fmt" + "github.com/gofrs/uuid" + auditlog "github.com/teamhanko/hanko/backend/audit_log" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +type EmailSetPrimary struct { + shared.Action +} + +func (a EmailSetPrimary) GetName() flowpilot.ActionName { + return shared.ActionEmailSetPrimary +} + +func (a EmailSetPrimary) GetDescription() string { + return "Sets a an email address as the primary email address." +} + +func (a EmailSetPrimary) Initialize(c flowpilot.InitializationContext) { + deps := a.GetDeps(c) + + if !deps.Cfg.Email.Enabled { + c.SuspendAction() + return + } + + userModel, ok := c.Get("session_user").(*models.User) + if !ok { + c.SuspendAction() + return + } + + if len(userModel.Emails) == 1 && userModel.Emails[0].IsPrimary() { + c.SuspendAction() + return + } + + if len(userModel.Emails) == 0 { + c.SuspendAction() + return + } + + c.AddInputs(flowpilot.StringInput("email_id").Required(true).Hidden(true)) +} + +func (a EmailSetPrimary) Execute(c flowpilot.ExecutionContext) error { + deps := a.GetDeps(c) + + if valid := c.ValidateInputData(); !valid { + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + userModel, ok := c.Get("session_user").(*models.User) + if !ok { + return c.Error(flowpilot.ErrorOperationNotPermitted) + } + + emailId := uuid.FromStringOrNil(c.Input().Get("email_id").String()) + emailModel := userModel.GetEmailById(emailId) + + if emailModel == nil { + return c.Error(shared.ErrorNotFound) + } + + if emailModel.IsPrimary() { + return c.Continue(shared.StateProfileInit) + } + + var primaryEmail *models.PrimaryEmail + if e := userModel.Emails.GetPrimary(); e != nil { + primaryEmail = e.PrimaryEmail + } + + if primaryEmail == nil { + primaryEmail = models.NewPrimaryEmail(emailModel.ID, userModel.ID) + err := deps.Persister.GetPrimaryEmailPersisterWithConnection(deps.Tx).Create(*primaryEmail) + if err != nil { + return fmt.Errorf("failed to store new primary email: %w", err) + } + } else { + primaryEmail.EmailID = emailModel.ID + err := deps.Persister.GetPrimaryEmailPersisterWithConnection(deps.Tx).Update(*primaryEmail) + if err != nil { + return fmt.Errorf("failed to change primary email: %w", err) + } + } + + err := deps.AuditLogger.CreateWithConnection( + deps.Tx, + deps.HttpContext, + models.AuditLogPrimaryEmailChanged, + &models.User{ID: userModel.ID}, + nil, + auditlog.Detail("email", emailModel.Address), + auditlog.Detail("flow_id", c.GetFlowID())) + + if err != nil { + return fmt.Errorf("could not create audit log: %w", err) + } + + userModel.SetPrimaryEmail(primaryEmail) + + return c.Continue(shared.StateProfileInit) +} diff --git a/backend/flow_api/flow/profile/action_email_verify.go b/backend/flow_api/flow/profile/action_email_verify.go new file mode 100644 index 000000000..43097678c --- /dev/null +++ b/backend/flow_api/flow/profile/action_email_verify.go @@ -0,0 +1,76 @@ +package profile + +import ( + "fmt" + "github.com/gofrs/uuid" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +type EmailVerify struct { + shared.Action +} + +func (a EmailVerify) GetName() flowpilot.ActionName { + return shared.ActionEmailVerify +} + +func (a EmailVerify) GetDescription() string { + return "Verify an email." +} + +func (a EmailVerify) Initialize(c flowpilot.InitializationContext) { + deps := a.GetDeps(c) + + if !deps.Cfg.Email.Enabled { + c.SuspendAction() + return + } + + userModel, ok := c.Get("session_user").(*models.User) + if !ok { + c.SuspendAction() + return + } + + if !userModel.Emails.HasUnverified() { + c.SuspendAction() + return + } + + c.AddInputs(flowpilot.StringInput("email_id").Required(true).Hidden(true)) +} + +func (a EmailVerify) Execute(c flowpilot.ExecutionContext) error { + if valid := c.ValidateInputData(); !valid { + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + userModel, ok := c.Get("session_user").(*models.User) + if !ok { + return c.Error(flowpilot.ErrorOperationNotPermitted) + } + + emailModel := userModel.GetEmailById(uuid.FromStringOrNil(c.Input().Get("email_id").String())) + if emailModel == nil { + return c.Error(shared.ErrorNotFound) + } + + err := c.Stash().Set(shared.StashPathEmail, emailModel.Address) + if err != nil { + return fmt.Errorf("failed to set email address to verify to stash: %w", err) + } + + err = c.Stash().Set(shared.StashPathUserID, userModel.ID.String()) + if err != nil { + return fmt.Errorf("failed to set user_id to stash: %w", err) + } + + err = c.Stash().Set(shared.StashPathPasscodeTemplate, "email_verification") + if err != nil { + return fmt.Errorf("failed to set passcode_tempalte to stash %w", err) + } + + return c.Continue(shared.StatePasscodeConfirmation, shared.StateProfileInit) +} diff --git a/backend/flow_api/flow/profile/action_password_create.go b/backend/flow_api/flow/profile/action_password_create.go new file mode 100644 index 000000000..6fd09a556 --- /dev/null +++ b/backend/flow_api/flow/profile/action_password_create.go @@ -0,0 +1,82 @@ +package profile + +import ( + "fmt" + auditlog "github.com/teamhanko/hanko/backend/audit_log" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +type PasswordCreate struct { + shared.Action +} + +func (a PasswordCreate) GetName() flowpilot.ActionName { + return shared.ActionPasswordCreate +} + +func (a PasswordCreate) GetDescription() string { + return "Create a new password." +} + +func (a PasswordCreate) Initialize(c flowpilot.InitializationContext) { + deps := a.GetDeps(c) + + userModel, _ := c.Get("session_user").(*models.User) + + if !deps.Cfg.Password.Enabled { + c.SuspendAction() + } + + if userModel.PasswordCredential != nil { + // The password_update action must be used instead + c.SuspendAction() + } + + c.AddInputs(flowpilot.StringInput("password"). + Required(true). + MinLength(deps.Cfg.Password.MinLength). + MaxLength(72), + ) + +} + +func (a PasswordCreate) Execute(c flowpilot.ExecutionContext) error { + deps := a.GetDeps(c) + + if valid := c.ValidateInputData(); !valid { + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + userModel, ok := c.Get("session_user").(*models.User) + if !ok { + return c.Error(flowpilot.ErrorOperationNotPermitted) + } + + password := c.Input().Get("password").String() + + passwordCredential := models.NewPasswordCredential(userModel.ID, password) // ? + + err := deps.PasswordService.CreatePassword(userModel.ID, password) // ? + if err != nil { + return fmt.Errorf("could not set password: %w", err) + } + + err = deps.AuditLogger.CreateWithConnection( + deps.Tx, + deps.HttpContext, + models.AuditLogPasswordChanged, + &models.User{ID: userModel.ID}, + nil, + auditlog.Detail("context", "profile"), + auditlog.Detail("flow_id", c.GetFlowID())) + + if err != nil { + return fmt.Errorf("could not create audit log: %w", err) + } + + userModel.PasswordCredential = passwordCredential + + return c.Continue(shared.StateProfileInit) +} diff --git a/backend/flow_api/flow/profile/action_password_delete.go b/backend/flow_api/flow/profile/action_password_delete.go new file mode 100644 index 000000000..947a30a95 --- /dev/null +++ b/backend/flow_api/flow/profile/action_password_delete.go @@ -0,0 +1,100 @@ +package profile + +import ( + "fmt" + auditlog "github.com/teamhanko/hanko/backend/audit_log" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flow_api/services" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +type PasswordDelete struct { + shared.Action +} + +func (a PasswordDelete) GetName() flowpilot.ActionName { + return shared.ActionPasswordDelete +} + +func (a PasswordDelete) GetDescription() string { + return "Delete a password." +} + +func (a PasswordDelete) Initialize(c flowpilot.InitializationContext) { + if a.mustSuspend(c) { + c.SuspendAction() + return + } +} + +func (a PasswordDelete) Execute(c flowpilot.ExecutionContext) error { + deps := a.GetDeps(c) + + userModel, ok := c.Get("session_user").(*models.User) + if !ok { + return c.Error(flowpilot.ErrorOperationNotPermitted) + } + + passwordCredentialModel, err := deps.Persister.GetPasswordCredentialPersisterWithConnection(deps.Tx).GetByUserID(userModel.ID) + if err != nil { + return fmt.Errorf("could not fetch password credential: %w", err) + } + + if passwordCredentialModel == nil { + return c.Continue(shared.StateProfileInit) + } + + err = deps.Persister.GetPasswordCredentialPersisterWithConnection(deps.Tx).Delete(*passwordCredentialModel) + if err != nil { + return fmt.Errorf("could not delete password credential: %w", err) + } + + err = deps.AuditLogger.CreateWithConnection( + deps.Tx, + deps.HttpContext, + models.AuditLogPasswordDeleted, + &models.User{ID: userModel.ID}, + nil, + auditlog.Detail("flow_id", c.GetFlowID())) + + if err != nil { + return fmt.Errorf("could not create audit log: %w", err) + } + + userModel.PasswordCredential = nil + + return c.Continue(shared.StateProfileInit) +} + +func (a PasswordDelete) mustSuspend(c flowpilot.Context) bool { + deps := a.GetDeps(c) + + if !deps.Cfg.Password.Enabled { + return true + } + + userModel, ok := c.Get("session_user").(*models.User) + if !ok { + return true + } + + if userModel.PasswordCredential == nil || !deps.Cfg.Password.Optional { + return true + } + + identities := userModel.GetIdentities() + + canDoWebauthn := deps.Cfg.Passkey.Enabled && len(userModel.WebauthnCredentials) > 0 + canUseUsernameAsLoginIdentifier := deps.Cfg.Username.UseAsLoginIdentifier && userModel.Username != nil + canUseEmailAsLoginIdentifier := deps.Cfg.Email.UseAsLoginIdentifier && len(userModel.Emails) > 0 + canDoPasscode := deps.Cfg.Email.Enabled && deps.Cfg.Email.UseForAuthentication && (canUseEmailAsLoginIdentifier || canUseUsernameAsLoginIdentifier && len(userModel.Emails) > 0) + canDoThirdParty := services.UserCanDoThirdParty(deps.Cfg, identities) || services.UserCanDoSaml(deps.Cfg, identities) + canUseNoOtherAuthMethod := !canDoWebauthn && !canDoPasscode && !canDoThirdParty + + if canUseNoOtherAuthMethod { + return true + } + + return false +} diff --git a/backend/flow_api/flow/profile/action_password_update.go b/backend/flow_api/flow/profile/action_password_update.go new file mode 100644 index 000000000..2422de553 --- /dev/null +++ b/backend/flow_api/flow/profile/action_password_update.go @@ -0,0 +1,80 @@ +package profile + +import ( + "fmt" + auditlog "github.com/teamhanko/hanko/backend/audit_log" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +type PasswordUpdate struct { + shared.Action +} + +func (a PasswordUpdate) GetName() flowpilot.ActionName { + return shared.ActionPasswordUpdate +} + +func (a PasswordUpdate) GetDescription() string { + return "Update an existing password." +} + +func (a PasswordUpdate) Initialize(c flowpilot.InitializationContext) { + deps := a.GetDeps(c) + + userModel, _ := c.Get("session_user").(*models.User) + + if !deps.Cfg.Password.Enabled { + c.SuspendAction() + } + + if userModel.PasswordCredential == nil { + // The password_create action must be used instead + c.SuspendAction() + } + + c.AddInputs(flowpilot.StringInput("password"). + Required(true). + MinLength(deps.Cfg.Password.MinLength). + MaxLength(72)) +} + +func (a PasswordUpdate) Execute(c flowpilot.ExecutionContext) error { + deps := a.GetDeps(c) + + if valid := c.ValidateInputData(); !valid { + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + userModel, ok := c.Get("session_user").(*models.User) + if !ok { + return c.Error(flowpilot.ErrorOperationNotPermitted) + } + + password := c.Input().Get("password").String() + + passwordCredential := models.NewPasswordCredential(userModel.ID, password) // ? + + err := deps.PasswordService.UpdatePassword(passwordCredential, password) + if err != nil { + return fmt.Errorf("could not udate password: %w", err) + } + + err = deps.AuditLogger.CreateWithConnection( + deps.Tx, + deps.HttpContext, + models.AuditLogPasswordChanged, + &models.User{ID: userModel.ID}, + nil, + auditlog.Detail("context", "profile"), + auditlog.Detail("flow_id", c.GetFlowID())) + + if err != nil { + return fmt.Errorf("could not create audit log: %w", err) + } + + userModel.PasswordCredential = passwordCredential + + return c.Continue(shared.StateProfileInit) +} diff --git a/backend/flow_api/flow/profile/action_username_create.go b/backend/flow_api/flow/profile/action_username_create.go new file mode 100644 index 000000000..574b5e093 --- /dev/null +++ b/backend/flow_api/flow/profile/action_username_create.go @@ -0,0 +1,95 @@ +package profile + +import ( + "fmt" + auditlog "github.com/teamhanko/hanko/backend/audit_log" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flow_api/services" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +type UsernameCreate struct { + shared.Action +} + +func (a UsernameCreate) GetName() flowpilot.ActionName { + return shared.ActionUsernameCreate +} + +func (a UsernameCreate) GetDescription() string { + return "Create a new username." +} + +func (a UsernameCreate) Initialize(c flowpilot.InitializationContext) { + deps := a.GetDeps(c) + + userModel, ok := c.Get("session_user").(*models.User) + if !ok { + c.SuspendAction() + return + } + + if !deps.Cfg.Username.Enabled || userModel.Username != nil { + c.SuspendAction() + return + } + + c.AddInputs(flowpilot.StringInput("username"). + Preserve(true). + Required(true). + TrimSpace(true). + LowerCase(true)) +} + +func (a UsernameCreate) Execute(c flowpilot.ExecutionContext) error { + deps := a.GetDeps(c) + + if valid := c.ValidateInputData(); !valid { + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + userModel, ok := c.Get("session_user").(*models.User) + if !ok { + return c.Error(flowpilot.ErrorOperationNotPermitted) + } + + username := c.Input().Get("username").String() + + if !services.ValidateUsername(username) { + c.Input().SetError("username", shared.ErrorInvalidUsername) + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + duplicateUsername, err := deps.Persister.GetUsernamePersisterWithConnection(deps.Tx).GetByName(username) + if err != nil { + return fmt.Errorf("failed to get user from db: %w", err) + } + + if duplicateUsername != nil && duplicateUsername.ID.String() != userModel.ID.String() { + c.Input().SetError("username", shared.ErrorUsernameAlreadyExists) + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + usernameModel := models.NewUsername(userModel.ID, username) + err = deps.Persister.GetUsernamePersisterWithConnection(deps.Tx).Create(*usernameModel) + if err != nil { + return fmt.Errorf("failed to create username: %w", err) + } + userModel.SetUsername(usernameModel) + + err = deps.AuditLogger.CreateWithConnection( + deps.Tx, + deps.HttpContext, + models.AuditLogUsernameChanged, + &models.User{ID: userModel.ID}, + nil, + auditlog.Detail("username", usernameModel.Username), + auditlog.Detail("flow_id", c.GetFlowID())) + + if err != nil { + return fmt.Errorf("could not create audit log: %w", err) + } + + return c.Continue(shared.StateProfileInit) +} diff --git a/backend/flow_api/flow/profile/action_username_delete.go b/backend/flow_api/flow/profile/action_username_delete.go new file mode 100644 index 000000000..b6abc4b2c --- /dev/null +++ b/backend/flow_api/flow/profile/action_username_delete.go @@ -0,0 +1,76 @@ +package profile + +import ( + "fmt" + auditlog "github.com/teamhanko/hanko/backend/audit_log" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +type UsernameDelete struct { + shared.Action +} + +func (a UsernameDelete) GetName() flowpilot.ActionName { + return shared.ActionUsernameDelete +} + +func (a UsernameDelete) GetDescription() string { + return "Delete the username of a user." +} + +func (a UsernameDelete) Initialize(c flowpilot.InitializationContext) { + deps := a.GetDeps(c) + + userModel, ok := c.Get("session_user").(*models.User) + if !ok { + c.SuspendAction() + return + } + + canDoWebauthn := deps.Cfg.Passkey.Enabled && len(userModel.WebauthnCredentials) > 0 + + if !deps.Cfg.Username.Enabled || + !deps.Cfg.Username.Optional || + userModel.Username == nil || + (len(userModel.Emails) == 0 && !canDoWebauthn) { + c.SuspendAction() + } +} + +func (a UsernameDelete) Execute(c flowpilot.ExecutionContext) error { + deps := a.GetDeps(c) + + if valid := c.ValidateInputData(); !valid { + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + userModel, ok := c.Get("session_user").(*models.User) + if !ok { + return c.Error(flowpilot.ErrorOperationNotPermitted) + } + + usernameModel := &models.Username{ID: userModel.Username.ID} + err := deps.Persister.GetUsernamePersisterWithConnection(deps.Tx).Delete(usernameModel) + if err != nil { + return fmt.Errorf("failed to delete username from db: %w", err) + } + deletedUsername := userModel.GetUsername() + userModel.DeleteUsername() + + err = deps.AuditLogger.CreateWithConnection( + deps.Tx, + deps.HttpContext, + models.AuditLogUsernameDeleted, + &models.User{ID: userModel.ID}, + nil, + auditlog.Detail("username", deletedUsername), + auditlog.Detail("flow_id", c.GetFlowID())) + + if err != nil { + return fmt.Errorf("could not create audit log: %w", err) + } + + return c.Continue(shared.StateProfileInit) +} diff --git a/backend/flow_api/flow/profile/action_username_update.go b/backend/flow_api/flow/profile/action_username_update.go new file mode 100644 index 000000000..3937bba68 --- /dev/null +++ b/backend/flow_api/flow/profile/action_username_update.go @@ -0,0 +1,100 @@ +package profile + +import ( + "fmt" + auditlog "github.com/teamhanko/hanko/backend/audit_log" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flow_api/services" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +type UsernameUpdate struct { + shared.Action +} + +func (a UsernameUpdate) GetName() flowpilot.ActionName { + return shared.ActionUsernameUpdate +} + +func (a UsernameUpdate) GetDescription() string { + return "Update an existing username." +} + +func (a UsernameUpdate) Initialize(c flowpilot.InitializationContext) { + deps := a.GetDeps(c) + + userModel, ok := c.Get("session_user").(*models.User) + if !ok { + c.SuspendAction() + return + } + + if !deps.Cfg.Username.Enabled || userModel.Username == nil { + c.SuspendAction() + return + } + + c.AddInputs(flowpilot.StringInput("username"). + Preserve(true). + Required(true). + TrimSpace(true). + LowerCase(true)) +} + +func (a UsernameUpdate) Execute(c flowpilot.ExecutionContext) error { + deps := a.GetDeps(c) + + if valid := c.ValidateInputData(); !valid { + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + userModel, ok := c.Get("session_user").(*models.User) + if !ok { + return c.Error(flowpilot.ErrorOperationNotPermitted) + } + + username := c.Input().Get("username").String() + + if !services.ValidateUsername(username) { + c.Input().SetError("username", shared.ErrorInvalidUsername) + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + duplicateUsername, err := deps.Persister.GetUsernamePersisterWithConnection(deps.Tx).GetByName(username) + if err != nil { + return fmt.Errorf("failed to get user from db: %w", err) + } + + if duplicateUsername != nil && duplicateUsername.ID.String() != userModel.ID.String() { + c.Input().SetError("username", shared.ErrorUsernameAlreadyExists) + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + usernameModel := &models.Username{ + ID: userModel.Username.ID, + UserId: userModel.ID, + Username: username, + } + + err = deps.Persister.GetUsernamePersisterWithConnection(deps.Tx).Update(usernameModel) + if err != nil { + return fmt.Errorf("failed to update username: %w", err) + } + userModel.SetUsername(usernameModel) + + err = deps.AuditLogger.CreateWithConnection( + deps.Tx, + deps.HttpContext, + models.AuditLogUsernameChanged, + &models.User{ID: userModel.ID}, + nil, + auditlog.Detail("username", userModel.GetUsername()), + auditlog.Detail("flow_id", c.GetFlowID())) + + if err != nil { + return fmt.Errorf("could not create audit log: %w", err) + } + + return c.Continue(shared.StateProfileInit) +} diff --git a/backend/flow_api/flow/profile/action_webauthn_credential_create.go b/backend/flow_api/flow/profile/action_webauthn_credential_create.go new file mode 100644 index 000000000..603d729ec --- /dev/null +++ b/backend/flow_api/flow/profile/action_webauthn_credential_create.go @@ -0,0 +1,75 @@ +package profile + +import ( + "errors" + "fmt" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flow_api/services" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +type WebauthnCredentialCreate struct { + shared.Action +} + +func (a WebauthnCredentialCreate) GetName() flowpilot.ActionName { + return shared.ActionWebauthnCredentialCreate +} + +func (a WebauthnCredentialCreate) GetDescription() string { + return "Create a Webauthn credential for the current session user." +} + +func (a WebauthnCredentialCreate) Initialize(c flowpilot.InitializationContext) { + deps := a.GetDeps(c) + + userModel, ok := c.Get("session_user").(*models.User) + + if !deps.Cfg.Passkey.Enabled || !c.Stash().Get(shared.StashPathWebauthnAvailable).Bool() || (ok && len(userModel.WebauthnCredentials) >= deps.Cfg.Passkey.Limit) { + c.SuspendAction() + } +} + +func (a WebauthnCredentialCreate) Execute(c flowpilot.ExecutionContext) error { + deps := a.GetDeps(c) + + userModel, ok := c.Get("session_user").(*models.User) + if !ok { + return c.Error(flowpilot.ErrorOperationNotPermitted) + } + + primaryEmailModel := userModel.Emails.GetPrimary() + if primaryEmailModel == nil && userModel.Username == nil { + return errors.New("user must have either email or username") + } + + var primaryEmailAddress string + if primaryEmailModel != nil { + primaryEmailAddress = primaryEmailModel.Address + } + + params := services.GenerateCreationOptionsParams{ + Tx: deps.Tx, + UserID: userModel.ID, + Email: primaryEmailAddress, + Username: userModel.GetUsername(), + } + + sessionDataModel, creationOptions, err := deps.WebauthnService.GenerateCreationOptions(params) + if err != nil { + return fmt.Errorf("failed to generate webauthn creation options: %w", err) + } + + err = c.Stash().Set(shared.StashPathWebauthnSessionDataID, sessionDataModel.ID) + if err != nil { + return err + } + + err = c.Payload().Set("creation_options", creationOptions) + if err != nil { + return err + } + + return c.Continue(shared.StateProfileWebauthnCredentialVerification) +} diff --git a/backend/flow_api/flow/profile/action_webauthn_credential_delete.go b/backend/flow_api/flow/profile/action_webauthn_credential_delete.go new file mode 100644 index 000000000..4fc9208be --- /dev/null +++ b/backend/flow_api/flow/profile/action_webauthn_credential_delete.go @@ -0,0 +1,109 @@ +package profile + +import ( + "fmt" + auditlog "github.com/teamhanko/hanko/backend/audit_log" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flow_api/services" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +type WebauthnCredentialDelete struct { + shared.Action +} + +func (a WebauthnCredentialDelete) GetName() flowpilot.ActionName { + return shared.ActionWebauthnCredentialDelete +} + +func (a WebauthnCredentialDelete) GetDescription() string { + return "Delete a Webauthn credential." +} + +func (a WebauthnCredentialDelete) Initialize(c flowpilot.InitializationContext) { + if a.mustSuspend(c) { + c.SuspendAction() + return + } + + c.AddInputs(flowpilot.StringInput("passkey_id").Required(true).Hidden(true)) +} + +func (a WebauthnCredentialDelete) Execute(c flowpilot.ExecutionContext) error { + deps := a.GetDeps(c) + + if valid := c.ValidateInputData(); !valid { + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + userModel, ok := c.Get("session_user").(*models.User) + if !ok { + return c.Error(flowpilot.ErrorOperationNotPermitted) + } + + webauthnCredentialModel := userModel.GetWebauthnCredentialById(c.Input().Get("passkey_id").String()) + if webauthnCredentialModel == nil { + return c.Error(shared.ErrorNotFound) + } + + err := deps.Persister.GetWebauthnCredentialPersisterWithConnection(deps.Tx).Delete(*webauthnCredentialModel) + if err != nil { + return fmt.Errorf("could not delete passkey: %w", err) + } + + err = deps.AuditLogger.CreateWithConnection( + deps.Tx, + deps.HttpContext, + models.AuditLogPasskeyDeleted, + &models.User{ID: userModel.ID}, + nil, + auditlog.Detail("credential_id", webauthnCredentialModel.ID), + auditlog.Detail("flow_id", c.GetFlowID())) + + if err != nil { + return fmt.Errorf("could not create audit log: %w", err) + } + + userModel.DeleteWebauthnCredential(webauthnCredentialModel.ID) + + return c.Continue(shared.StateProfileInit) +} + +func (a WebauthnCredentialDelete) mustSuspend(c flowpilot.Context) bool { + deps := a.GetDeps(c) + + if !deps.Cfg.Passkey.Enabled { + return true + } + + userModel, ok := c.Get("session_user").(*models.User) + if !ok { + return true + } + + if len(userModel.WebauthnCredentials) == 0 { + return true + } + + identities := userModel.GetIdentities() + + isLastWebauthnCredential := len(userModel.WebauthnCredentials) == 1 + + if isLastWebauthnCredential && !deps.Cfg.Passkey.Optional { + return true + } + + canUseUsernameAsLoginIdentifier := deps.Cfg.Username.UseAsLoginIdentifier && userModel.Username != nil + canUseEmailAsLoginIdentifier := deps.Cfg.Email.UseAsLoginIdentifier && len(userModel.Emails) > 0 + canDoPassword := deps.Cfg.Password.Enabled && userModel.PasswordCredential != nil && (canUseUsernameAsLoginIdentifier || canUseEmailAsLoginIdentifier) + canDoPasscode := deps.Cfg.Email.Enabled && deps.Cfg.Email.UseForAuthentication && (canUseEmailAsLoginIdentifier || canUseUsernameAsLoginIdentifier && len(userModel.Emails) > 0) + canDoThirdParty := services.UserCanDoThirdParty(deps.Cfg, identities) || services.UserCanDoSaml(deps.Cfg, identities) + canUseNoOtherAuthMethod := !canDoPassword && !canDoThirdParty && !canDoPasscode + + if isLastWebauthnCredential && canUseNoOtherAuthMethod { + return true + } + + return false +} diff --git a/backend/flow_api/flow/profile/action_webauthn_credential_rename.go b/backend/flow_api/flow/profile/action_webauthn_credential_rename.go new file mode 100644 index 000000000..bb322bab0 --- /dev/null +++ b/backend/flow_api/flow/profile/action_webauthn_credential_rename.go @@ -0,0 +1,71 @@ +package profile + +import ( + "fmt" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +type WebauthnCredentialRename struct { + shared.Action +} + +func (a WebauthnCredentialRename) GetName() flowpilot.ActionName { + return shared.ActionWebauthnCredentialRename +} + +func (a WebauthnCredentialRename) GetDescription() string { + return "Rename a Webauthn credential." +} + +func (a WebauthnCredentialRename) Initialize(c flowpilot.InitializationContext) { + deps := a.GetDeps(c) + + if !deps.Cfg.Passkey.Enabled { + c.SuspendAction() + return + } + + userModel, ok := c.Get("session_user").(*models.User) + if !ok { + c.SuspendAction() + return + } + + if len(userModel.WebauthnCredentials) == 0 { + c.SuspendAction() + return + } + + c.AddInputs(flowpilot.StringInput("passkey_id").Required(true).Hidden(true)) + c.AddInputs(flowpilot.StringInput("passkey_name").Required(true)) +} + +func (a WebauthnCredentialRename) Execute(c flowpilot.ExecutionContext) error { + deps := a.GetDeps(c) + + if valid := c.ValidateInputData(); !valid { + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + userModel, ok := c.Get("session_user").(*models.User) + if !ok { + return c.Error(flowpilot.ErrorOperationNotPermitted) + } + + webauthnCredentialModel := userModel.GetWebauthnCredentialById(c.Input().Get("passkey_id").String()) + if webauthnCredentialModel == nil { + return c.Error(shared.ErrorNotFound) + } + + webauthnCredentialName := c.Input().Get("passkey_name").String() + webauthnCredentialModel.Name = &webauthnCredentialName + + err := deps.Persister.GetWebauthnCredentialPersisterWithConnection(deps.Tx).Update(*webauthnCredentialModel) + if err != nil { + return fmt.Errorf("could not update credential: %w", err) + } + + return c.Continue(shared.StateProfileInit) +} diff --git a/backend/flow_api/flow/profile/action_webauthn_verify_attestation_response.go b/backend/flow_api/flow/profile/action_webauthn_verify_attestation_response.go new file mode 100644 index 000000000..80d3ecbde --- /dev/null +++ b/backend/flow_api/flow/profile/action_webauthn_verify_attestation_response.go @@ -0,0 +1,93 @@ +package profile + +import ( + "errors" + "fmt" + "github.com/gofrs/uuid" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flow_api/services" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +type WebauthnVerifyAttestationResponse struct { + shared.Action +} + +func (a WebauthnVerifyAttestationResponse) GetName() flowpilot.ActionName { + return shared.ActionWebauthnVerifyAttestationResponse +} + +func (a WebauthnVerifyAttestationResponse) GetDescription() string { + return "Send the result which was generated by creating a webauthn credential." +} + +func (a WebauthnVerifyAttestationResponse) Initialize(c flowpilot.InitializationContext) { + deps := a.GetDeps(c) + + if !deps.Cfg.Passkey.Enabled || !c.Stash().Get(shared.StashPathWebauthnAvailable).Bool() { + c.SuspendAction() + } + + c.AddInputs(flowpilot.JSONInput("public_key").Required(true)) +} + +func (a WebauthnVerifyAttestationResponse) Execute(c flowpilot.ExecutionContext) error { + deps := a.GetDeps(c) + + if valid := c.ValidateInputData(); !valid { + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + userModel, ok := c.Get("session_user").(*models.User) + if !ok { + return c.Error(flowpilot.ErrorOperationNotPermitted) + } + + if !c.Stash().Get(shared.StashPathWebauthnSessionDataID).Exists() { + return errors.New("webauthn_session_data_id does not exist in the stash") + } + + sessionDataID, err := uuid.FromString(c.Stash().Get(shared.StashPathWebauthnSessionDataID).String()) + if err != nil { + return fmt.Errorf("failed to parse webauthn_session_data_id: %w", err) + } + + var primaryEmailAddress string + if primaryEmailModel := userModel.Emails.GetPrimary(); primaryEmailModel != nil { + primaryEmailAddress = primaryEmailModel.Address + } + + params := services.VerifyAttestationResponseParams{ + Tx: deps.Tx, + SessionDataID: sessionDataID, + PublicKey: c.Input().Get("public_key").String(), + UserID: userModel.ID, + Email: primaryEmailAddress, + Username: userModel.GetUsername(), + } + + credential, err := deps.WebauthnService.VerifyAttestationResponse(params) + if err != nil { + if errors.Is(err, services.ErrInvalidWebauthnCredential) { + return c.Error(shared.ErrorPasskeyInvalid.Wrap(err)) + } + + return fmt.Errorf("failed to verify attestation response: %w", err) + } + + err = c.Stash().Set(shared.StashPathWebauthnCredential, credential) + if err != nil { + return fmt.Errorf("failed to set webauthn_credential to the stash: %w", err) + } + + // Set user_id explicitly because persisting the credential is now part of a shared hook which has + // to work in multiple flows, e.g. the login flow, which does not work with the session_user in the + // context like the profile does + err = c.Stash().Set(shared.StashPathUserID, userModel.ID.String()) + if err != nil { + return fmt.Errorf("failed to set user_id to the stash: %w", err) + } + + return c.Continue(shared.StateProfileInit) +} diff --git a/backend/flow_api/flow/profile/hook_get_profile_data.go b/backend/flow_api/flow/profile/hook_get_profile_data.go new file mode 100644 index 000000000..c5bb44e65 --- /dev/null +++ b/backend/flow_api/flow/profile/hook_get_profile_data.go @@ -0,0 +1,36 @@ +package profile + +import ( + "errors" + "fmt" + "github.com/teamhanko/hanko/backend/dto" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +type GetProfileData struct { + shared.Action +} + +func (h GetProfileData) Execute(c flowpilot.HookExecutionContext) error { + deps := h.GetDeps(c) + + userModel, ok := c.Get("session_user").(*models.User) + if !ok { + return errors.New("no valid session") + } + + profileData := dto.ProfileDataFromUserModel(userModel) + + if !deps.Cfg.Passkey.Enabled { + profileData.WebauthnCredentials = nil + } + + err := c.Payload().Set("user", profileData) + if err != nil { + return fmt.Errorf("failed to set user payload: %w", err) + } + + return nil +} diff --git a/backend/flow_api/flow/profile/hook_refresh_session_user.go b/backend/flow_api/flow/profile/hook_refresh_session_user.go new file mode 100644 index 000000000..de5a557cb --- /dev/null +++ b/backend/flow_api/flow/profile/hook_refresh_session_user.go @@ -0,0 +1,39 @@ +package profile + +import ( + "errors" + "fmt" + "github.com/gofrs/uuid" + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flowpilot" +) + +type RefreshSessionUser struct { + shared.Action +} + +func (h RefreshSessionUser) Execute(c flowpilot.HookExecutionContext) error { + deps := h.GetDeps(c) + + sessionToken, ok := deps.HttpContext.Get("session").(jwt.Token) + if !ok { + return errors.New("failed to cast session object") + } + + userId, err := uuid.FromString(sessionToken.Subject()) + if err != nil { + return fmt.Errorf("failed to parse userId from JWT subject: %w", err) + } + + userModel, err := deps.Persister.GetUserPersisterWithConnection(deps.Tx).Get(userId) + if err != nil { + return fmt.Errorf("failed to fetch user: %w", err) + } + + if userModel != nil { + c.Set("session_user", userModel) + } + + return nil +} diff --git a/backend/flow_api/flow/registration/action_register_login_identifier.go b/backend/flow_api/flow/registration/action_register_login_identifier.go new file mode 100644 index 000000000..ef570ec77 --- /dev/null +++ b/backend/flow_api/flow/registration/action_register_login_identifier.go @@ -0,0 +1,215 @@ +package registration + +import ( + "errors" + "fmt" + "github.com/gofrs/uuid" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flow_api/services" + "github.com/teamhanko/hanko/backend/flowpilot" + "strings" +) + +// RegisterLoginIdentifier takes the identifier which the user entered and checks if they are valid and available according to the configuration +type RegisterLoginIdentifier struct { + shared.Action +} + +func (a RegisterLoginIdentifier) GetName() flowpilot.ActionName { + return shared.ActionRegisterLoginIdentifier +} + +func (a RegisterLoginIdentifier) GetDescription() string { + return "Enter an identifier to register." +} + +func (a RegisterLoginIdentifier) Initialize(c flowpilot.InitializationContext) { + deps := a.GetDeps(c) + + if !deps.Cfg.Account.AllowSignup { + c.SuspendAction() + return + } + + if (!deps.Cfg.Email.Enabled || (deps.Cfg.Email.Enabled && !deps.Cfg.Email.AcquireOnRegistration)) && + (!deps.Cfg.Username.Enabled || (deps.Cfg.Username.Enabled && !deps.Cfg.Username.AcquireOnRegistration)) { + c.SuspendAction() + return + } + + if deps.Cfg.Email.Enabled && deps.Cfg.Email.AcquireOnRegistration { + input := flowpilot.EmailInput("email"). + MaxLength(deps.Cfg.Email.MaxLength). + Required(!deps.Cfg.Email.Optional). + TrimSpace(true). + LowerCase(true) + + c.AddInputs(input) + } + + if deps.Cfg.Username.Enabled && deps.Cfg.Username.AcquireOnRegistration { + input := flowpilot.StringInput("username"). + MinLength(deps.Cfg.Username.MinLength). + MaxLength(deps.Cfg.Username.MaxLength). + Required(!deps.Cfg.Username.Optional). + TrimSpace(true). + LowerCase(true) + + c.AddInputs(input) + } +} + +func (a RegisterLoginIdentifier) Execute(c flowpilot.ExecutionContext) error { + deps := a.GetDeps(c) + + if valid := c.ValidateInputData(); !valid { + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + email := c.Input().Get("email").String() + username := c.Input().Get("username").String() + + if deps.Cfg.Email.Optional && len(email) == 0 && + deps.Cfg.Username.Optional && len(username) == 0 { + err := errors.New("either email or username must be provided") + c.Input().SetError("username", flowpilot.ErrorValueInvalid.Wrap(err)) + c.Input().SetError("email", flowpilot.ErrorValueInvalid.Wrap(err)) + return c.Error(flowpilot.ErrorFormDataInvalid.Wrap(err)) + } + + if username != "" { + if !services.ValidateUsername(username) { + c.Input().SetError("username", shared.ErrorInvalidUsername) + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + // Check that username is not already taken + // this check is non-exhaustive as the username is not blocked here and might be created after the check here and the user creation + userModel, err := deps.Persister.GetUserPersister().GetByUsername(username) + if err != nil { + return err + } + if userModel != nil { + c.Input().SetError("username", shared.ErrorUsernameAlreadyExists) + return c.Error(flowpilot.ErrorFormDataInvalid) + } + } + + if email != "" { + if deps.Cfg.Saml.Enabled { + domain := strings.Split(email, "@")[1] + if provider, err := deps.SamlService.GetProviderByDomain(domain); err == nil && provider != nil { + authUrl, err := deps.SamlService.GetAuthUrl(provider, deps.Cfg.Saml.DefaultRedirectUrl, true) + + if err != nil { + return fmt.Errorf("failed to get auth url: %w", err) + } + + _ = c.Payload().Set("redirect_url", authUrl) + + return c.Continue(shared.StateThirdParty) + } + } + + // Check that email is not already taken + // this check is non-exhaustive as the email is not blocked here and might be created after the check here and the user creation + emailModel, err := deps.Persister.GetEmailPersister().FindByAddress(email) + if err != nil { + return err + } + // Do not return an error when only identifier is email and email verification is on (account enumeration protection) + if emailModel != nil { + // E-mail address already exists + if !deps.Cfg.Email.RequireVerification { + c.Input().SetError("email", shared.ErrorEmailAlreadyExists) + return c.Error(flowpilot.ErrorFormDataInvalid) + } else { + err = c.CopyInputValuesToStash("email") + if err != nil { + return fmt.Errorf("failed to copy email to stash: %w", err) + } + + err = c.Stash().Set(shared.StashPathPasscodeTemplate, "email_registration_attempted") + if err != nil { + return fmt.Errorf("failed to set passcode_template to the stash: %w", err) + } + + return c.Continue(shared.StatePasscodeConfirmation) + } + } + } + + err := c.CopyInputValuesToStash("email", "username") + if err != nil { + return fmt.Errorf("failed to copy input values to the stash: %w", err) + } + + userID, err := uuid.NewV4() + if err != nil { + return fmt.Errorf("failed to generate a new user id: %w", err) + } + + err = c.Stash().Set(shared.StashPathUserID, userID.String()) + if err != nil { + return fmt.Errorf("failed to stash user_id: %w", err) + } + + if email != "" && deps.Cfg.Email.RequireVerification { + if err = c.Stash().Set(shared.StashPathPasscodeTemplate, "email_verification"); err != nil { + return fmt.Errorf("failed to set passcode_template to stash: %w", err) + } + } + + return c.Continue(append(a.generateRegistrationStates(c), shared.StateSuccess)...) +} + +func (a RegisterLoginIdentifier) generateRegistrationStates(c flowpilot.ExecutionContext) []flowpilot.StateName { + deps := a.GetDeps(c) + + result := make([]flowpilot.StateName, 0) + + emailExists := len(c.Input().Get("email").String()) > 0 + if emailExists && deps.Cfg.Email.RequireVerification { + result = append(result, shared.StatePasscodeConfirmation) + } + + webauthnAvailable := c.Stash().Get(shared.StashPathWebauthnAvailable).Bool() + passkeyEnabled := webauthnAvailable && deps.Cfg.Passkey.Enabled + passwordEnabled := deps.Cfg.Password.Enabled + passwordAndPasskeyEnabled := passkeyEnabled && passwordEnabled + + alwaysAcquirePasskey := deps.Cfg.Passkey.AcquireOnRegistration == "always" + conditionalAcquirePasskey := deps.Cfg.Passkey.AcquireOnRegistration == "conditional" + alwaysAcquirePassword := deps.Cfg.Password.AcquireOnRegistration == "always" + conditionalAcquirePassword := deps.Cfg.Password.AcquireOnRegistration == "conditional" + neverAcquirePasskey := deps.Cfg.Passkey.AcquireOnRegistration == "never" + neverAcquirePassword := deps.Cfg.Password.AcquireOnRegistration == "never" + + if passwordAndPasskeyEnabled { + if alwaysAcquirePasskey && alwaysAcquirePassword { + if !deps.Cfg.Password.Optional && deps.Cfg.Passkey.Optional { + result = append(result, shared.StatePasswordCreation, shared.StateOnboardingCreatePasskey) + } else { + result = append(result, shared.StateOnboardingCreatePasskey, shared.StatePasswordCreation) + } + } else if alwaysAcquirePasskey && conditionalAcquirePassword { + result = append(result, shared.StateOnboardingCreatePasskey) + } else if conditionalAcquirePasskey && alwaysAcquirePassword { + result = append(result, shared.StatePasswordCreation) + } else if conditionalAcquirePasskey && conditionalAcquirePassword { + result = append(result, shared.StateCredentialOnboardingChooser) + } else if conditionalAcquirePasskey && neverAcquirePassword { + result = append(result, shared.StateOnboardingCreatePasskey) + } else if neverAcquirePasskey && (alwaysAcquirePassword || conditionalAcquirePassword) { + result = append(result, shared.StatePasswordCreation) + } else if (alwaysAcquirePasskey || conditionalAcquirePasskey) && neverAcquirePassword { + result = append(result, shared.StateOnboardingCreatePasskey) + } + } else if passkeyEnabled && (alwaysAcquirePasskey || conditionalAcquirePasskey) { + result = append(result, shared.StateOnboardingCreatePasskey) + } else if passwordEnabled && (alwaysAcquirePassword || conditionalAcquirePassword) { + result = append(result, shared.StatePasswordCreation) + } + + return result +} diff --git a/backend/flow_api/flow/registration/hook_create_user.go b/backend/flow_api/flow/registration/hook_create_user.go new file mode 100644 index 000000000..4d6058635 --- /dev/null +++ b/backend/flow_api/flow/registration/hook_create_user.go @@ -0,0 +1,150 @@ +package registration + +import ( + "encoding/json" + "fmt" + webauthnLib "github.com/go-webauthn/webauthn/webauthn" + "github.com/gofrs/uuid" + auditlog "github.com/teamhanko/hanko/backend/audit_log" + "github.com/teamhanko/hanko/backend/dto/intern" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/persistence/models" + "time" +) + +type CreateUser struct { + shared.Action +} + +func (h CreateUser) Execute(c flowpilot.HookExecutionContext) error { + // Set by shared thirdparty_oauth action because the third party callback endpoint already + // creates the user. + if c.Stash().Get(shared.StashPathSkipUserCreation).Bool() { + return nil + } + + deps := h.GetDeps(c) + + userId, err := uuid.NewV4() + if err != nil { + return err + } + if c.Stash().Get(shared.StashPathUserID).Exists() { + userId, err = uuid.FromString(c.Stash().Get(shared.StashPathUserID).String()) + if err != nil { + return fmt.Errorf("failed to parse stashed user_id into a uuid: %w", err) + } + } + + var credentialModel *models.WebauthnCredential + if c.Stash().Get(shared.StashPathWebauthnCredential).Exists() { + webauthnCredentialStr := c.Stash().Get(shared.StashPathWebauthnCredential).String() + + var webauthnCredential webauthnLib.Credential + err = json.Unmarshal([]byte(webauthnCredentialStr), &webauthnCredential) + if err != nil { + return fmt.Errorf("failed to unmarshal stashed webauthn_credential: %w", err) + } + + credentialModel = intern.WebauthnCredentialToModel(&webauthnCredential, userId, false, false, deps.AuthenticatorMetadata) + } + + err = h.createUser( + c, + userId, + c.Stash().Get(shared.StashPathEmail).String(), + c.Stash().Get(shared.StashPathEmailVerified).Bool(), + c.Stash().Get(shared.StashPathUsername).String(), + credentialModel, + c.Stash().Get(shared.StashPathNewPassword).String(), + ) + if err != nil { + return fmt.Errorf("failed to create user: %w", err) + } + + return nil +} + +func (h CreateUser) createUser(c flowpilot.HookExecutionContext, id uuid.UUID, email string, emailVerified bool, username string, passkey *models.WebauthnCredential, password string) error { + deps := h.GetDeps(c) + + now := time.Now().UTC() + + var auditLogDetails []auditlog.DetailOption + + err := deps.Persister.GetUserPersisterWithConnection(deps.Tx).Create(models.User{ + ID: id, + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + return err + } + + if email != "" { + emailModel := models.NewEmail(&id, email) + emailModel.Verified = emailVerified + err = deps.Persister.GetEmailPersisterWithConnection(deps.Tx).Create(*emailModel) + if err != nil { + return err + } + + primaryEmail := models.NewPrimaryEmail(emailModel.ID, id) + err = deps.Persister.GetPrimaryEmailPersisterWithConnection(deps.Tx).Create(*primaryEmail) + if err != nil { + return err + } + } + + if passkey != nil { + err = deps.Persister.GetWebauthnCredentialPersisterWithConnection(deps.Tx).Create(*passkey) + if err != nil { + return err + } + + auditLogDetails = append(auditLogDetails, auditlog.Detail("passkey", passkey.ID)) + } + + if password != "" { + err = deps.Persister.GetPasswordCredentialPersisterWithConnection(deps.Tx).Create(models.PasswordCredential{ + UserId: id, + Password: password, + }) + if err != nil { + return err + } + + auditLogDetails = append(auditLogDetails, auditlog.Detail("password", true)) + } + + user, err := deps.Persister.GetUserPersisterWithConnection(deps.Tx).Get(id) + if err != nil { + return err + } + + if username != "" { + usernameModel := models.NewUsername(user.ID, username) + err = deps.Persister.GetUsernamePersisterWithConnection(deps.Tx).Create(*usernameModel) + if err != nil { + return err + } + auditLogDetails = append(auditLogDetails, auditlog.Detail("username", username)) + } + + auditLogDetails = append(auditLogDetails, auditlog.Detail("flow_id", c.GetFlowID())) + + err = deps.AuditLogger.Create( + deps.HttpContext, + models.AuditLogUserCreated, + user, + nil, + auditLogDetails..., + ) + + if err != nil { + return fmt.Errorf("failed to create audit log: %w", err) + } + + return nil +} diff --git a/backend/flow_api/flow/shared/action_back.go b/backend/flow_api/flow/shared/action_back.go new file mode 100644 index 000000000..27b262fd8 --- /dev/null +++ b/backend/flow_api/flow/shared/action_back.go @@ -0,0 +1,25 @@ +package shared + +import ( + "github.com/teamhanko/hanko/backend/flowpilot" +) + +type Back struct{} + +func (a Back) GetName() flowpilot.ActionName { + return ActionBack +} + +func (a Back) GetDescription() string { + return "Navigate one step back." +} + +func (a Back) Initialize(c flowpilot.InitializationContext) { + if !c.StateIsRevertible() { + c.SuspendAction() + } +} + +func (a Back) Execute(c flowpilot.ExecutionContext) error { + return c.Revert() +} diff --git a/backend/flow_api/flow/shared/action_exchange_token.go b/backend/flow_api/flow/shared/action_exchange_token.go new file mode 100644 index 000000000..ea5245d27 --- /dev/null +++ b/backend/flow_api/flow/shared/action_exchange_token.go @@ -0,0 +1,116 @@ +package shared + +import ( + "errors" + "fmt" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/persistence/models" + "github.com/teamhanko/hanko/backend/rate_limiter" + "time" +) + +type ExchangeToken struct { + Action +} + +func (a ExchangeToken) GetName() flowpilot.ActionName { + return ActionExchangeToken +} + +func (a ExchangeToken) GetDescription() string { + return "Exchange a one time token." +} + +func (a ExchangeToken) Initialize(c flowpilot.InitializationContext) { + c.AddInputs(flowpilot.StringInput("token").Hidden(true).Required(true)) +} + +func (a ExchangeToken) Execute(c flowpilot.ExecutionContext) error { + if valid := c.ValidateInputData(); !valid { + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + deps := a.GetDeps(c) + + if deps.Cfg.RateLimiter.Enabled { + rateLimitKey := rate_limiter.CreateRateLimitTokenExchangeKey(deps.HttpContext.RealIP()) + retryAfterSeconds, ok, err := rate_limiter.Limit2(deps.TokenExchangeRateLimiter, rateLimitKey) + if err != nil { + return fmt.Errorf("rate limiter failed: %w", err) + } + + if !ok { + err = c.Payload().Set("retry_after", retryAfterSeconds) + if err != nil { + return fmt.Errorf("failed to set a value for retry_after to the payload: %w", err) + } + return c.Error(ErrorRateLimitExceeded.Wrap(fmt.Errorf("rate limit exceeded for: %s", rateLimitKey))) + } + } + + tokenModel, err := deps.Persister.GetTokenPersisterWithConnection(deps.Tx).GetByValue(c.Input().Get("token").String()) + if err != nil { + return fmt.Errorf("failed to fetch token from db: %w", err) + } + + if tokenModel == nil { + return errors.New("token not found") + } + + if time.Now().UTC().After(tokenModel.ExpiresAt) { + return errors.New("token expired") + } + + identity, err := deps.Persister.GetIdentityPersisterWithConnection(deps.Tx).GetByID(*tokenModel.IdentityID) + if err != nil { + return fmt.Errorf("failed to fetch identity from db: %w", err) + } + + // Set so the issue_session hook knows who to create the session for. + if err := c.Stash().Set(StashPathUserID, tokenModel.UserID.String()); err != nil { + return fmt.Errorf("failed to set user_id to stash: %w", err) + } + + // Set because the thirdparty/callback endpoint already creates a user. + if err := c.Stash().Set(StashPathSkipUserCreation, true); err != nil { + return fmt.Errorf("failed to set skip_user_creation to stash: %w", err) + } + + err = deps.Persister.GetTokenPersisterWithConnection(deps.Tx).Delete(*tokenModel) + if err != nil { + return fmt.Errorf("failed to delete token from db: %w", err) + } + + onboardingStates, err := a.determineOnboardingStates(c, identity, tokenModel.UserCreated) + if err != nil { + return fmt.Errorf("failed to determine onboarding stattes: %w", err) + } + + return c.Continue(onboardingStates...) +} + +func (a ExchangeToken) determineOnboardingStates(c flowpilot.ExecutionContext, identity *models.Identity, userCreated bool) ([]flowpilot.StateName, error) { + deps := a.GetDeps(c) + result := make([]flowpilot.StateName, 0) + + if deps.Cfg.Email.RequireVerification && identity.Email != nil && !identity.Email.Verified { + if err := c.Stash().Set(StashPathEmail, identity.Email.Address); err != nil { + return nil, fmt.Errorf("failed to stash email: %w", err) + } + + if err := c.Stash().Set(StashPathPasscodeTemplate, "email_verification"); err != nil { + return nil, fmt.Errorf("failed to stash passcode_template: %w", err) + } + + result = append(result, StatePasscodeConfirmation) + } + + if deps.Cfg.Username.Enabled && len(identity.Email.User.GetUsername()) == 0 { + if (!userCreated && deps.Cfg.Username.AcquireOnLogin) || + (userCreated && deps.Cfg.Username.AcquireOnRegistration) { + result = append(result, StateOnboardingUsername) + } + } + + return append(result, StateSuccess), nil +} diff --git a/backend/flow_api/flow/shared/action_skip.go b/backend/flow_api/flow/shared/action_skip.go new file mode 100644 index 000000000..8a91f13c8 --- /dev/null +++ b/backend/flow_api/flow/shared/action_skip.go @@ -0,0 +1,23 @@ +package shared + +import ( + "github.com/teamhanko/hanko/backend/flowpilot" +) + +type Skip struct { + Action +} + +func (a Skip) GetName() flowpilot.ActionName { + return ActionSkip +} + +func (a Skip) GetDescription() string { + return "Skip" +} + +func (a Skip) Initialize(c flowpilot.InitializationContext) {} + +func (a Skip) Execute(c flowpilot.ExecutionContext) error { + return c.Continue() +} diff --git a/backend/flow_api/flow/shared/action_thirdparty_oauth.go b/backend/flow_api/flow/shared/action_thirdparty_oauth.go new file mode 100644 index 000000000..9a43ecf33 --- /dev/null +++ b/backend/flow_api/flow/shared/action_thirdparty_oauth.go @@ -0,0 +1,87 @@ +package shared + +import ( + "fmt" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/thirdparty" + "github.com/teamhanko/hanko/backend/utils" + "golang.org/x/oauth2" + "net/http" + "strings" +) + +type ThirdPartyOAuth struct { + Action +} + +func (a ThirdPartyOAuth) GetName() flowpilot.ActionName { + return ActionThirdPartyOAuth +} + +func (a ThirdPartyOAuth) GetDescription() string { + return "Sign up/sign in with a third party provider via OAuth." +} + +func (a ThirdPartyOAuth) Initialize(c flowpilot.InitializationContext) { + deps := a.GetDeps(c) + + enabledProviders := deps.Cfg.ThirdParty.Providers.GetEnabled() + if len(enabledProviders) == 0 { + c.SuspendAction() + return + } + + providerInput := flowpilot.StringInput("provider"). + Hidden(true). + Required(true) + + for _, provider := range enabledProviders { + providerInput.AllowedValue(provider.DisplayName, strings.ToLower(provider.DisplayName)) + } + + c.AddInputs(flowpilot.StringInput("redirect_to").Hidden(true).Required(true), providerInput) +} + +func (a ThirdPartyOAuth) Execute(c flowpilot.ExecutionContext) error { + deps := a.GetDeps(c) + + errorRedirectTo := deps.HttpContext.Request().Header.Get("Referer") + if errorRedirectTo == "" { + errorRedirectTo = deps.Cfg.ThirdParty.ErrorRedirectURL + } + + if valid := c.ValidateInputData(); !valid { + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + redirectTo := c.Input().Get("redirect_to").String() + if ok := thirdparty.IsAllowedRedirect(deps.Cfg.ThirdParty, redirectTo); !ok { + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + provider, err := thirdparty.GetProvider(deps.Cfg.ThirdParty, c.Input().Get("provider").String()) + if err != nil { + return c.Error(flowpilot.ErrorFormDataInvalid.Wrap(err)) + } + + state, err := thirdparty.GenerateState(&deps.Cfg, provider.Name(), redirectTo, thirdparty.GenerateStateForFlowAPI(true)) + if err != nil { + return c.Error(flowpilot.ErrorTechnical.Wrap(err)) + } + + authCodeUrl := provider.AuthCodeURL(string(state), oauth2.SetAuthURLParam("prompt", "consent")) + + cookie := utils.GenerateStateCookie(&deps.Cfg, utils.HankoThirdpartyStateCookie, string(state), utils.CookieOptions{ + MaxAge: 300, + Path: "/", + SameSite: http.SameSiteLaxMode, + }) + + deps.HttpContext.SetCookie(cookie) + + if err = c.Payload().Set("redirect_url", authCodeUrl); err != nil { + return fmt.Errorf("failed to set redirect_url to payload: %w", err) + } + + return c.Continue(StateThirdParty) +} diff --git a/backend/flow_api/flow/shared/const_action_names.go b/backend/flow_api/flow/shared/const_action_names.go new file mode 100644 index 000000000..8b9ba163c --- /dev/null +++ b/backend/flow_api/flow/shared/const_action_names.go @@ -0,0 +1,42 @@ +package shared + +import "github.com/teamhanko/hanko/backend/flowpilot" + +const ( + ActionAccountDelete flowpilot.ActionName = "account_delete" + ActionBack flowpilot.ActionName = "back" + ActionContinueToPasscodeConfirmation flowpilot.ActionName = "continue_to_passcode_confirmation" + ActionContinueToPasscodeConfirmationRecovery flowpilot.ActionName = "continue_to_passcode_confirmation_recovery" + ActionContinueToPasskeyRegistration flowpilot.ActionName = "continue_to_passkey_registration" + ActionContinueToPasswordLogin flowpilot.ActionName = "continue_to_password_login" + ActionContinueToPasswordRegistration flowpilot.ActionName = "continue_to_password_registration" + ActionContinueWithLoginIdentifier flowpilot.ActionName = "continue_with_login_identifier" + ActionEmailCreate flowpilot.ActionName = "email_create" + ActionEmailDelete flowpilot.ActionName = "email_delete" + ActionEmailSetPrimary flowpilot.ActionName = "email_set_primary" + ActionEmailVerify flowpilot.ActionName = "email_verify" + ActionExchangeToken flowpilot.ActionName = "exchange_token" + ActionPasswordDelete flowpilot.ActionName = "password_delete" + ActionPasswordLogin flowpilot.ActionName = "password_login" + ActionPasswordRecovery flowpilot.ActionName = "password_recovery" + ActionPasswordCreate flowpilot.ActionName = "password_create" + ActionPasswordUpdate flowpilot.ActionName = "password_update" + ActionRegisterClientCapabilities flowpilot.ActionName = "register_client_capabilities" + ActionRegisterLoginIdentifier flowpilot.ActionName = "register_login_identifier" + ActionRegisterPassword flowpilot.ActionName = "register_password" + ActionResendPasscode flowpilot.ActionName = "resend_passcode" + ActionSkip flowpilot.ActionName = "skip" + ActionThirdPartyOAuth flowpilot.ActionName = "thirdparty_oauth" + ActionUsernameCreate flowpilot.ActionName = "username_create" + ActionUsernameUpdate flowpilot.ActionName = "username_update" + ActionUsernameDelete flowpilot.ActionName = "username_delete" + ActionEmailAddressSet flowpilot.ActionName = "email_address_set" + ActionVerifyPasscode flowpilot.ActionName = "verify_passcode" + ActionWebauthnCredentialCreate flowpilot.ActionName = "webauthn_credential_create" + ActionWebauthnCredentialDelete flowpilot.ActionName = "webauthn_credential_delete" + ActionWebauthnCredentialRename flowpilot.ActionName = "webauthn_credential_rename" + ActionWebauthnGenerateCreationOptions flowpilot.ActionName = "webauthn_generate_creation_options" + ActionWebauthnGenerateRequestOptions flowpilot.ActionName = "webauthn_generate_request_options" + ActionWebauthnVerifyAssertionResponse flowpilot.ActionName = "webauthn_verify_assertion_response" + ActionWebauthnVerifyAttestationResponse flowpilot.ActionName = "webauthn_verify_attestation_response" +) diff --git a/backend/flow_api/flow/shared/const_flow_names.go b/backend/flow_api/flow/shared/const_flow_names.go new file mode 100644 index 000000000..35b81f0ba --- /dev/null +++ b/backend/flow_api/flow/shared/const_flow_names.go @@ -0,0 +1,13 @@ +package shared + +import "github.com/teamhanko/hanko/backend/flowpilot" + +const ( + FlowLogin flowpilot.FlowName = "login" + FlowRegistration flowpilot.FlowName = "registration" + FlowCapabilities flowpilot.FlowName = "capabilities" + FlowProfile flowpilot.FlowName = "profile" + FlowCredentialUsage flowpilot.FlowName = "credential_usage" + FlowCredentialOnboarding flowpilot.FlowName = "credential_onboarding" + FlowUserDetails flowpilot.FlowName = "user_details" +) diff --git a/backend/flow_api/flow/shared/const_stash_paths.go b/backend/flow_api/flow/shared/const_stash_paths.go new file mode 100644 index 000000000..001ed8b5d --- /dev/null +++ b/backend/flow_api/flow/shared/const_stash_paths.go @@ -0,0 +1,25 @@ +package shared + +const ( + StashPathEmail = "email" + StashPathEmailVerified = "email_verified" + StashPathLoginMethod = "login_method" + StashPathNewPassword = "new_password" + StashPathPasscodeEmail = "sticky.passcode_email" + StashPathPasscodeID = "sticky.passcode_id" + StashPathPasscodeTemplate = "passcode_template" + StashPathSkipUserCreation = "skip_user_creation" + StashPathUserHasPassword = "user_has_password" + StashPathUserHasWebauthnCredential = "user_has_webauthn_credential" + StashPathUserHasUsername = "user_has_username" + StashPathUserHasEmails = "user_has_emails" + StashPathUserID = "user_id" + StashPathUsername = "username" + StashPathWebauthnAvailable = "webauthn_available" + StashPathWebauthnConditionalMediationAvailable = "webauthn_conditional_mediation_available" + StashPathWebauthnCredential = "webauthn_credential" + StashPathWebauthnSessionDataID = "webauthn_session_data_id" + StashPathUserIdentification = "user_identification" + StashPathLoginOnboardingScheduled = "login_onboarding_scheduled" + StashPathLoginOnboardingCreateEmail = "login_onboarding_create_email" +) diff --git a/backend/flow_api/flow/shared/const_state_names.go b/backend/flow_api/flow/shared/const_state_names.go new file mode 100644 index 000000000..ca5a760cc --- /dev/null +++ b/backend/flow_api/flow/shared/const_state_names.go @@ -0,0 +1,26 @@ +package shared + +import "github.com/teamhanko/hanko/backend/flowpilot" + +const ( + StateError flowpilot.StateName = "error" + StateLoginInit flowpilot.StateName = "login_init" + StateLoginMethodChooser flowpilot.StateName = "login_method_chooser" + StateLoginPasskey flowpilot.StateName = "login_passkey" + StateLoginPassword flowpilot.StateName = "login_password" + StateLoginPasswordRecovery flowpilot.StateName = "login_password_recovery" + StateOnboardingCreatePasskey flowpilot.StateName = "onboarding_create_passkey" + StateCredentialOnboardingChooser flowpilot.StateName = "credential_onboarding_chooser" + StateOnboardingVerifyPasskeyAttestation flowpilot.StateName = "onboarding_verify_passkey_attestation" + StatePasscodeConfirmation flowpilot.StateName = "passcode_confirmation" + StatePasswordCreation flowpilot.StateName = "password_creation" + StatePreflight flowpilot.StateName = "preflight" + StateProfileAccountDeleted flowpilot.StateName = "account_deleted" + StateProfileInit flowpilot.StateName = "profile_init" + StateProfileWebauthnCredentialVerification flowpilot.StateName = "webauthn_credential_verification" + StateRegistrationInit flowpilot.StateName = "registration_init" + StateSuccess flowpilot.StateName = "success" + StateThirdParty flowpilot.StateName = "thirdparty" + StateOnboardingEmail flowpilot.StateName = "onboarding_email" + StateOnboardingUsername flowpilot.StateName = "onboarding_username" +) diff --git a/backend/flow_api/flow/shared/errors.go b/backend/flow_api/flow/shared/errors.go new file mode 100644 index 000000000..d1e68413e --- /dev/null +++ b/backend/flow_api/flow/shared/errors.go @@ -0,0 +1,22 @@ +package shared + +import ( + "github.com/teamhanko/hanko/backend/flowpilot" + "net/http" +) + +var ( + ErrorPasscodeInvalid = flowpilot.NewFlowError("passcode_invalid", "The passcode is invalid.", http.StatusBadRequest) + ErrorPasskeyInvalid = flowpilot.NewFlowError("passkey_invalid", "The passkey is invalid.", http.StatusUnauthorized) + ErrorPasscodeMaxAttemptsReached = flowpilot.NewFlowError("passcode_max_attempts_reached", "The passcode was entered wrong too many times.", http.StatusUnauthorized) + ErrorRateLimitExceeded = flowpilot.NewFlowError("rate_limit_exceeded", "The rate limit has been exceeded.", http.StatusTooManyRequests) + ErrorNotFound = flowpilot.NewFlowError("not_found", "The requested resource was not found.", http.StatusNotFound) + ErrorUnauthorized = flowpilot.NewFlowError("unauthorized", "The session is invalid.", http.StatusUnauthorized) +) + +var ( + ErrorEmailAlreadyExists = flowpilot.NewInputError("email_already_exists", "The email address already exists.") + ErrorUsernameAlreadyExists = flowpilot.NewInputError("username_already_exists", "The username already exists.") + ErrorUnknownUsername = flowpilot.NewInputError("unknown_username_error", "The username is unknown.") + ErrorInvalidUsername = flowpilot.NewInputError("invalid_username_error", "The username is invalid.") +) diff --git a/backend/flow_api/flow/shared/flow.go b/backend/flow_api/flow/shared/flow.go new file mode 100644 index 000000000..686f6e327 --- /dev/null +++ b/backend/flow_api/flow/shared/flow.go @@ -0,0 +1,38 @@ +package shared + +import ( + "github.com/gobuffalo/pop/v6" + "github.com/labstack/echo/v4" + "github.com/sethvargo/go-limiter" + auditlog "github.com/teamhanko/hanko/backend/audit_log" + "github.com/teamhanko/hanko/backend/config" + "github.com/teamhanko/hanko/backend/ee/saml" + "github.com/teamhanko/hanko/backend/flow_api/services" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/mapper" + "github.com/teamhanko/hanko/backend/persistence" + "github.com/teamhanko/hanko/backend/session" +) + +type Dependencies struct { + Cfg config.Config + HttpContext echo.Context + PasscodeService services.Passcode + PasswordService services.Password + WebauthnService services.WebauthnService + SamlService saml.Service + Persister persistence.Persister + SessionManager session.Manager + PasscodeRateLimiter limiter.Store + PasswordRateLimiter limiter.Store + TokenExchangeRateLimiter limiter.Store + Tx *pop.Connection + AuthenticatorMetadata mapper.AuthenticatorMetadata + AuditLogger auditlog.Logger +} + +type Action struct{} + +func (a *Action) GetDeps(c flowpilot.Context) *Dependencies { + return c.Get("deps").(*Dependencies) +} diff --git a/backend/flow_api/flow/shared/hook_email_persist_verified_status.go b/backend/flow_api/flow/shared/hook_email_persist_verified_status.go new file mode 100644 index 000000000..113018b85 --- /dev/null +++ b/backend/flow_api/flow/shared/hook_email_persist_verified_status.go @@ -0,0 +1,122 @@ +package shared + +import ( + "errors" + "fmt" + "github.com/gofrs/uuid" + auditlog "github.com/teamhanko/hanko/backend/audit_log" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +type EmailPersistVerifiedStatus struct { + Action +} + +func (h EmailPersistVerifiedStatus) Execute(c flowpilot.HookExecutionContext) error { + deps := h.GetDeps(c) + + if !c.Stash().Get(StashPathEmailVerified).Bool() { + return nil + } + + if !c.Stash().Get(StashPathEmail).Exists() { + return errors.New("verified email not set on the stash") + } + + if !c.Stash().Get(StashPathUserID).Exists() { + return errors.New("user_id not set on the stash") + } + + userId, err := uuid.FromString(c.Stash().Get(StashPathUserID).String()) + if err != nil { + return fmt.Errorf("failed to parse stashed user_id into a uuid: %w", err) + } + + emailAddressToVerify := c.Stash().Get(StashPathEmail).String() + + emailAddressToVerifyModel, err := deps.Persister.GetEmailPersisterWithConnection(deps.Tx).FindByAddress(emailAddressToVerify) + if err != nil { + return fmt.Errorf("could not fetch email: %w", err) + } + + var emailCreated bool + if emailAddressToVerifyModel == nil { + newEmailModel := models.NewEmail(&userId, emailAddressToVerify) + newEmailModel.Verified = true + + err := deps.Persister.GetEmailPersisterWithConnection(deps.Tx).Create(*newEmailModel) + if err != nil { + return fmt.Errorf("could not save email: %w", err) + } + + emailModels, err := deps.Persister.GetEmailPersisterWithConnection(deps.Tx).FindByUserId(*newEmailModel.UserID) + if err != nil { + return fmt.Errorf("could fetch emails: %w", err) + } + + if userModel, ok := c.Get("session_user").(*models.User); ok { + userModel.Emails = append(userModel.Emails, *newEmailModel) + } + + if len(emailModels) == 1 && emailModels[0].ID.String() == newEmailModel.ID.String() { + // The user has only one 1 email and it is the email we just added. It makes sense then, + // to automatically set this as the primary email. + primaryEmailModel := models.NewPrimaryEmail(newEmailModel.ID, userId) + err = deps.Persister.GetPrimaryEmailPersisterWithConnection(deps.Tx).Create(*primaryEmailModel) + if err != nil { + return fmt.Errorf("could not save primary email: %w", err) + } + + if userModel, ok := c.Get("session_user").(*models.User); ok { + userModel.SetPrimaryEmail(primaryEmailModel) + } + } + + emailCreated = true + } else if !emailAddressToVerifyModel.Verified { + emailAddressToVerifyModel.Verified = true + err = deps.Persister.GetEmailPersisterWithConnection(deps.Tx).Update(*emailAddressToVerifyModel) + if err != nil { + return fmt.Errorf("could not update email: %w", err) + } + + if userModel, ok := c.Get("session_user").(*models.User); ok { + userModel.UpdateEmail(*emailAddressToVerifyModel) + } + } + + // Audit log verification only if this is not a login via passcode because it implies verification. + // Only login actions should set the "login_method" stash entry. + if c.Stash().Get(StashPathLoginMethod).String() != "passcode" { + err = deps.AuditLogger.CreateWithConnection( + deps.Tx, + deps.HttpContext, + models.AuditLogEmailVerified, + &models.User{ID: userId}, + nil, + auditlog.Detail("email", emailAddressToVerify), + auditlog.Detail("flow_id", c.GetFlowID())) + + if err != nil { + return fmt.Errorf("could not create audit log: %w", err) + } + } + + if emailCreated { + err = deps.AuditLogger.CreateWithConnection( + deps.Tx, + deps.HttpContext, + models.AuditLogEmailCreated, + &models.User{ID: userId}, + nil, + auditlog.Detail("email", emailAddressToVerify), + auditlog.Detail("flow_id", c.GetFlowID())) + + if err != nil { + return fmt.Errorf("could not create audit log: %w", err) + } + } + + return nil +} diff --git a/backend/flow_api/flow/shared/hook_generate_oauth_links.go b/backend/flow_api/flow/shared/hook_generate_oauth_links.go new file mode 100644 index 000000000..612c36809 --- /dev/null +++ b/backend/flow_api/flow/shared/hook_generate_oauth_links.go @@ -0,0 +1,64 @@ +package shared + +import ( + "fmt" + "github.com/labstack/echo/v4" + "github.com/teamhanko/hanko/backend/flowpilot" + "net/url" +) + +type GenerateOAuthLinks struct { + Action +} + +func (h GenerateOAuthLinks) Execute(c flowpilot.HookExecutionContext) error { + deps := h.GetDeps(c) + + returnToUrl := deps.Cfg.ThirdParty.DefaultRedirectURL + + referer := deps.HttpContext.Request().Header.Get("Referer") + if referer != "" { + u, err := url.Parse(referer) + if err != nil { + return err + } + + // remove any query and fragment parts of the referer + u.RawQuery = "" + u.Fragment = "" + returnToUrl = u.String() + } + + if deps.Cfg.ThirdParty.Providers.GitHub.Enabled { + c.AddLink(OAuthLink("github", h.generateHref(deps.HttpContext, "github", returnToUrl))) + } + if deps.Cfg.ThirdParty.Providers.Google.Enabled { + c.AddLink(OAuthLink("google", h.generateHref(deps.HttpContext, "google", returnToUrl))) + } + if deps.Cfg.ThirdParty.Providers.Apple.Enabled { + c.AddLink(OAuthLink("apple", h.generateHref(deps.HttpContext, "apple", returnToUrl))) + } + + return nil +} + +func (h GenerateOAuthLinks) generateHref(c echo.Context, provider string, returnToUrl string) string { + host := c.Request().Host + forwardedProto := c.Request().Header.Get("X-Forwarded-Proto") + if forwardedProto == "" { + // Assume that a proxy is setting the X-Forwarded-Proto header correctly. Hanko should always be deployed behind a proxy, + // because you cannot start the backend with https and passkeys only work in a secure context. + // If the X-Forwarded-Proto header is not set, set it to 'http' because otherwise you would need to set up a https environment for local testing. + forwardedProto = "http" + } + + u, _ := url.Parse(fmt.Sprintf("%s://%s/thirdparty/auth", forwardedProto, host)) + query := url.Values{} + query.Set("provider", provider) + if returnToUrl != "" { + query.Set("redirect_to", returnToUrl) + } + u.RawQuery = query.Encode() + + return u.String() +} diff --git a/backend/flow_api/flow/shared/hook_get_user_data.go b/backend/flow_api/flow/shared/hook_get_user_data.go new file mode 100644 index 000000000..351a15420 --- /dev/null +++ b/backend/flow_api/flow/shared/hook_get_user_data.go @@ -0,0 +1,33 @@ +package shared + +import ( + "fmt" + "github.com/gofrs/uuid" + "github.com/teamhanko/hanko/backend/dto" + "github.com/teamhanko/hanko/backend/flowpilot" +) + +type GetUserData struct { + Action +} + +func (h GetUserData) Execute(c flowpilot.HookExecutionContext) error { + deps := h.GetDeps(c) + + userId, err := uuid.FromString(c.Stash().Get("user_id").String()) + if err != nil { + return fmt.Errorf("failed to parse stashed user_id into a uuid: %w", err) + } + + userModel, err := deps.Persister.GetUserPersisterWithConnection(deps.Tx).Get(userId) + if err != nil { + return fmt.Errorf("failed to get user from db: %w", err) + } + + err = c.Payload().Set("user", dto.ProfileDataFromUserModel(userModel)) + if err != nil { + return fmt.Errorf("failed to set user payload: %w", err) + } + + return nil +} diff --git a/backend/flow_api/flow/shared/hook_issue_session.go b/backend/flow_api/flow/shared/hook_issue_session.go new file mode 100644 index 000000000..0e6aa7b62 --- /dev/null +++ b/backend/flow_api/flow/shared/hook_issue_session.go @@ -0,0 +1,78 @@ +package shared + +import ( + "errors" + "fmt" + "github.com/gofrs/uuid" + auditlog "github.com/teamhanko/hanko/backend/audit_log" + "github.com/teamhanko/hanko/backend/dto" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +type IssueSession struct { + Action +} + +func (h IssueSession) Execute(c flowpilot.HookExecutionContext) error { + deps := h.GetDeps(c) + + var userId uuid.UUID + var err error + if c.Stash().Get(StashPathUserID).Exists() { + userId, err = uuid.FromString(c.Stash().Get(StashPathUserID).String()) + if err != nil { + return fmt.Errorf("failed to parse stashed user_id into a uuid: %w", err) + } + } else { + return errors.New("user_id not found in stash") + } + + emails, err := deps.Persister.GetEmailPersisterWithConnection(deps.Tx).FindByUserId(userId) + if err != nil { + return fmt.Errorf("failed to fetch emails from db: %w", err) + } + + var emailDTO *dto.EmailJwt + + if email := emails.GetPrimary(); email != nil { + emailDTO = dto.JwtFromEmailModel(email) + } + + sessionToken, err := deps.SessionManager.GenerateJWT(userId, emailDTO) + if err != nil { + return fmt.Errorf("failed to generate JWT: %w", err) + } + + cookie, err := deps.SessionManager.GenerateCookie(sessionToken) + if err != nil { + return fmt.Errorf("failed to generate auth cookie, %w", err) + } + + deps.HttpContext.Response().Header().Set("X-Session-Lifetime", fmt.Sprintf("%d", cookie.MaxAge)) + + if deps.Cfg.Session.EnableAuthTokenHeader { + deps.HttpContext.Response().Header().Set("X-Auth-Token", sessionToken) + } else { + deps.HttpContext.SetCookie(cookie) + } + + // Audit log logins only, because user creation on registration implies that the user is logged + // in after a registration. Only login actions should set the "login_method" stash entry. + if c.Stash().Get(StashPathLoginMethod).Exists() { + err = deps.AuditLogger.CreateWithConnection( + deps.Tx, + deps.HttpContext, + models.AuditLogLoginSuccess, + &models.User{ID: userId}, + err, + auditlog.Detail("login_method", c.Stash().Get(StashPathLoginMethod).String()), + auditlog.Detail("flow_id", c.GetFlowID())) + + if err != nil { + return fmt.Errorf("could not create audit log: %w", err) + } + } + + return nil +} diff --git a/backend/flow_api/flow/shared/hook_password_save.go b/backend/flow_api/flow/shared/hook_password_save.go new file mode 100644 index 000000000..afc3b09ec --- /dev/null +++ b/backend/flow_api/flow/shared/hook_password_save.go @@ -0,0 +1,34 @@ +package shared + +import ( + "fmt" + "github.com/gofrs/uuid" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +type PasswordSave struct { + Action +} + +func (h PasswordSave) Execute(c flowpilot.HookExecutionContext) error { + deps := h.GetDeps(c) + + if !c.Stash().Get(StashPathNewPassword).Exists() { + return nil + } + + passwordId, _ := uuid.NewV4() + passwordCredential := models.PasswordCredential{ + ID: passwordId, + UserId: uuid.FromStringOrNil(c.Stash().Get(StashPathUserID).String()), + Password: c.Stash().Get(StashPathNewPassword).String(), + } + + err := deps.Persister.GetPasswordCredentialPersister().Create(passwordCredential) + if err != nil { + return fmt.Errorf("could not create password: %w", err) + } + // TODO: add audit log? + return nil +} diff --git a/backend/flow_api/flow/shared/hook_persist_webauthn_credential.go b/backend/flow_api/flow/shared/hook_persist_webauthn_credential.go new file mode 100644 index 000000000..0a6cc6b21 --- /dev/null +++ b/backend/flow_api/flow/shared/hook_persist_webauthn_credential.go @@ -0,0 +1,66 @@ +package shared + +import ( + "encoding/json" + "fmt" + webauthnLib "github.com/go-webauthn/webauthn/webauthn" + "github.com/gofrs/uuid" + auditlog "github.com/teamhanko/hanko/backend/audit_log" + "github.com/teamhanko/hanko/backend/dto/intern" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +type WebauthnCredentialSave struct { + Action +} + +func (h WebauthnCredentialSave) Execute(c flowpilot.HookExecutionContext) error { + deps := h.GetDeps(c) + + if !c.Stash().Get(StashPathUserID).Exists() { + return nil + } + + userId, err := uuid.FromString(c.Stash().Get(StashPathUserID).String()) + if err != nil { + return fmt.Errorf("failed to parse stashed user_id into a uuid: %w", err) + } + + if !c.Stash().Get(StashPathWebauthnCredential).Exists() { + return nil + } + + webauthnCredentialJson := c.Stash().Get(StashPathWebauthnCredential).String() + + var webauthnCredential webauthnLib.Credential + err = json.Unmarshal([]byte(webauthnCredentialJson), &webauthnCredential) + if err != nil { + return fmt.Errorf("failed to unmarshal stashed webauthn_credential: %w", err) + } + + credentialModel := intern.WebauthnCredentialToModel(&webauthnCredential, userId, webauthnCredential.Flags.BackupEligible, webauthnCredential.Flags.BackupState, deps.AuthenticatorMetadata) + err = deps.Persister.GetWebauthnCredentialPersisterWithConnection(deps.Tx).Create(*credentialModel) + if err != nil { + return fmt.Errorf("failed so save credential: %w", err) + } + + err = deps.AuditLogger.CreateWithConnection( + deps.Tx, + deps.HttpContext, + models.AuditLogPasskeyCreated, + &models.User{ID: userId}, + nil, + auditlog.Detail("credential_id", credentialModel.ID), + auditlog.Detail("flow_id", c.GetFlowID())) + + if err != nil { + return fmt.Errorf("could not create audit log: %w", err) + } + + if userModel, ok := c.Get("session_user").(*models.User); ok { + userModel.WebauthnCredentials = append(userModel.WebauthnCredentials, *credentialModel) + } + + return nil +} diff --git a/backend/flow_api/flow/shared/links.go b/backend/flow_api/flow/shared/links.go new file mode 100644 index 000000000..6929d49c7 --- /dev/null +++ b/backend/flow_api/flow/shared/links.go @@ -0,0 +1,25 @@ +package shared + +import "github.com/teamhanko/hanko/backend/flowpilot" + +// Link categories enumeration. +const ( + CategoryLegal flowpilot.LinkCategory = "legal" + CategoryOauth flowpilot.LinkCategory = "oauth" + CategoryOther flowpilot.LinkCategory = "other" +) + +// LegalLink creates a new link with legal the category "legal". +func LegalLink(name string, href string) flowpilot.Link { + return flowpilot.NewLink(name, CategoryLegal, href).Target(flowpilot.LinkTargetBlank) +} + +// OAuthLink creates a new link with legal the category "oauth". +func OAuthLink(name string, href string) flowpilot.Link { + return flowpilot.NewLink(name, CategoryOauth, href) +} + +// OtherLink creates a new link with legal the category "other". +func OtherLink(name string, href string) flowpilot.Link { + return flowpilot.NewLink(name, CategoryOther, href) +} diff --git a/backend/flow_api/flow/user_details/action_set_email.go b/backend/flow_api/flow/user_details/action_set_email.go new file mode 100644 index 000000000..1fa6e9b09 --- /dev/null +++ b/backend/flow_api/flow/user_details/action_set_email.go @@ -0,0 +1,87 @@ +package user_details + +import ( + "fmt" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flowpilot" +) + +type EmailAddressSet struct { + shared.Action +} + +func (a EmailAddressSet) GetName() flowpilot.ActionName { + return shared.ActionEmailAddressSet +} + +func (a EmailAddressSet) GetDescription() string { + return "Set a new email address." +} + +func (a EmailAddressSet) Initialize(c flowpilot.InitializationContext) { + deps := a.GetDeps(c) + + c.AddInputs(flowpilot.StringInput("email"). + Required(!deps.Cfg.Email.Optional). + MaxLength(deps.Cfg.Email.MaxLength). + Preserve(true). + TrimSpace(true). + LowerCase(true)) +} + +func (a EmailAddressSet) Execute(c flowpilot.ExecutionContext) error { + deps := a.GetDeps(c) + + if valid := c.ValidateInputData(); !valid { + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + email := c.Input().Get("email").String() + + err := c.Stash().Set(shared.StashPathEmail, email) + if err != nil { + return fmt.Errorf("failed to stash email address: %w", err) + } + + existingEmail, err := deps.Persister.GetEmailPersister().FindByAddress(email) + if err != nil { + return fmt.Errorf("failed to get email from db: %w", err) + } + + if deps.Cfg.Email.RequireVerification { + // Email verification is enabled. Send an email regardless of whether the email address exists, but select the + // appropriate passcode template beforehand. + if existingEmail != nil { + err = c.Stash().Set(shared.StashPathPasscodeTemplate, "email_registration_attempted") // "email_verification" + if err != nil { + return fmt.Errorf("failed to set passcode_template to the stash: %w", err) + } + } else { + err = c.Stash().Set(shared.StashPathPasscodeTemplate, "email_verification") + if err != nil { + return fmt.Errorf("failed to set passcode_template to the stash: %w", err) + } + } + + if err = c.Stash().Set(shared.StashPathLoginOnboardingCreateEmail, true); err != nil { + return fmt.Errorf("failed to set login_onboarding_create_email to the stash: %w", err) + } + + return c.Continue(shared.StatePasscodeConfirmation) + } + + // Email verification is turned off, hence we can display an error if the email already exists, or continue the flow + // without passcode verification otherwise. + if existingEmail != nil { + c.Input().SetError("email", shared.ErrorEmailAlreadyExists) + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + if err = c.Stash().Set(shared.StashPathLoginOnboardingCreateEmail, true); err != nil { + return fmt.Errorf("failed to set login_onboarding_create_email to the stash: %w", err) + } + + c.PreventRevert() + + return c.Continue() +} diff --git a/backend/flow_api/flow/user_details/action_set_username.go b/backend/flow_api/flow/user_details/action_set_username.go new file mode 100644 index 000000000..d0594d0c6 --- /dev/null +++ b/backend/flow_api/flow/user_details/action_set_username.go @@ -0,0 +1,69 @@ +package user_details + +import ( + "fmt" + "github.com/gofrs/uuid" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flow_api/services" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +type UsernameSet struct { + shared.Action +} + +func (a UsernameSet) GetName() flowpilot.ActionName { + return shared.ActionUsernameCreate +} + +func (a UsernameSet) GetDescription() string { + return "Set a new username." +} + +func (a UsernameSet) Initialize(c flowpilot.InitializationContext) { + deps := a.GetDeps(c) + + c.AddInputs(flowpilot.StringInput("username"). + Required(!deps.Cfg.Username.Optional). + MinLength(deps.Cfg.Username.MinLength). + MaxLength(deps.Cfg.Username.MaxLength). + TrimSpace(true). + LowerCase(true)) +} + +func (a UsernameSet) Execute(c flowpilot.ExecutionContext) error { + deps := a.GetDeps(c) + + if valid := c.ValidateInputData(); !valid { + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + userID := uuid.FromStringOrNil(c.Stash().Get(shared.StashPathUserID).String()) + username := c.Input().Get("username").String() + + if !services.ValidateUsername(username) { + c.Input().SetError("username", shared.ErrorInvalidUsername) + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + duplicateUsername, err := deps.Persister.GetUsernamePersisterWithConnection(deps.Tx).GetByName(username) + if err != nil { + return fmt.Errorf("failed to get user from db: %w", err) + } + + if duplicateUsername != nil && duplicateUsername.ID.String() != userID.String() { + c.Input().SetError("username", shared.ErrorUsernameAlreadyExists) + return c.Error(flowpilot.ErrorFormDataInvalid) + } + + usernameModel := models.NewUsername(userID, username) + err = deps.Persister.GetUsernamePersisterWithConnection(deps.Tx).Create(*usernameModel) + if err != nil { + return fmt.Errorf("failed to create username: %w", err) + } + + c.PreventRevert() + + return c.Continue() +} diff --git a/backend/flow_api/flow/user_details/action_skip_email.go b/backend/flow_api/flow/user_details/action_skip_email.go new file mode 100644 index 000000000..5daec6b1e --- /dev/null +++ b/backend/flow_api/flow/user_details/action_skip_email.go @@ -0,0 +1,33 @@ +package user_details + +import ( + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flowpilot" +) + +type SkipEmail struct { + shared.Action +} + +func (a SkipEmail) GetName() flowpilot.ActionName { + return shared.ActionSkip +} + +func (a SkipEmail) GetDescription() string { + return "Skip" +} + +func (a SkipEmail) Initialize(c flowpilot.InitializationContext) { + deps := a.GetDeps(c) + + if !deps.Cfg.Email.Optional { + c.SuspendAction() + } +} + +func (a SkipEmail) Execute(c flowpilot.ExecutionContext) error { + c.PreventRevert() + + return c.Continue() + +} diff --git a/backend/flow_api/flow/user_details/action_skip_username.go b/backend/flow_api/flow/user_details/action_skip_username.go new file mode 100644 index 000000000..36e63733f --- /dev/null +++ b/backend/flow_api/flow/user_details/action_skip_username.go @@ -0,0 +1,31 @@ +package user_details + +import ( + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flowpilot" +) + +type SkipUsername struct { + shared.Action +} + +func (a SkipUsername) GetName() flowpilot.ActionName { + return shared.ActionSkip +} + +func (a SkipUsername) GetDescription() string { + return "Skip" +} + +func (a SkipUsername) Initialize(c flowpilot.InitializationContext) { + deps := a.GetDeps(c) + + if !deps.Cfg.Username.Optional { + c.SuspendAction() + } +} +func (a SkipUsername) Execute(c flowpilot.ExecutionContext) error { + c.PreventRevert() + + return c.Continue() +} diff --git a/backend/flow_api/handler.go b/backend/flow_api/handler.go new file mode 100644 index 000000000..6a8ab6390 --- /dev/null +++ b/backend/flow_api/handler.go @@ -0,0 +1,163 @@ +package flow_api + +import ( + "fmt" + "github.com/gobuffalo/pop/v6" + echojwt "github.com/labstack/echo-jwt/v4" + "github.com/labstack/echo/v4" + "github.com/rs/zerolog" + zeroLogger "github.com/rs/zerolog/log" + "github.com/sethvargo/go-limiter" + auditlog "github.com/teamhanko/hanko/backend/audit_log" + "github.com/teamhanko/hanko/backend/config" + "github.com/teamhanko/hanko/backend/ee/saml" + "github.com/teamhanko/hanko/backend/flow_api/flow" + "github.com/teamhanko/hanko/backend/flow_api/flow/shared" + "github.com/teamhanko/hanko/backend/flow_api/services" + "github.com/teamhanko/hanko/backend/flowpilot" + "github.com/teamhanko/hanko/backend/mapper" + "github.com/teamhanko/hanko/backend/persistence" + "github.com/teamhanko/hanko/backend/persistence/models" + "github.com/teamhanko/hanko/backend/session" + "strconv" + "time" +) + +type FlowPilotHandler struct { + Persister persistence.Persister + Cfg config.Config + PasscodeService services.Passcode + PasswordService services.Password + WebauthnService services.WebauthnService + SamlService saml.Service + SessionManager session.Manager + PasscodeRateLimiter limiter.Store + PasswordRateLimiter limiter.Store + TokenExchangeRateLimiter limiter.Store + AuthenticatorMetadata mapper.AuthenticatorMetadata + AuditLogger auditlog.Logger +} + +func (h *FlowPilotHandler) RegistrationFlowHandler(c echo.Context) error { + return h.executeFlow(c, flow.RegistrationFlow.Debug(h.Cfg.Debug).MustBuild()) +} + +func (h *FlowPilotHandler) LoginFlowHandler(c echo.Context) error { + return h.executeFlow(c, flow.LoginFlow.Debug(h.Cfg.Debug).MustBuild()) +} + +func (h *FlowPilotHandler) ProfileFlowHandler(c echo.Context) error { + profileFlow := flow.ProfileFlow.Debug(h.Cfg.Debug).MustBuild() + + if err := h.validateSession(c); err != nil { + flowResult := profileFlow.ResultFromError(err) + return c.JSON(flowResult.GetStatus(), flowResult.GetResponse()) + } + + return h.executeFlow(c, profileFlow) +} + +func (h *FlowPilotHandler) validateSession(c echo.Context) error { + lookup := fmt.Sprintf("header:Authorization:Bearer,cookie:%s", h.Cfg.Session.Cookie.GetName()) + extractors, err := echojwt.CreateExtractors(lookup) + + if err != nil { + return flowpilot.ErrorTechnical.Wrap(err) + } + + var lastExtractorErr, lastTokenErr error + for _, extractor := range extractors { + auths, extractorErr := extractor(c) + if extractorErr != nil { + lastExtractorErr = extractorErr + continue + } + for _, auth := range auths { + token, tokenErr := h.SessionManager.Verify(auth) + if tokenErr != nil { + lastTokenErr = tokenErr + continue + } + + c.Set("session", token) + + return nil + } + } + + if lastTokenErr != nil { + return shared.ErrorUnauthorized.Wrap(lastTokenErr) + } else if lastExtractorErr != nil { + return shared.ErrorUnauthorized.Wrap(lastExtractorErr) + } + + return nil +} + +func (h *FlowPilotHandler) executeFlow(c echo.Context, flow flowpilot.Flow) error { + const queryParamKey = "action" + + var err error + var inputData flowpilot.InputData + var flowResult flowpilot.FlowResult + + txFunc := func(tx *pop.Connection) error { + deps := &shared.Dependencies{ + Cfg: h.Cfg, + PasscodeRateLimiter: h.PasscodeRateLimiter, + PasswordRateLimiter: h.PasswordRateLimiter, + TokenExchangeRateLimiter: h.TokenExchangeRateLimiter, + Tx: tx, + Persister: h.Persister, + HttpContext: c, + SessionManager: h.SessionManager, + PasscodeService: h.PasscodeService, + PasswordService: h.PasswordService, + WebauthnService: h.WebauthnService, + SamlService: h.SamlService, + AuthenticatorMetadata: h.AuthenticatorMetadata, + AuditLogger: h.AuditLogger, + } + + flow.Set("deps", deps) + + flowResult, err = flow.Execute(models.NewFlowDB(tx), + flowpilot.WithQueryParamKey(queryParamKey), + flowpilot.WithQueryParamValue(c.QueryParam(queryParamKey)), + flowpilot.WithInputData(inputData), + flowpilot.UseCompression(!h.Cfg.Debug)) + + return err + } + + err = c.Bind(&inputData) + if err != nil { + flowResult = flow.ResultFromError(flowpilot.ErrorTechnical.Wrap(err)) + } else { + err = h.Persister.Transaction(txFunc) + if err != nil { + flowResult = flow.ResultFromError(err) + } + } + + log := zeroLogger.Info(). + Str("time_unix", strconv.FormatInt(time.Now().Unix(), 10)). + Str("id", c.Response().Header().Get(echo.HeaderXRequestID)). + Str("remote_ip", c.RealIP()).Str("host", c.Request().Host). + Str("method", c.Request().Method).Str("uri", c.Request().RequestURI). + Str("user_agent", c.Request().UserAgent()).Int("status", flowResult.GetStatus()). + Str("referer", c.Request().Referer()) + if flowResult.GetResponse().Error != nil { + log.Str("error", fmt.Sprintf(flowResult.GetResponse().Error.Code)) + if flowResult.GetResponse().Error.Internal != nil { + log.Str("error_internal", *flowResult.GetResponse().Error.Internal) + } + } + log.Send() + + return c.JSON(flowResult.GetStatus(), flowResult.GetResponse()) +} + +func init() { + zerolog.TimeFieldFormat = time.RFC3339Nano +} diff --git a/backend/flow_api/services/email.go b/backend/flow_api/services/email.go new file mode 100644 index 000000000..b174ce1f7 --- /dev/null +++ b/backend/flow_api/services/email.go @@ -0,0 +1,62 @@ +package services + +import ( + "fmt" + "github.com/teamhanko/hanko/backend/config" + "github.com/teamhanko/hanko/backend/mail" + "gopkg.in/gomail.v2" +) + +type Email struct { + renderer *mail.Renderer + mailer mail.Mailer + cfg config.Config +} + +func NewEmailService(cfg config.Config) (*Email, error) { + renderer, err := mail.NewRenderer() + if err != nil { + return nil, err + } + mailer, err := mail.NewMailer(cfg.EmailDelivery.SMTP) + if err != nil { + panic(fmt.Errorf("failed to create mailer: %w", err)) + } + + return &Email{ + renderer, + mailer, + cfg, + }, nil +} + +// SendEmail sends an email with a translated specified template as body. +// The template name must be the name of the template without the content type and the file ending. +// E.g. when the file is created as "email_verification_text.tmpl" then the template name is just "email_verification" +// Currently only "[template_name]_text.tmpl" template can be used. +// The subject header of an email is also translated. The message_key must be "subject_[template_name]". +func (s *Email) SendEmail(template string, lang string, data map[string]interface{}, emailAddress string) error { + text, err := s.renderer.Render(fmt.Sprintf("%s_text.tmpl", template), lang, data) + if err != nil { + return err + } + //html, err := s.renderer.Render(fmt.Sprintf("%s_html.tmpl", template), lang, data) + if err != nil { + return err + } + + message := gomail.NewMessage() + message.SetAddressHeader("To", emailAddress, "") + message.SetAddressHeader("From", s.cfg.EmailDelivery.FromAddress, s.cfg.EmailDelivery.FromName) + + message.SetHeader("Subject", s.renderer.Translate(lang, fmt.Sprintf("subject_%s", template), data)) + message.SetBody("text/plain", text) + //message.AddAlternative("text/html", html) + + err = s.mailer.Send(message) + if err != nil { + return err + } + + return nil +} diff --git a/backend/flow_api/services/passcode.go b/backend/flow_api/services/passcode.go new file mode 100644 index 000000000..842cada9b --- /dev/null +++ b/backend/flow_api/services/passcode.go @@ -0,0 +1,173 @@ +package services + +import ( + "errors" + "fmt" + "github.com/gobuffalo/pop/v6" + "github.com/gofrs/uuid" + "github.com/teamhanko/hanko/backend/config" + "github.com/teamhanko/hanko/backend/crypto" + "github.com/teamhanko/hanko/backend/persistence" + "github.com/teamhanko/hanko/backend/persistence/models" + "golang.org/x/crypto/bcrypt" + "time" +) + +var maxPasscodeTries = 3 + +var ( + ErrorPasscodeInvalid = errors.New("passcode invalid") + ErrorPasscodeNotFound = errors.New("passcode not found") + ErrorPasscodeExpired = errors.New("passcode is expired") + ErrorPasscodeMaxAttemptsReached = errors.New("the passcode was entered wrong too many times") +) + +type SendPasscodeParams struct { + Template string + EmailAddress string + Language string +} + +type ValidatePasscodeParams struct { + Tx *pop.Connection + PasscodeID uuid.UUID +} + +type Passcode interface { + ValidatePasscode(ValidatePasscodeParams) (bool, error) + SendPasscode(SendPasscodeParams) (uuid.UUID, error) + VerifyPasscodeCode(tx *pop.Connection, passcodeID uuid.UUID, passcode string) error +} + +type passcode struct { + emailService Email + passcodeGenerator crypto.PasscodeGenerator + persister persistence.Persister + cfg config.Config +} + +func NewPasscodeService(cfg config.Config, emailService Email, persister persistence.Persister) Passcode { + return &passcode{ + emailService, + crypto.NewPasscodeGenerator(), + persister, + cfg, + } +} + +func (s *passcode) ValidatePasscode(p ValidatePasscodeParams) (bool, error) { + if !p.PasscodeID.IsNil() { + _, err := s.getPasscode(p.Tx, p.PasscodeID) + if err != nil { + if errors.Is(err, ErrorPasscodeNotFound) || errors.Is(err, ErrorPasscodeExpired) || errors.Is(err, ErrorPasscodeMaxAttemptsReached) { + return false, nil + } else { + return false, fmt.Errorf("failed to get passcode from db: %v", err) + } + } + + return true, nil + } + + return false, nil +} + +func (s *passcode) VerifyPasscodeCode(tx *pop.Connection, passcodeID uuid.UUID, value string) error { + passcodePersister := s.persister.GetPasscodePersisterWithConnection(tx) + passcodeModel, err := s.getPasscode(tx, passcodeID) + if err != nil { + return err + } + + err = bcrypt.CompareHashAndPassword([]byte(passcodeModel.Code), []byte(value)) + if err != nil { + passcodeModel.TryCount += 1 + + err = passcodePersister.Update(*passcodeModel) + if err != nil { + return fmt.Errorf("failed to update passcode: %w", err) + } + + if passcodeModel.TryCount >= maxPasscodeTries { + return ErrorPasscodeMaxAttemptsReached + } + + return ErrorPasscodeInvalid + } + + err = passcodePersister.Delete(*passcodeModel) + if err != nil { + return fmt.Errorf("failed to delete passcode from db: %w", err) + } + + return nil +} + +func (s *passcode) SendPasscode(p SendPasscodeParams) (uuid.UUID, error) { + code, err := s.passcodeGenerator.Generate() + if err != nil { + return uuid.Nil, err + } + hashedPasscode, err := bcrypt.GenerateFromPassword([]byte(code), 12) + if err != nil { + return uuid.Nil, err + } + + passcodeId, err := uuid.NewV4() + if err != nil { + return uuid.Nil, err + } + + now := time.Now().UTC() + passcodeModel := models.Passcode{ + ID: passcodeId, + Ttl: s.cfg.Email.PasscodeTtl, + Code: string(hashedPasscode), + TryCount: 0, + CreatedAt: now, + UpdatedAt: now, + } + + err = s.persister.GetPasscodePersister().Create(passcodeModel) + if err != nil { + return uuid.Nil, err + } + + durationTTL := time.Duration(passcodeModel.Ttl) * time.Second + data := map[string]interface{}{ + "Code": code, + "ServiceName": s.cfg.Service.Name, + "TTL": fmt.Sprintf("%.0f", durationTTL.Minutes()), + } + + err = s.emailService.SendEmail(p.Template, p.Language, data, p.EmailAddress) + if err != nil { + return uuid.Nil, err + } + + return passcodeId, nil +} + +func (s *passcode) getPasscode(tx *pop.Connection, passcodeID uuid.UUID) (*models.Passcode, error) { + passcodePersister := s.persister.GetPasscodePersisterWithConnection(tx) + + passcodeModel, err := passcodePersister.Get(passcodeID) + if err != nil { + return nil, fmt.Errorf("failed to get passcode from db: %w", err) + } + + if passcodeModel == nil { + return nil, ErrorPasscodeNotFound + } + + expirationTime := passcodeModel.CreatedAt.Add(time.Duration(passcodeModel.Ttl) * time.Second) + if expirationTime.Before(time.Now().UTC()) { + return nil, ErrorPasscodeExpired + } + + if passcodeModel.TryCount >= maxPasscodeTries { + return nil, ErrorPasscodeMaxAttemptsReached + } + + return passcodeModel, nil +} diff --git a/backend/flow_api/services/password.go b/backend/flow_api/services/password.go new file mode 100644 index 000000000..215cb29e7 --- /dev/null +++ b/backend/flow_api/services/password.go @@ -0,0 +1,113 @@ +package services + +import ( + "errors" + "fmt" + "github.com/gofrs/uuid" + "github.com/teamhanko/hanko/backend/config" + "github.com/teamhanko/hanko/backend/persistence" + "github.com/teamhanko/hanko/backend/persistence/models" + "golang.org/x/crypto/bcrypt" +) + +var ( + ErrorPasswordInvalid = errors.New("password invalid") +) + +type Password interface { + VerifyPassword(userId uuid.UUID, password string) error + RecoverPassword(userId uuid.UUID, newPassword string) error + CreatePassword(userId uuid.UUID, newPassword string) error + UpdatePassword(passwordCredentialModel *models.PasswordCredential, newPassword string) error +} + +type password struct { + persister persistence.Persister + cfg config.Config +} + +func NewPasswordService(cfg config.Config, persister persistence.Persister) Password { + return &password{ + persister, + cfg, + } +} + +func (s password) VerifyPassword(userId uuid.UUID, password string) error { + user, err := s.persister.GetUserPersister().Get(userId) + if err != nil { + return fmt.Errorf("failed to get user: %w", err) + } + + if user == nil { + return ErrorPasswordInvalid + } + + pw, err := s.persister.GetPasswordCredentialPersister().GetByUserID(userId) + if err != nil { + return fmt.Errorf("error retrieving password credential: %w", err) + } + + if pw == nil { + return ErrorPasswordInvalid + } + + if err = bcrypt.CompareHashAndPassword([]byte(pw.Password), []byte(password)); err != nil { + return ErrorPasswordInvalid + } + + return nil +} + +func (s password) RecoverPassword(userId uuid.UUID, newPassword string) error { + passwordPersister := s.persister.GetPasswordCredentialPersister() + + passwordCredentialModel, err := passwordPersister.GetByUserID(userId) + if err != nil { + return fmt.Errorf("failed to get password credential by user id: %w", err) + } + + if passwordCredentialModel == nil { + err = s.CreatePassword(userId, newPassword) + } else { + err = s.UpdatePassword(passwordCredentialModel, newPassword) + } + + if err != nil { + return err + } + + return nil +} + +func (s password) CreatePassword(userId uuid.UUID, newPassword string) error { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), 12) + if err != nil { + return ErrorPasswordInvalid + } + + passwordCredentialModel := models.NewPasswordCredential(userId, string(hashedPassword)) + + err = s.persister.GetPasswordCredentialPersister().Create(*passwordCredentialModel) + if err != nil { + return fmt.Errorf("failed to set password: %w", err) + } + + return nil +} + +func (s password) UpdatePassword(passwordCredentialModel *models.PasswordCredential, newPassword string) error { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), 12) + if err != nil { + return ErrorPasswordInvalid + } + + passwordCredentialModel.Password = string(hashedPassword) + + err = s.persister.GetPasswordCredentialPersister().Update(*passwordCredentialModel) + if err != nil { + return fmt.Errorf("failed to update password: %w", err) + } + + return nil +} diff --git a/backend/flow_api/services/user.go b/backend/flow_api/services/user.go new file mode 100644 index 000000000..9b13b2cd1 --- /dev/null +++ b/backend/flow_api/services/user.go @@ -0,0 +1,32 @@ +package services + +import ( + "github.com/teamhanko/hanko/backend/config" + "github.com/teamhanko/hanko/backend/persistence/models" + "regexp" +) + +func UserCanDoThirdParty(cfg config.Config, identities models.Identities) bool { + for _, identity := range identities { + if provider := cfg.ThirdParty.Providers.Get(identity.ProviderName); provider != nil { + return provider.Enabled + } + } + + return false +} + +func UserCanDoSaml(cfg config.Config, identities models.Identities) bool { + for _, identity := range identities { + if provider := cfg.Saml.GetProviderByDomain(identity.ProviderName); provider != nil { + return cfg.Saml.Enabled && provider.Enabled + } + } + + return false +} + +func ValidateUsername(name string) bool { + re := regexp.MustCompile(`^\w+$`) + return re.MatchString(name) +} diff --git a/backend/flow_api/services/webauthn.go b/backend/flow_api/services/webauthn.go new file mode 100644 index 000000000..77a5d4fce --- /dev/null +++ b/backend/flow_api/services/webauthn.go @@ -0,0 +1,246 @@ +package services + +import ( + "encoding/base64" + "errors" + "fmt" + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" + "github.com/gobuffalo/pop/v6" + "github.com/gofrs/uuid" + "github.com/teamhanko/hanko/backend/config" + "github.com/teamhanko/hanko/backend/persistence" + "github.com/teamhanko/hanko/backend/persistence/models" + "strings" + "time" +) + +type GenerateRequestOptionsParams struct { + Tx *pop.Connection +} + +type VerifyAssertionResponseParams struct { + Tx *pop.Connection + SessionDataID uuid.UUID + AssertionResponse string +} + +type GenerateCreationOptionsParams struct { + Tx *pop.Connection + UserID uuid.UUID + Email string + Username string +} + +type VerifyAttestationResponseParams struct { + Tx *pop.Connection + SessionDataID uuid.UUID + PublicKey string + UserID uuid.UUID + Email string + Username string +} + +type WebauthnService interface { + GenerateRequestOptions(GenerateRequestOptionsParams) (*models.WebauthnSessionData, *protocol.CredentialAssertion, error) + VerifyAssertionResponse(VerifyAssertionResponseParams) (*models.User, error) + GenerateCreationOptions(GenerateCreationOptionsParams) (*models.WebauthnSessionData, *protocol.CredentialCreation, error) + VerifyAttestationResponse(VerifyAttestationResponseParams) (*webauthn.Credential, error) +} + +type webauthnUser struct { + id uuid.UUID + email string + username string +} + +func (user webauthnUser) WebAuthnID() []byte { + return user.id.Bytes() +} + +func (user webauthnUser) WebAuthnName() string { + if len(user.email) > 0 { + return user.email + } + + return user.username +} + +func (user webauthnUser) WebAuthnDisplayName() string { + if len(user.username) > 0 { + return user.username + } + + return user.email +} + +func (user webauthnUser) WebAuthnCredentials() []webauthn.Credential { + return nil +} + +func (user webauthnUser) WebAuthnIcon() string { + return "" +} + +var ( + ErrInvalidWebauthnCredential = errors.New("this passkey cannot be used anymore") +) + +type webauthnService struct { + cfg config.Config + persister persistence.Persister +} + +func NewWebauthnService(cfg config.Config, persister persistence.Persister) WebauthnService { + return &webauthnService{cfg: cfg, persister: persister} +} + +func (s *webauthnService) GenerateRequestOptions(p GenerateRequestOptionsParams) (*models.WebauthnSessionData, *protocol.CredentialAssertion, error) { + userVerificationRequirement := protocol.UserVerificationRequirement(s.cfg.Passkey.UserVerification) + options, sessionData, err := s.cfg.Webauthn.Handler.BeginDiscoverableLogin( + webauthn.WithUserVerification(userVerificationRequirement), + ) + if err != nil { + return nil, nil, fmt.Errorf("failed to create webauthn assertion options for discoverable login: %w", err) + } + + webAuthnSessionDataModel, err := models.NewWebauthnSessionDataFrom(sessionData, models.WebauthnOperationAuthentication) + if err != nil { + return nil, nil, fmt.Errorf("failed to generate a new webauthn session data model: %w", err) + } + + err = s.persister.GetWebauthnSessionDataPersisterWithConnection(p.Tx).Create(*webAuthnSessionDataModel) + if err != nil { + return nil, nil, fmt.Errorf("failed to store webauthn assertion session data: %w", err) + } + + return webAuthnSessionDataModel, options, nil +} + +func (s *webauthnService) VerifyAssertionResponse(p VerifyAssertionResponseParams) (*models.User, error) { + credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(strings.NewReader(p.AssertionResponse)) + if err != nil { + return nil, fmt.Errorf("%s: %w", err, ErrInvalidWebauthnCredential) + } + + sessionDataModel, err := s.persister.GetWebauthnSessionDataPersister().Get(p.SessionDataID) + if err != nil { + return nil, fmt.Errorf("failed to get session data from db: %w", err) + } + + userID, err := uuid.FromBytes(credentialAssertionData.Response.UserHandle) + if err != nil { + return nil, fmt.Errorf("failed to parse user id from user handle: %w", err) + } + + userModel, err := s.persister.GetUserPersister().Get(userID) + if err != nil { + return nil, fmt.Errorf("failed to fetch user from db: %w", err) + } + + if userModel == nil { + return nil, fmt.Errorf("%s: %w", err, ErrInvalidWebauthnCredential) + } + + discoverableUserHandler := func(rawID, userHandle []byte) (webauthn.User, error) { + return userModel, nil + } + + sessionData := sessionDataModel.ToSessionData() + + credential, err := s.cfg.Webauthn.Handler.ValidateDiscoverableLogin( + discoverableUserHandler, + *sessionData, + credentialAssertionData, + ) + if err != nil { + return nil, fmt.Errorf("%s: %w", err, ErrInvalidWebauthnCredential) + } + + encodedCredentialId := base64.RawURLEncoding.EncodeToString(credential.ID) + if credentialModel := userModel.GetWebauthnCredentialById(encodedCredentialId); credentialModel != nil { + now := time.Now().UTC() + flags := credentialAssertionData.Response.AuthenticatorData.Flags + + credentialModel.LastUsedAt = &now + credentialModel.BackupState = flags.HasBackupState() + credentialModel.BackupEligible = flags.HasBackupEligible() + + err = s.persister.GetWebauthnCredentialPersisterWithConnection(p.Tx).Update(*credentialModel) + if err != nil { + return nil, fmt.Errorf("failed to update webauthn credential: %w", err) + } + } + + err = s.persister.GetWebauthnSessionDataPersisterWithConnection(p.Tx).Delete(*sessionDataModel) + if err != nil { + return nil, fmt.Errorf("failed to delete assertion session data: %w", err) + } + + return userModel, nil +} + +func (s *webauthnService) GenerateCreationOptions(p GenerateCreationOptionsParams) (*models.WebauthnSessionData, *protocol.CredentialCreation, error) { + user := webauthnUser{id: p.UserID, email: p.Email, username: p.Username} + + requireResidentKey := true + authenticatorSelection := protocol.AuthenticatorSelection{ + RequireResidentKey: &requireResidentKey, + ResidentKey: protocol.ResidentKeyRequirementRequired, + UserVerification: protocol.VerificationRequired, + } + + attestationPreference := protocol.ConveyancePreference(s.cfg.Passkey.AttestationPreference) + options, sessionData, err := s.cfg.Webauthn.Handler.BeginRegistration( + user, + webauthn.WithConveyancePreference(attestationPreference), + webauthn.WithAuthenticatorSelection(authenticatorSelection), + ) + if err != nil { + return nil, nil, fmt.Errorf("%s: %w", err, ErrInvalidWebauthnCredential) + } + + sessionDataModel, err := models.NewWebauthnSessionDataFrom(sessionData, models.WebauthnOperationRegistration) + if err != nil { + return nil, nil, fmt.Errorf("failed to create new session data model instance: %w", err) + } + + err = s.persister.GetWebauthnSessionDataPersisterWithConnection(p.Tx).Create(*sessionDataModel) + if err != nil { + return nil, nil, fmt.Errorf("failed to store session data to the db: %W", err) + } + + return sessionDataModel, options, nil +} + +func (s *webauthnService) VerifyAttestationResponse(p VerifyAttestationResponseParams) (*webauthn.Credential, error) { + credentialCreationData, err := protocol.ParseCredentialCreationResponseBody(strings.NewReader(p.PublicKey)) + if err != nil { + return nil, fmt.Errorf("failed to parse credential creation response; %w", err) + } + + sessionDataModel, err := s.persister.GetWebauthnSessionDataPersister().Get(p.SessionDataID) + if err != nil { + return nil, fmt.Errorf("failed to get session data from db: %w", err) + } + + user := webauthnUser{id: p.UserID, email: p.Email, username: p.Username} + + sessionData := sessionDataModel.ToSessionData() + + credential, err := s.cfg.Webauthn.Handler.CreateCredential( + user, + *sessionData, + credentialCreationData, + ) + if err != nil { + return nil, fmt.Errorf("%s: %w", err, ErrInvalidWebauthnCredential) + } + + err = s.persister.GetWebauthnSessionDataPersisterWithConnection(p.Tx).Delete(*sessionDataModel) + if err != nil { + return nil, fmt.Errorf("failed to delete webauthn session data: %w", err) + } + + return credential, nil +} diff --git a/backend/flow_api/static/generic_client.html b/backend/flow_api/static/generic_client.html new file mode 100644 index 000000000..0d63fa53c --- /dev/null +++ b/backend/flow_api/static/generic_client.html @@ -0,0 +1,409 @@ + + + + + +

✨ Generic Flowpilot Client

+ + + + + +
+ + + diff --git a/backend/flowpilot/action_input.go b/backend/flowpilot/action_input.go new file mode 100644 index 000000000..d27269890 --- /dev/null +++ b/backend/flowpilot/action_input.go @@ -0,0 +1,29 @@ +package flowpilot + +import ( + "encoding/json" + "github.com/teamhanko/hanko/backend/flowpilot/jsonmanager" +) + +type actionInput interface { + jsonmanager.JSONManager +} + +type readOnlyActionInput interface { + jsonmanager.ReadOnlyJSONManager +} + +// newActionInput creates a new instance of actionInput with empty JSON data. +func newActionInput() actionInput { + return jsonmanager.NewJSONManager() +} + +// newActionInputFromInputData creates a new instance of actionInput with the given JSON data +// which was previously unmarshalled into a generic map. +func newActionInputFromInputData(data InputData) (actionInput, error) { + dataBytes, err := json.Marshal(data.InputDataMap) + if err != nil { + return nil, err + } + return jsonmanager.NewJSONManagerFromString(string(dataBytes)) +} diff --git a/backend/flowpilot/builder.go b/backend/flowpilot/builder.go new file mode 100644 index 000000000..0737bc2f1 --- /dev/null +++ b/backend/flowpilot/builder.go @@ -0,0 +1,284 @@ +package flowpilot + +import ( + "errors" + "fmt" + "time" +) + +type FlowBuilder interface { + TTL(ttl time.Duration) FlowBuilder + State(stateName StateName, actions ...Action) FlowBuilder + InitialState(stateNames ...StateName) FlowBuilder + ErrorState(stateName StateName) FlowBuilder + BeforeState(stateName StateName, hooks ...HookAction) FlowBuilder + AfterState(stateName StateName, hooks ...HookAction) FlowBuilder + AfterFlow(flowName FlowName, hooks ...HookAction) FlowBuilder + Debug(enabled bool) FlowBuilder + SubFlows(subFlows ...subFlow) FlowBuilder + Build() (Flow, error) + MustBuild() Flow + BeforeEachAction(hooks ...HookAction) FlowBuilder + AfterEachAction(hooks ...HookAction) FlowBuilder +} + +// defaultFlowBuilderBase is the base flow builder struct. +type defaultFlowBuilderBase struct { + name FlowName + flow stateActions + subFlows SubFlows + stateDetails stateDetails + beforeStateHooks stateHooks + afterStateHooks stateHooks + beforeEachActionHooks hookActions + afterEachActionHooks hookActions + afterFlowHooks flowHooks +} + +// defaultFlowBuilder is a builder struct for creating a new Flow. +type defaultFlowBuilder struct { + path string + ttl time.Duration + initialStateNames []StateName + errorStateName StateName + debug bool + + defaultFlowBuilderBase +} + +// newFlowBuilderBase creates a new defaultFlowBuilderBase instance. +func newFlowBuilderBase(name FlowName) defaultFlowBuilderBase { + return defaultFlowBuilderBase{ + name: name, + flow: make(stateActions), + subFlows: make(SubFlows, 0), + stateDetails: make(stateDetails), + beforeStateHooks: make(stateHooks), + afterStateHooks: make(stateHooks), + afterFlowHooks: make(flowHooks), + } +} + +// NewFlow creates a new defaultFlowBuilder that builds a new flow available under the specified path. +func NewFlow(name FlowName) FlowBuilder { + path := fmt.Sprintf("/%s", name) + fbBase := newFlowBuilderBase(name) + return &defaultFlowBuilder{path: path, defaultFlowBuilderBase: fbBase} +} + +// TTL sets the time-to-live (TTL) for the flow. +func (fb *defaultFlowBuilder) TTL(ttl time.Duration) FlowBuilder { + fb.ttl = ttl + return fb +} + +func (fb *defaultFlowBuilderBase) addState(stateName StateName, actions ...Action) { + fb.flow[stateName] = append(fb.flow[stateName], actions...) +} + +func (fb *defaultFlowBuilderBase) addBeforeStateHooks(stateName StateName, hooks ...HookAction) { + fb.beforeStateHooks[stateName] = append(fb.beforeStateHooks[stateName], hooks...) +} + +func (fb *defaultFlowBuilderBase) addAfterStateHooks(stateName StateName, hooks ...HookAction) { + fb.afterStateHooks[stateName] = append(fb.afterStateHooks[stateName], hooks...) +} + +func (fb *defaultFlowBuilderBase) addAfterFlowHooks(flowName FlowName, hooks ...HookAction) { + fb.afterFlowHooks[flowName] = append(fb.afterFlowHooks[flowName], hooks...) +} + +func (fb *defaultFlowBuilder) addBeforeEachActionHooks(hooks ...HookAction) { + fb.beforeEachActionHooks = append(fb.beforeEachActionHooks, hooks...) +} + +func (fb *defaultFlowBuilder) addAfterEachActionHooks(hooks ...HookAction) { + fb.afterEachActionHooks = append(fb.afterEachActionHooks, hooks...) +} + +func (fb *defaultFlowBuilderBase) addSubFlows(subFlows ...subFlow) { + fb.subFlows = append(fb.subFlows, subFlows...) +} + +func (fb *defaultFlowBuilderBase) addStateIfNotExists(stateName StateName) { + if _, exists := fb.flow[stateName]; !exists { + fb.addState(stateName) + } +} + +// scanFlowStates iterates through each state in the provided flow and associates relevant information, also it checks +// for uniqueness of state names. +func (fb *defaultFlowBuilder) scanFlowStates(flow flowBase) error { + // Iterate through states in the flow. + for stateName, actions := range flow.getFlow() { + // Check if state name is already in use. + if _, ok := fb.stateDetails[stateName]; ok { + continue + } + + actionDetails := make(defaultActionDetails, len(actions)) + + for i, action := range actions { + actionDetails[i] = &defaultActionDetail{ + action: action, + flowName: flow.getName(), + } + } + + // Create state details. + state := &defaultStateDetail{ + name: stateName, + actionDetails: actionDetails, + flow: flow.getFlow(), + subFlows: flow.getSubFlows(), + flowName: flow.getName(), + } + + // Store state details. + fb.stateDetails[stateName] = state + } + + for stateName, actions := range flow.getBeforeStateHooks() { + fb.beforeStateHooks[stateName] = append(fb.beforeStateHooks[stateName], actions...) + } + + for stateName, actions := range flow.getAfterStateHooks() { + fb.afterStateHooks[stateName] = append(fb.afterStateHooks[stateName], actions...) + } + + actions := flow.getAfterFlowHooks() + fb.afterFlowHooks[flow.getName()] = append(fb.afterFlowHooks[flow.getName()], actions...) + + // Recursively scan sub-flows. + for _, sf := range flow.getSubFlows() { + if err := fb.scanFlowStates(sf); err != nil { + return err + } + } + + return nil +} + +// validate performs validation checks on the flow configuration. +func (fb *defaultFlowBuilder) validate() error { + // Validate fixed states and their presence in the flow. + if len(fb.initialStateNames) == 0 { + return errors.New("fixed state 'initialState' is not set") + } + if len(fb.errorStateName) == 0 { + return errors.New("fixed state 'errorState' is not set") + } + if !fb.flow.stateExists(fb.initialStateNames[0]) && !fb.subFlows.stateExists(fb.initialStateNames[0]) { + return fmt.Errorf("initial state '%s' does not belong to the flow or a sub-flow", fb.initialStateNames[0]) + } + if !fb.flow.stateExists(fb.errorStateName) { + return fmt.Errorf("error state '%s' does not belong to the flow", fb.errorStateName) + } + + return nil +} + +// State adds a new state to the flow. +func (fb *defaultFlowBuilder) State(stateName StateName, actions ...Action) FlowBuilder { + fb.addState(stateName, actions...) + return fb +} + +func (fb *defaultFlowBuilder) BeforeState(stateName StateName, hooks ...HookAction) FlowBuilder { + fb.addBeforeStateHooks(stateName, hooks...) + return fb +} + +func (fb *defaultFlowBuilder) AfterState(stateName StateName, hooks ...HookAction) FlowBuilder { + fb.addAfterStateHooks(stateName, hooks...) + return fb +} + +func (fb *defaultFlowBuilder) AfterFlow(flowName FlowName, hooks ...HookAction) FlowBuilder { + fb.addAfterFlowHooks(flowName, hooks...) + return fb +} + +func (fb *defaultFlowBuilder) BeforeEachAction(hooks ...HookAction) FlowBuilder { + fb.addBeforeEachActionHooks(hooks...) + return fb +} + +func (fb *defaultFlowBuilder) AfterEachAction(hooks ...HookAction) FlowBuilder { + fb.addAfterEachActionHooks(hooks...) + return fb +} + +func (fb *defaultFlowBuilder) InitialState(nextStateNames ...StateName) FlowBuilder { + fb.initialStateNames = nextStateNames + return fb +} + +func (fb *defaultFlowBuilder) ErrorState(stateName StateName) FlowBuilder { + fb.addStateIfNotExists(stateName) + fb.errorStateName = stateName + return fb +} + +func (fb *defaultFlowBuilder) SubFlows(subFlows ...subFlow) FlowBuilder { + fb.addSubFlows(subFlows...) + return fb +} + +// Debug enables the debug mode, which causes the flow response to contain the actual error. +func (fb *defaultFlowBuilder) Debug(enabled bool) FlowBuilder { + fb.debug = enabled + return fb +} + +// Build constructs and returns the Flow object. +func (fb *defaultFlowBuilder) Build() (Flow, error) { + if err := fb.validate(); err != nil { + return nil, fmt.Errorf("flow validation failed: %w", err) + } + + dfb := &defaultFlowBase{ + name: fb.name, + flow: fb.flow, + subFlows: fb.subFlows, + beforeStateHooks: fb.beforeStateHooks, + afterStateHooks: fb.afterStateHooks, + beforeEachActionHooks: fb.beforeEachActionHooks, + afterEachActionHooks: fb.afterEachActionHooks, + afterFlowHooks: fb.afterFlowHooks, + } + + flow := &defaultFlow{ + initialStateNames: fb.initialStateNames, + errorStateName: fb.errorStateName, + stateDetails: fb.stateDetails, + ttl: fb.ttl, + debug: fb.debug, + defaultFlowBase: dfb, + contextValues: make(contextValues), + } + + // Check if states were already scanned, if so, don't scan again + if len(fb.stateDetails) == 0 { + if err := fb.scanFlowStates(flow); err != nil { + return nil, fmt.Errorf("failed to scan flow states: %w", err) + } + } + + flow.defaultFlowBase.beforeStateHooks.makeUnique() + flow.defaultFlowBase.afterStateHooks.makeUnique() + flow.defaultFlowBase.afterFlowHooks.makeUnique() + + return flow, nil +} + +// MustBuild constructs and returns the Flow object, panics on error. +func (fb *defaultFlowBuilder) MustBuild() Flow { + f, err := fb.Build() + + if err != nil { + panic(err) + } + + return f +} diff --git a/backend/flowpilot/builder_subflow.go b/backend/flowpilot/builder_subflow.go new file mode 100644 index 000000000..26c2c0f4c --- /dev/null +++ b/backend/flowpilot/builder_subflow.go @@ -0,0 +1,66 @@ +package flowpilot + +type SubFlowBuilder interface { + State(stateName StateName, actions ...Action) SubFlowBuilder + BeforeState(stateName StateName, hooks ...HookAction) SubFlowBuilder + AfterState(stateName StateName, hooks ...HookAction) SubFlowBuilder + SubFlows(subFlows ...subFlow) SubFlowBuilder + Build() (subFlow, error) + MustBuild() subFlow +} + +// defaultFlowBuilder is a builder struct for creating a new subFlow. +type defaultSubFlowBuilder struct { + defaultFlowBuilderBase +} + +// NewSubFlow creates a new SubFlowBuilder. +func NewSubFlow(name FlowName) SubFlowBuilder { + fbBase := newFlowBuilderBase(name) + return &defaultSubFlowBuilder{defaultFlowBuilderBase: fbBase} +} + +func (sfb *defaultSubFlowBuilder) SubFlows(subFlows ...subFlow) SubFlowBuilder { + sfb.addSubFlows(subFlows...) + return sfb +} + +// State adds a new state to the flow. +func (sfb *defaultSubFlowBuilder) State(stateName StateName, actions ...Action) SubFlowBuilder { + sfb.addState(stateName, actions...) + return sfb +} + +func (sfb *defaultSubFlowBuilder) BeforeState(stateName StateName, hooks ...HookAction) SubFlowBuilder { + sfb.addBeforeStateHooks(stateName, hooks...) + return sfb +} + +func (sfb *defaultSubFlowBuilder) AfterState(stateName StateName, hooks ...HookAction) SubFlowBuilder { + sfb.addAfterStateHooks(stateName, hooks...) + return sfb +} + +// Build constructs and returns the subFlow object. +func (sfb *defaultSubFlowBuilder) Build() (subFlow, error) { + f := defaultFlowBase{ + name: sfb.name, + flow: sfb.flow, + subFlows: sfb.subFlows, + beforeStateHooks: sfb.beforeStateHooks, + afterStateHooks: sfb.afterStateHooks, + } + + return &f, nil +} + +// MustBuild constructs and returns the subFlow object, panics on error. +func (sfb *defaultSubFlowBuilder) MustBuild() subFlow { + sf, err := sfb.Build() + + if err != nil { + panic(err) + } + + return sf +} diff --git a/backend/flowpilot/context.go b/backend/flowpilot/context.go new file mode 100644 index 000000000..05dcff2fc --- /dev/null +++ b/backend/flowpilot/context.go @@ -0,0 +1,271 @@ +package flowpilot + +import ( + "database/sql" + "errors" + "fmt" + "github.com/gofrs/uuid" + "time" +) + +type context interface { + // Get returns the context value with the given name. + Get(string) interface{} + GetFlowName() FlowName + // IsFlow returns true if the name matches the current flow name. + IsFlow(name FlowName) bool +} + +// flowContext represents the basic context for a flow. +type flowContext interface { + // Set sets a context value for the given key. + Set(string, interface{}) + // GetFlowID returns the unique ID of the current defaultFlow. + GetFlowID() uuid.UUID + // Payload returns the JSONManager for accessing payload data. + Payload() payload + // Stash returns the JSONManager for accessing stash data. + Stash() stash + // GetInitialState returns the initial state of the flow. + GetInitialState() StateName + // GetCurrentState returns the current state of the flow. + GetCurrentState() StateName + // GetPreviousState returns the previous state of the flow. + GetPreviousState() StateName + // IsPreviousState returns true if the previous state equals the given name. + IsPreviousState(name StateName) bool + // GetErrorState returns the designated error state of the flow. + GetErrorState() StateName +} + +// actionInitializationContext represents the basic context for a flow action's initialization. +type actionInitializationContext interface { + // AddInputs adds input parameters to the inputSchema. + AddInputs(inputs ...Input) + StateIsRevertible() bool + + flowContext + actionSuspender +} + +// actionExecutionContext represents the context for an action execution. +type actionExecutionContext interface { + // Input returns the executionInputSchema for the action. + Input() executionInputSchema + // ValidateInputData validates the input data against the inputSchema. + ValidateInputData() bool + // CopyInputValuesToStash copies specified inputs to the stash. + CopyInputValuesToStash(inputNames ...string) error + SetFlowError(FlowError) + PreventRevert() + + actionSuspender + flowContext +} + +// actionExecutionContinuationContext represents the context within an action continuation. +type actionExecutionContinuationContext interface { + Continue(stateNames ...StateName) error + // Error continues the flow execution to the specified next state with an error. + Error(flowErr FlowError) error + // Revert reverts the flow back to the previous state. + Revert() error + + actionExecutionContext +} + +type actionSuspender interface { + // SuspendAction suspends the current action's execution. + SuspendAction() +} + +type Context interface { + context +} + +// InitializationContext is a shorthand for actionInitializationContext within the flow initialization method. +type InitializationContext interface { + context + actionInitializationContext +} + +// ExecutionContext is a shorthand for actionExecutionContinuationContext within flow execution method. +type ExecutionContext interface { + context + actionExecutionContinuationContext +} + +type HookExecutionContext interface { + context + actionExecutionContext + + GetFlowError() FlowError + AddLink(...Link) + ScheduleStates(...StateName) +} + +type BeforeEachActionExecutionContext interface { + actionExecutionContinuationContext +} + +// createAndInitializeFlow initializes the flow and returns a flow Response. +func createAndInitializeFlow(db FlowDB, flow defaultFlow) (FlowResult, error) { + // Wrap the provided FlowDB with additional functionality. + dbw := wrapDB(db) + // Calculate the expiration time for the flow. + expiresAt := time.Now().Add(flow.ttl).UTC() + + // Initialize the stash and add initial next states. + s, err := newStash(flow.initialStateNames...) + if err != nil { + return nil, fmt.Errorf("failed to initialize a new stash: %w", err) + } + + s.useCompression(flow.useCompression) + + p := newPayload() + + csrfToken, err := generateRandomString(32) + if err != nil { + return nil, fmt.Errorf("failed to generate csrf token: %w", err) + } + + // Create a new flow model with the provided parameters. + flowCreation := flowCreationParam{ + data: s.String(), + csrfToken: csrfToken, + expiresAt: expiresAt, + } + flowModel, err := dbw.createFlowWithParam(flowCreation) + if err != nil { + return nil, fmt.Errorf("failed to create flow: %w", err) + } + + // Create a defaultFlowContext instance. + fc := &defaultFlowContext{ + flow: flow, + dbw: dbw, + flowModel: flowModel, + stash: s, + payload: p, + } + + er := executionResult{nextStateName: s.getStateName()} + + aec := defaultActionExecutionContext{ + actionName: "", + inputSchema: nil, + executionResult: &er, + defaultFlowContext: fc, + } + + err = aec.executeBeforeStateHooks(aec.stash.getStateName()) + if err != nil { + return nil, fmt.Errorf("failed to execute before hook actions: %w", err) + } + + return er.generateResponse(fc), nil +} + +// executeFlowAction processes the flow and returns a Response. +func executeFlowAction(db FlowDB, flow defaultFlow) (FlowResult, error) { + actionName := flow.queryParam.getActionName() + + // Retrieve the flow model from the database using the flow ID. + flowModel, err := db.GetFlow(flow.queryParam.getFlowID()) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return newFlowResultFromError(flow.errorStateName, ErrorOperationNotPermitted.Wrap(err), flow.debug), nil + } + return nil, fmt.Errorf("failed to get flow: %w", err) + } + + // Check if the flow has expired. + if time.Now().After(flowModel.ExpiresAt) { + return newFlowResultFromError(flow.errorStateName, ErrorFlowExpired, flow.debug), nil + } + + // Parse stash data from the flow model. + s, err := newStashFromString(flowModel.Data) + if err != nil { + return nil, fmt.Errorf("failed to parse stash from flow: %w", err) + } + + s.useCompression(flow.useCompression) + + // Initialize JSONManagers for payload and flash data. + p := newPayload() + + // Parse raw input data into JSONManager. + inputJSON, err := newActionInputFromInputData(flow.inputData) + if err != nil { + return nil, fmt.Errorf("failed to parse input data: %w", err) + } + csrfTokenToValidate := flow.inputData.CSRFToken + + if len(flowModel.CSRFToken) <= 0 || flowModel.CSRFToken != csrfTokenToValidate { + err = errors.New("csrf token mismatch") + return newFlowResultFromError(flow.errorStateName, ErrorOperationNotPermitted.Wrap(err), flow.debug), nil + } + + // Create a defaultFlowContext instance. + fc := &defaultFlowContext{ + flow: flow, + dbw: wrapDB(db), + flowModel: flowModel, + stash: s, + payload: p, + } + + state, err := flow.getState(s.getStateName()) + if err != nil { + return nil, err + } + + // Get the action associated with the actionParam name. + ad, err := state.getActionDetail(actionName) + if err != nil { + return newFlowResultFromError(flow.errorStateName, ErrorOperationNotPermitted.Wrap(err), flow.debug), nil + } + + // Initialize the inputSchema and action context for action execution. + inputSchema := newSchemaWithInputData(inputJSON) + aic := &defaultActionInitializationContext{ + inputSchema: inputSchema.forInitializationContext(), + defaultFlowContext: fc, + } + + // Create a actionExecutionContext instance for action execution. + aec := &defaultActionExecutionContext{ + actionName: actionName, + inputSchema: inputSchema, + defaultFlowContext: fc, + } + + err = aec.executeBeforeEachActionHooks() + if err != nil { + return newFlowResultFromError(flow.errorStateName, ErrorOperationNotPermitted, flow.debug), nil + } + + ad.getAction().Initialize(aic) + + // Check if the action is suspended. + if aic.isSuspended { + return newFlowResultFromError(flow.errorStateName, ErrorOperationNotPermitted, flow.debug), nil + } + + // Execute the action and handle any errors. + err = ad.getAction().Execute(aec) + if err != nil { + return nil, fmt.Errorf("the action failed to handle the request: %w", err) + } + + // Ensure that the action has set a result object. + if aec.executionResult == nil { + er := executionResult{nextStateName: s.getStateName()} + aec.executionResult = &er + } + + // Generate a response based on the execution result. + return aec.executionResult.generateResponse(fc), nil +} diff --git a/backend/flowpilot/context_action_exec.go b/backend/flowpilot/context_action_exec.go new file mode 100644 index 000000000..014a4acea --- /dev/null +++ b/backend/flowpilot/context_action_exec.go @@ -0,0 +1,258 @@ +package flowpilot + +import ( + "errors" + "fmt" + "strconv" + "strings" +) + +// defaultActionExecutionContext is the default implementation of the actionExecutionContext interface. +type defaultActionExecutionContext struct { + actionName ActionName // Name of the action being executed. + inputSchema executionInputSchema // JSONManager for accessing input data. + flowError FlowError + executionResult *executionResult // Result of the action execution. + links []Link // TODO: + isSuspended bool + preventRevert bool + + *defaultFlowContext // Embedding the defaultFlowContext for common context fields. +} + +// closeExecutionContext updates the flow's state and stores data to the database. +func (aec *defaultActionExecutionContext) closeExecutionContext() error { + var err error + + if aec.executionResult != nil { + return errors.New("execution context is closed already") + } + + nextStateName := aec.stash.getStateName() + + actionResult := &actionExecutionResult{ + actionName: aec.actionName, + inputSchema: aec.inputSchema, + isSuspended: aec.isSuspended, + } + + result := &executionResult{ + flowError: aec.flowError, + actionExecutionResult: actionResult, + links: aec.links, + nextStateName: nextStateName, + } + + aec.executionResult = result + + csrfToken, err := generateRandomString(32) + if err != nil { + return fmt.Errorf("failed to generate csrf token: %w", err) + } + + newVersion := aec.flowModel.Version + 1 + + // Prepare parameters for updating the flow in the database. + flowUpdate := flowUpdateParam{ + flowID: aec.flowModel.ID, + data: aec.stash.String(), + version: newVersion, + csrfToken: csrfToken, + expiresAt: aec.flowModel.ExpiresAt, + createdAt: aec.flowModel.CreatedAt, + } + + // Update the flow model in the database. + if _, err = aec.dbw.updateFlowWithParam(flowUpdate); err != nil { + return fmt.Errorf("failed to store updated flow: %w", err) + } + + aec.flowModel.Version = newVersion + aec.flowModel.CSRFToken = csrfToken + + return nil +} + +func (aec *defaultActionExecutionContext) executeBeforeStateHooks(nextStateName StateName) error { + if actions := aec.flow.beforeStateHooks[nextStateName]; actions != nil { + for _, hook := range actions.reverse() { + if err := hook.Execute(aec); err != nil { + return fmt.Errorf("failed to execute before state hook (state: %s): %w", nextStateName, err) + } + } + } + return nil +} + +func (aec *defaultActionExecutionContext) executeBeforeEachActionHooks() error { + for _, hook := range aec.flow.beforeEachActionHooks { + err := hook.Execute(aec) + if err != nil { + return fmt.Errorf("failed to execute before each action (action: %s): %w", aec.actionName, err) + } + } + return nil +} + +func (aec *defaultActionExecutionContext) executeAfterHooks() error { + currentStateName := aec.stash.getStateName() + currentState, _ := aec.flow.getState(currentStateName) + currentFlowName := currentState.getFlowName() + + var nextFlowName FlowName + if nextStateName := aec.stash.getNextStateName(); len(nextStateName) > 0 { + nextState, _ := aec.flow.getState(nextStateName) + nextFlowName = nextState.getFlowName() + } + + if len(nextFlowName) == 0 || currentFlowName != nextFlowName { + for _, hook := range aec.flow.afterFlowHooks[currentFlowName].reverse() { + if err := hook.Execute(aec); err != nil { + return fmt.Errorf("failed to execute hook after flow hook (flow: %s): %w", currentFlowName, err) + } + } + } + + if actions := aec.flow.afterStateHooks[currentStateName]; actions != nil { + for _, hook := range actions.reverse() { + if err := hook.Execute(aec); err != nil { + return fmt.Errorf("failed to execute after state hook (state: %s): %w", currentStateName, err) + } + } + } + + for _, hook := range aec.flow.afterEachActionHooks { + err := hook.Execute(aec) + if err != nil { + return fmt.Errorf("failed to execute after each action hook (action: %s): %w", aec.actionName, err) + } + } + + return nil +} + +// Input returns the executionInputSchema for accessing input data. +func (aec *defaultActionExecutionContext) Input() executionInputSchema { + return aec.inputSchema +} + +// payload returns the JSONManager for accessing payload data. +func (aec *defaultActionExecutionContext) Payload() payload { + return aec.payload +} + +// CopyInputValuesToStash copies specified inputs to the stash. +func (aec *defaultActionExecutionContext) CopyInputValuesToStash(inputNames ...string) error { + for _, inputName := range inputNames { + // Copy input values to the stash. + if result := aec.inputSchema.Get(inputName); result.Exists() && len(result.String()) > 0 { + if err := aec.stash.Set(inputName, result.Value()); err != nil { + return err + } + } + } + + return nil +} + +func (aec *defaultActionExecutionContext) SetFlowError(err FlowError) { + aec.flowError = err +} + +func (aec *defaultActionExecutionContext) GetFlowError() FlowError { + return aec.flowError +} + +// ValidateInputData validates the input data against the inputSchema. +func (aec *defaultActionExecutionContext) ValidateInputData() bool { + return aec.inputSchema.validateInputData() +} + +// Error continues the flow execution to the current state, if it's a 4xx error or to the error state otherwise. +// The flow response will contain the given error. +func (aec *defaultActionExecutionContext) Error(flowErr FlowError) error { + aec.flowError = flowErr + statusStr := strconv.Itoa(aec.flowError.Status()) + + var nextStateName StateName + if strings.HasPrefix(statusStr, "4") { + nextStateName = aec.stash.getStateName() + } else { + nextStateName = aec.flow.errorStateName + } + + if err := aec.executeBeforeStateHooks(nextStateName); err != nil { + return err + } + + if err := aec.stash.pushErrorState(nextStateName); err != nil { + return err + } + + return aec.closeExecutionContext() +} + +// Revert reverts the flow back to the previous state. +func (aec *defaultActionExecutionContext) Revert() error { + if err := aec.stash.revertState(); err != nil { + return fmt.Errorf("failed to revert to the previous state: %w", err) + } + + if err := aec.executeBeforeEachActionHooks(); err != nil { + return err + } + + if err := aec.executeBeforeStateHooks(aec.stash.getStateName()); err != nil { + return err + } + + return aec.closeExecutionContext() +} + +func (aec *defaultActionExecutionContext) Continue(stateNames ...StateName) error { + for _, stateName := range stateNames { + if _, ok := aec.flow.stateDetails[stateName]; !ok { + return fmt.Errorf("cannot continue, state does not exist: %s", stateName) + } + } + + if err := aec.executeBeforeEachActionHooks(); err != nil { + return err + } + + aec.stash.addScheduledStateNames(stateNames...) + + if err := aec.executeAfterHooks(); err != nil { + return err + } + + if err := aec.executeBeforeStateHooks(aec.stash.getNextStateName()); err != nil { + return err + } + + if err := aec.stash.pushState(!aec.preventRevert); err != nil { + return fmt.Errorf("cannot continue, failed to update stash data: %s", err) + } + + return aec.closeExecutionContext() +} + +func (aec *defaultActionExecutionContext) AddLink(links ...Link) { + aec.links = append(aec.links, links...) +} + +func (aec *defaultActionExecutionContext) ScheduleStates(stateNames ...StateName) { + aec.stash.addScheduledStateNames(stateNames...) +} + +func (aec *defaultActionExecutionContext) Set(key string, value interface{}) { + aec.flow.Set(key, value) +} + +func (aec *defaultActionExecutionContext) SuspendAction() { + aec.isSuspended = true +} + +func (aec *defaultActionExecutionContext) PreventRevert() { + aec.preventRevert = true +} diff --git a/backend/flowpilot/context_action_init.go b/backend/flowpilot/context_action_init.go new file mode 100644 index 000000000..0082dde2d --- /dev/null +++ b/backend/flowpilot/context_action_init.go @@ -0,0 +1,40 @@ +package flowpilot + +// defaultActionInitializationContext is the default implementation of the actionInitializationContext interface. +type defaultActionInitializationContext struct { + inputSchema initializationInputSchema // initializationInputSchema for action initialization. + isSuspended bool // Flag indicating if the method is suspended. + *defaultFlowContext // Embedding the defaultFlowContext for common context fields. +} + +func (aic *defaultActionInitializationContext) Payload() payload { + return aic.payload +} + +func (aic *defaultActionInitializationContext) Set(s string, i interface{}) { + aic.flow.Set(s, i) +} + +// AddInputs adds input data to the initializationInputSchema. +func (aic *defaultActionInitializationContext) AddInputs(inputs ...Input) { + aic.inputSchema.AddInputs(inputs...) +} + +// SuspendAction sets the isSuspended flag to indicate the action is suspended. +func (aic *defaultActionInitializationContext) SuspendAction() { + aic.isSuspended = true +} + +// Stash returns the ReadOnlyJSONManager for accessing stash data. +func (aic *defaultActionInitializationContext) Stash() stash { + return aic.stash +} + +// Get returns the context value with the given name. +func (aic *defaultActionInitializationContext) Get(key string) interface{} { + return aic.flow.contextValues[key] +} + +func (aic *defaultActionInitializationContext) StateIsRevertible() bool { + return aic.stash.isRevertible() +} diff --git a/backend/flowpilot/context_flow.go b/backend/flowpilot/context_flow.go new file mode 100644 index 000000000..b3e3cc863 --- /dev/null +++ b/backend/flowpilot/context_flow.go @@ -0,0 +1,75 @@ +package flowpilot + +import ( + "github.com/gofrs/uuid" +) + +// defaultFlowContext is the default implementation of the flowContext interface. +type defaultFlowContext struct { + payload payload // JSONManager for payload data. + stash stash // JSONManager for stash data. + flow defaultFlow // The associated defaultFlow instance. + dbw flowDBWrapper // Wrapped FlowDB instance with additional functionality. + flowModel *FlowModel // The current FlowModel. +} + +// GetFlowID returns the unique ID of the current flow. +func (fc *defaultFlowContext) GetFlowID() uuid.UUID { + return fc.flowModel.ID +} + +// GetInitialState returns the initial state of the flow. +func (fc *defaultFlowContext) GetInitialState() StateName { + return fc.flow.initialStateNames[0] +} + +// GetCurrentState returns the current state of the flow. +func (fc *defaultFlowContext) GetCurrentState() StateName { + return fc.stash.getStateName() +} + +// CurrentStateEquals returns true, when one of the given stateNames matches the current state name. +func (fc *defaultFlowContext) CurrentStateEquals(stateNames ...StateName) bool { + for _, s := range stateNames { + if s == fc.stash.getStateName() { + return true + } + } + + return false +} + +// GetPreviousState returns the previous state of the flow. +func (fc *defaultFlowContext) GetPreviousState() StateName { + return fc.stash.getPreviousStateName() +} + +// IsPreviousState returns true if the previous state equals the given name +func (fc *defaultFlowContext) IsPreviousState(name StateName) bool { + return fc.stash.getPreviousStateName() == name +} + +// GetErrorState returns the designated error state of the flow. +func (fc *defaultFlowContext) GetErrorState() StateName { + return fc.flow.errorStateName +} + +// Stash returns the JSONManager for accessing stash data. +func (fc *defaultFlowContext) Stash() stash { + return fc.stash +} + +// Get returns the context value with the given name. +func (fc *defaultFlowContext) Get(name string) interface{} { + return fc.flow.contextValues[name] +} + +// GetFlowName returns the name of the current flow. +func (fc *defaultFlowContext) GetFlowName() FlowName { + return fc.flow.name +} + +// IsFlow returns true if the name matches the current flow name. +func (fc *defaultFlowContext) IsFlow(name FlowName) bool { + return fc.flow.name == name +} diff --git a/backend/flowpilot/db.go b/backend/flowpilot/db.go new file mode 100644 index 000000000..5a2987ad5 --- /dev/null +++ b/backend/flowpilot/db.go @@ -0,0 +1,109 @@ +package flowpilot + +import ( + "fmt" + "github.com/gofrs/uuid" + "time" +) + +// FlowModel represents the database model for a flow. +type FlowModel struct { + ID uuid.UUID // Unique ID of the flow. + Data string // Stash data associated with the flow. + CSRFToken string // Current CSRF token + Version int // Version of the flow. + ExpiresAt time.Time // Expiry time of the flow. + CreatedAt time.Time // Creation time of the flow. + UpdatedAt time.Time // Update time of the flow. +} + +// FlowDB is the interface for interacting with the flow database. +type FlowDB interface { + GetFlow(flowID uuid.UUID) (*FlowModel, error) + CreateFlow(flowModel FlowModel) error + UpdateFlow(flowModel FlowModel) error +} + +// flowDBWrapper is an extended FlowDB interface that includes additional methods. +type flowDBWrapper interface { + FlowDB + createFlowWithParam(p flowCreationParam) (*FlowModel, error) + updateFlowWithParam(p flowUpdateParam) (*FlowModel, error) +} + +// defaultFlowDBWrapper wraps a FlowDB instance to provide additional functionality. +type defaultFlowDBWrapper struct { + FlowDB +} + +// wrapDB wraps a FlowDB instance to provide flowDBWrapper functionality. +func wrapDB(db FlowDB) flowDBWrapper { + return &defaultFlowDBWrapper{FlowDB: db} +} + +// flowCreationParam holds parameters for creating a new flow. +type flowCreationParam struct { + data string // + csrfToken string // Current CSRF token + expiresAt time.Time // Expiry time of the flow. +} + +// CreateFlowWithParam creates a new flow with the given parameters. +func (w *defaultFlowDBWrapper) createFlowWithParam(p flowCreationParam) (*FlowModel, error) { + // Generate a new UUID for the flow. + flowID, err := uuid.NewV4() + if err != nil { + return nil, fmt.Errorf("failed to generate a new flow id: %w", err) + } + + // Prepare the FlowModel for creation. + fm := FlowModel{ + ID: flowID, + Data: p.data, + Version: 0, + CSRFToken: p.csrfToken, + ExpiresAt: p.expiresAt, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } + + // Create the flow in the database. + err = w.CreateFlow(fm) + if err != nil { + return nil, fmt.Errorf("failed to store a new flow to the dbw: %w", err) + } + + return &fm, nil +} + +// flowUpdateParam holds parameters for updating a flow. +type flowUpdateParam struct { + flowID uuid.UUID // ID of the flow to update. + data string // Updated stash data for the flow. + version int // Updated version of the flow. + csrfToken string // Current CSRF tokens + expiresAt time.Time // Updated expiry time of the flow. + createdAt time.Time // Original creation time of the flow. +} + +// UpdateFlowWithParam updates the specified flow with the given parameters. +func (w *defaultFlowDBWrapper) updateFlowWithParam(p flowUpdateParam) (*FlowModel, error) { + // Prepare the updated FlowModel. + fm := FlowModel{ + ID: p.flowID, + Data: p.data, + Version: p.version, + CSRFToken: p.csrfToken, + ExpiresAt: p.expiresAt, + UpdatedAt: time.Now().UTC(), + CreatedAt: p.createdAt, + } + + // Update the flow in the database. + err := w.UpdateFlow(fm) + if err != nil { + return nil, fmt.Errorf("failed to store updated flow to the dbw: %w", err) + } + + return &fm, nil +} diff --git a/backend/flowpilot/errors.go b/backend/flowpilot/errors.go new file mode 100644 index 000000000..849eda13f --- /dev/null +++ b/backend/flowpilot/errors.go @@ -0,0 +1,176 @@ +package flowpilot + +import ( + "fmt" + "net/http" + "strings" +) + +// flowpilotError defines the interface for custom error types in the Flowpilot package. +type flowpilotError interface { + error + + Unwrap() error + Code() string + Message() string + + toResponseError(debug bool) *ResponseError +} + +// FlowError is an interface representing flow-related errors. +type FlowError interface { + flowpilotError + + Wrap(error) FlowError + Status() int +} + +// InputError is an interface representing input-related errors. +type InputError interface { + flowpilotError + + Wrap(error) InputError +} + +// defaultError is a base struct for custom error types. +type defaultError struct { + cause error // The error cause. + code string // Unique error code. + message string // Contains a description of the error. + errorText string // The string representation of the error. +} + +// Code returns the error code. +func (e *defaultError) Code() string { + return e.code +} + +// Message returns the error message. +func (e *defaultError) Message() string { + return e.message +} + +// Unwrap returns the wrapped error. +func (e *defaultError) Unwrap() error { + return e.cause +} + +// Error returns the formatted error message. +func (e *defaultError) Error() string { + return e.errorText +} + +// toResponseError converts the error to a ResponseError for public exposure. +func (e *defaultError) toResponseError(debug bool) *ResponseError { + publicError := &ResponseError{ + Code: e.Code(), + Message: e.Message(), + } + + if e.cause != nil { + cause := e.cause.Error() + publicError.Internal = &cause + if debug { + publicError.Cause = &cause + } + } + + return publicError +} + +// defaultFlowError is a struct for flow-related errors. +type defaultFlowError struct { + defaultError + + status int // The suggested HTTP status code. +} + +// createErrorText creates the text used as the string representation of the error. +func createErrorText(code, message string, cause error) string { + text := fmt.Sprintf("%s - %s", code, message) + + if cause != nil { + text = fmt.Sprintf("%s: %s", text, cause.Error()) + } + + return text +} + +// NewFlowError creates a new FlowError instance. +func NewFlowError(code, message string, status int) FlowError { + return newFlowErrorWithCause(code, message, status, nil) +} + +// newFlowErrorWithCause creates a new FlowError instance with an error cause. +func newFlowErrorWithCause(code, message string, status int, cause error) FlowError { + errorText := createErrorText(code, message, cause) + + e := defaultError{ + cause: cause, + code: code, + message: message, + errorText: errorText, + } + + return &defaultFlowError{defaultError: e, status: status} +} + +// Status returns the suggested HTTP status code. +func (e *defaultFlowError) Status() int { + return e.status +} + +// Wrap wraps the error with another error. +func (e *defaultFlowError) Wrap(err error) FlowError { + return newFlowErrorWithCause(e.code, e.message, e.status, err) +} + +// defaultInputError is a struct for input-related errors. +type defaultInputError struct { + defaultError +} + +// NewInputError creates a new InputError instance. +func NewInputError(code, message string) InputError { + return newInputErrorWithCause(code, message, nil) +} + +// newInputErrorWithCause creates a new InputError instance with an error cause. +func newInputErrorWithCause(code, message string, cause error) InputError { + errorText := createErrorText(code, message, cause) + + e := defaultError{ + cause: cause, + code: code, + message: message, + errorText: errorText, + } + + return &defaultInputError{defaultError: e} +} + +// Wrap wraps the error with another error. +func (e *defaultInputError) Wrap(err error) InputError { + return newInputErrorWithCause(e.code, e.message, err) +} + +// Predefined flow error types +var ( + ErrorTechnical = NewFlowError("technical_error", "Something went wrong.", http.StatusInternalServerError) + ErrorFlowExpired = NewFlowError("flow_expired_error", "The flow has expired.", http.StatusGone) + ErrorFlowDiscontinuity = NewFlowError("flow_discontinuity_error", "The flow can't be continued.", http.StatusInternalServerError) + ErrorOperationNotPermitted = NewFlowError("operation_not_permitted_error", "The operation is not permitted.", http.StatusForbidden) + ErrorFormDataInvalid = NewFlowError("form_data_invalid_error", "Form data invalid.", http.StatusBadRequest) +) + +// Predefined input error types +var ( + ErrorValueMissing = NewInputError("value_missing_error", "The value is missing.") + ErrorValueInvalid = NewInputError("value_invalid_error", "The value is invalid.") + ErrorValueTooLong = NewInputError("value_too_long_error", "The value is too long.") + ErrorValueTooShort = NewInputError("value_too_short_error", "The value is too short.") +) + +func createMustBeOneOfError(values []string) InputError { + return NewInputError("value_invalid_error", fmt.Sprintf("The value is invalid. Must be one of: %s", strings.Join(values, ","))) +} diff --git a/backend/flowpilot/flow.go b/backend/flowpilot/flow.go new file mode 100644 index 000000000..e934a40d6 --- /dev/null +++ b/backend/flowpilot/flow.go @@ -0,0 +1,295 @@ +package flowpilot + +import ( + "fmt" + "reflect" + "time" +) + +// FlowName represents the name of the flow. +type FlowName string + +// InputData holds input data in JSON format. +type InputData struct { + InputDataMap map[string]interface{} `json:"input_data"` + CSRFToken string `json:"csrf_token"` +} + +// WithQueryParamKey sets the ActionName for flowExecutionOptions. +func WithQueryParamKey(key string) func(*defaultFlow) { + return func(f *defaultFlow) { + f.queryParamKey = key + } +} + +// WithQueryParamValue sets the ActionName for flowExecutionOptions. +func WithQueryParamValue(value string) func(*defaultFlow) { + return func(f *defaultFlow) { + f.queryParamValue = value + } +} + +// WithInputData sets the InputData for flowExecutionOptions. +func WithInputData(inputData InputData) func(*defaultFlow) { + return func(f *defaultFlow) { + f.inputData = inputData + } +} + +// UseCompression causes the flow data to be compressed before stored to the db. +func UseCompression(b bool) func(*defaultFlow) { + return func(f *defaultFlow) { + f.useCompression = b + } +} + +// StateName represents the name of a state in a flow. +type StateName string + +// ActionName represents the name of an action. +type ActionName string + +// Action defines the interface for flow actions. +type Action interface { + GetName() ActionName // Get the action name. + GetDescription() string // Get the action description. + Initialize(InitializationContext) // Initialize the action. + Execute(ExecutionContext) error // Execute the action. +} + +// Actions represents a list of Action +type Actions []Action + +// HookAction defines the interface for a hook action. +type HookAction interface { + Execute(HookExecutionContext) error +} + +// hookActions represents a list of HookAction interfaces. +type hookActions []HookAction + +func (actions hookActions) makeUnique() hookActions { + seen := make(map[HookAction]bool) + var uniqueSlice []HookAction + + for _, action := range actions { + if _, found := seen[action]; !found { + seen[action] = true + uniqueSlice = append(uniqueSlice, action) + } + } + + return uniqueSlice +} + +func (actions hookActions) reverse() hookActions { + a := make(hookActions, len(actions)) + copy(a, actions) + n := reflect.ValueOf(a).Len() + swap := reflect.Swapper(a) + for i, j := 0, n-1; i < j; i, j = i+1, j-1 { + swap(i, j) + } + return a +} + +// stateActions maps state names to associated actions. +type stateActions map[StateName]Actions + +// stateActions maps state names to associated hook actions. +type stateHooks map[StateName]hookActions + +func (sh stateHooks) makeUnique() { + for stateName, actions := range sh { + sh[stateName] = actions.makeUnique() + } +} + +// flowHooks maps state names to associated hook actions. +type flowHooks map[FlowName]hookActions + +func (fh flowHooks) makeUnique() { + for stateName, actions := range fh { + fh[stateName] = actions.makeUnique() + } +} + +// stateExists checks if a state exists in the flow. +func (st stateActions) stateExists(stateName StateName) bool { + _, ok := st[stateName] + return ok +} + +// SubFlows represents a list of SubFlow interfaces. +type SubFlows []subFlow + +// stateExists checks if the given state exists in a sub-flow of the current flow. +func (sfs SubFlows) stateExists(state StateName) bool { + for _, sf := range sfs { + if sf.getFlow().stateExists(state) { + return true + } + } + + return false +} + +func (sfs SubFlows) getSubFlowFromStateName(state StateName) subFlow { + for _, sf := range sfs { + if sf.getFlow().stateExists(state) { + return sf + } + } + return nil +} + +// flowBase represents the base of the flow interfaces. +type flowBase interface { + getName() FlowName + getSubFlows() SubFlows + getFlow() stateActions + getBeforeStateHooks() stateHooks + getAfterStateHooks() stateHooks + getAfterFlowHooks() hookActions +} + +// Flow represents a flow. +type Flow interface { + // Execute executes the flow using the provided FlowDB and options. + // It returns the result of the flow execution and an error if any. + Execute(db FlowDB, opts ...func(*defaultFlow)) (FlowResult, error) + // ResultFromError converts an error into a FlowResult. + ResultFromError(err error) FlowResult + // Set sets a value with the given key in the flow context. + Set(string, interface{}) + // setDefaults sets the default values for the flow. + setDefaults() + // getState retrieves the details of a specific state in the flow. + getState(stateName StateName) (stateDetail, error) + // Embed the flowBase interface. + flowBase +} + +// subFlow represents a sub-flow. +type subFlow interface { + flowBase +} + +type contextValues map[string]interface{} + +type defaultFlowBase struct { + name FlowName + flow stateActions // StateName to Actions mapping. + subFlows SubFlows // The sub-flows of the current flow. + beforeStateHooks stateHooks // StateName to hookActions mapping. + afterStateHooks stateHooks // StateName to hookActions mapping. + beforeEachActionHooks hookActions // List of hookActions that run before each action. + afterEachActionHooks hookActions // List of hookActions that run after each action. + afterFlowHooks flowHooks +} + +// defaultFlow defines a flow structure with states, actions, and settings. +type defaultFlow struct { + stateDetails stateDetails // Maps state names to flow details. + initialStateNames []StateName // A list of next states in case a sub-flow should be invoked initially. + errorStateName StateName // State representing errors. + ttl time.Duration // Time-to-live for the flow. + debug bool // Enables debug mode. + queryParam queryParam // TODO + contextValues contextValues // Values to be used within the flow context. + inputData InputData + useCompression bool + queryParamKey string + queryParamValue string + + *defaultFlowBase +} + +func (f *defaultFlow) Set(name string, value interface{}) { + f.contextValues[name] = value +} + +// getActionsForState returns state details for the specified state. +func (f *defaultFlow) getState(stateName StateName) (stateDetail, error) { + if state, ok := f.stateDetails[stateName]; ok { + return state, nil + } + + return nil, fmt.Errorf("unknown state: %s", stateName) +} + +// getName returns the flow name. +func (f *defaultFlowBase) getName() FlowName { + return f.name +} + +// getSubFlows returns the sub-flows of the current flow. +func (f *defaultFlowBase) getSubFlows() SubFlows { + return f.subFlows +} + +// getFlow returns the state to action mapping of the current flow. +func (f *defaultFlowBase) getFlow() stateActions { + return f.flow +} + +func (f *defaultFlowBase) getBeforeStateHooks() stateHooks { + return f.beforeStateHooks +} + +func (f *defaultFlowBase) getAfterStateHooks() stateHooks { + return f.afterStateHooks +} + +func (f *defaultFlowBase) getAfterFlowHooks() hookActions { + return f.afterFlowHooks[f.name] +} + +// setDefaults sets default values for defaultFlow settings. +func (f *defaultFlow) setDefaults() { + if f.ttl.Seconds() == 0 { + f.ttl = time.Minute * 60 + } + + if len(f.queryParamKey) == 0 { + f.queryParamKey = "flowpilot_action" + } +} + +// Execute handles the execution of actions for a defaultFlow. +func (f *defaultFlow) Execute(db FlowDB, opts ...func(flow *defaultFlow)) (FlowResult, error) { + for _, option := range opts { + option(f) + } + + // Set default values for flow settings. + f.setDefaults() + + // If the action is empty, create a new flow. + if len(f.queryParamValue) == 0 { + return createAndInitializeFlow(db, *f) + } + + // Otherwise, update an existing flow... + q, err := newQueryParam(f.queryParamKey, f.queryParamValue) + if err != nil { + return newFlowResultFromError(f.errorStateName, ErrorTechnical.Wrap(err), f.debug), nil + } + + f.queryParam = q + + return executeFlowAction(db, *f) +} + +// ResultFromError returns an error response for the defaultFlow. +func (f *defaultFlow) ResultFromError(err error) FlowResult { + flowError := ErrorTechnical + + if e, ok := err.(FlowError); ok { + flowError = e + } else { + flowError = flowError.Wrap(err) + } + + return newFlowResultFromError(f.errorStateName, flowError, f.debug) +} diff --git a/backend/flowpilot/input.go b/backend/flowpilot/input.go new file mode 100644 index 000000000..2e35516cc --- /dev/null +++ b/backend/flowpilot/input.go @@ -0,0 +1,282 @@ +package flowpilot + +import ( + "regexp" +) + +// inputType represents the type of the input field. +type inputType string + +// Input types enumeration. +const ( + inputTypeString inputType = "string" + inputTypeBoolean inputType = "boolean" + inputTypeEmail inputType = "email" + inputTypeNumber inputType = "number" + inputTypePassword inputType = "password" + inputTypeJSON inputType = "json" +) + +// Input defines the interface for input fields. +type Input interface { + MinLength(minLength int) Input + MaxLength(maxLength int) Input + Required(b bool) Input + Hidden(b bool) Input + Preserve(b bool) Input + AllowedValue(name string, value interface{}) Input + TrimSpace(b bool) Input + LowerCase(b bool) Input + + setValue(value interface{}) Input + setError(inputError InputError) + getError() InputError + getName() string + shouldPreserve() bool + shouldTrimSpace() bool + shouldConvertToLowerCase() bool + validate(inputData readOnlyActionInput) bool + toResponseInput() *ResponseInput +} + +// defaultExtraInputOptions holds additional input field options. +type defaultExtraInputOptions struct { + preserveValue bool + trimSpace bool + lowerCase bool +} + +// defaultInput represents an input field with its options. +type defaultInput struct { + name string + dataType inputType + value interface{} + minLength *int + maxLength *int + required *bool + hidden *bool + error InputError + allowedValues allowedValues + + defaultExtraInputOptions +} + +func (i *defaultInput) AllowedValue(name string, value interface{}) Input { + i.allowedValues.add(&defaultAllowedValue{ + value: value, + text: name, + }) + return i +} + +// newInput creates a new input instance with provided parameters. +func newInput(name string, inputType inputType) Input { + extraOptions := defaultExtraInputOptions{ + preserveValue: false, + trimSpace: false, + lowerCase: false, + } + + return &defaultInput{ + name: name, + dataType: inputType, + defaultExtraInputOptions: extraOptions, + allowedValues: &defaultAllowedValues{}, + } +} + +// StringInput creates a new input field of string type. +func StringInput(name string) Input { + return newInput(name, inputTypeString) +} + +// EmailInput creates a new input field of email type. +func EmailInput(name string) Input { + return newInput(name, inputTypeEmail) +} + +// NumberInput creates a new input field of number type. +func NumberInput(name string) Input { + return newInput(name, inputTypeNumber) +} + +// BooleanInput creates a new input field of boolean type. +func BooleanInput(name string) Input { + return newInput(name, inputTypeBoolean) +} + +// PasswordInput creates a new input field of password type. +func PasswordInput(name string) Input { + return newInput(name, inputTypePassword) +} + +// JSONInput creates a new input field of JSON type. +func JSONInput(name string) Input { + return newInput(name, inputTypeJSON) +} + +// MinLength sets the minimum length for the input field. +func (i *defaultInput) MinLength(minLength int) Input { + i.minLength = &minLength + return i +} + +// MaxLength sets the maximum length for the input field. +func (i *defaultInput) MaxLength(maxLength int) Input { + i.maxLength = &maxLength + return i +} + +// Required sets whether the input field is required. +func (i *defaultInput) Required(b bool) Input { + i.required = &b + return i +} + +// Hidden sets whether the input field is hidden. +func (i *defaultInput) Hidden(b bool) Input { + i.hidden = &b + return i +} + +// Preserve sets whether the input field value should be preserved, so that the value is included in the response +// instead of being blanked out. +func (i *defaultInput) Preserve(b bool) Input { + i.preserveValue = b + return i +} + +// TrimSpace sets whether the leading and trailing whitespaces should be trimmed. +func (i *defaultInput) TrimSpace(b bool) Input { + i.trimSpace = b + return i +} + +// LowerCase sets whether the value should be converted to lower case. +func (i *defaultInput) LowerCase(b bool) Input { + i.lowerCase = b + return i +} + +// setValue sets the value for the input field for the current response. +func (i *defaultInput) setValue(value interface{}) Input { + i.value = &value + return i +} + +// getName returns the name of the input field. +func (i *defaultInput) getName() string { + return i.name +} + +// setError sets an error to the given input field. +func (i *defaultInput) setError(inputError InputError) { + i.error = inputError +} + +// getError returns the input error. +func (i *defaultInput) getError() InputError { + return i.error +} + +// shouldPreserve indicates the value should be preserved. +func (i *defaultInput) shouldPreserve() bool { + return i.preserveValue +} + +// shouldTrimSpace indicates the value should be trimmed. +func (i *defaultInput) shouldTrimSpace() bool { + return i.trimSpace +} + +// shouldConvertToLowerCase indicates the value should be converted to lower case. +func (i *defaultInput) shouldConvertToLowerCase() bool { + return i.lowerCase +} + +// validate performs validation on the input field. +func (i *defaultInput) validate(inputData readOnlyActionInput) bool { + // TODO: Replace with more structured validation logic. + + var inputValue *string + + if v := inputData.Get(i.name); v.Exists() { + inputValue = &v.Str + } + + if i.dataType == inputTypeJSON { + // skip further validation + return true + } + + if i.dataType == inputTypeBoolean { + return true + } + + isRequired := i.required != nil && *i.required + hasEmptyOrNilValue := inputValue == nil || len(*inputValue) <= 0 + + if isRequired && hasEmptyOrNilValue { + i.error = ErrorValueMissing + return false + } + + if !hasEmptyOrNilValue && i.minLength != nil { + if len(*inputValue) < *i.minLength { + i.error = ErrorValueTooShort + return false + } + } + + if !hasEmptyOrNilValue && i.maxLength != nil { + if len(*inputValue) > *i.maxLength { + i.error = ErrorValueTooLong + return false + } + } + + if i.dataType == inputTypeEmail && (isRequired || (!isRequired && !hasEmptyOrNilValue)) { + pattern := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) + if matched := pattern.MatchString(*inputValue); !matched { + i.error = ErrorValueInvalid + return false + } + } + + if i.dataType == inputTypeString && inputValue != nil { + if i.allowedValues.isAllowed(*inputValue) { + return true + } + + i.error = createMustBeOneOfError(i.allowedValues.getValues()) + return false + } + + return true +} + +// toResponseInput converts the defaultInput to a ResponseInput for public exposure. +func (i *defaultInput) toResponseInput() *ResponseInput { + var e *ResponseError + var av *ResponseAllowedValues + + if i.error != nil { + e = i.error.toResponseError(true) + } + + if i.allowedValues != nil && i.allowedValues.hasAny() { + av = i.allowedValues.toResponseAllowedValues() + } + + return &ResponseInput{ + Name: i.name, + Type: i.dataType, + Value: i.value, + MinLength: i.minLength, + MaxLength: i.maxLength, + Required: i.required, + Hidden: i.hidden, + Error: e, + AllowedValues: av, + } +} diff --git a/backend/flowpilot/input_allowed_value.go b/backend/flowpilot/input_allowed_value.go new file mode 100644 index 000000000..587253c79 --- /dev/null +++ b/backend/flowpilot/input_allowed_value.go @@ -0,0 +1,71 @@ +package flowpilot + +type allowedValue interface { + toResponseAllowedValue() *ResponseAllowedValue + getValue() interface{} +} + +type defaultAllowedValue struct { + text string + value interface{} +} + +func (av *defaultAllowedValue) getValue() interface{} { + return av.value +} + +// toResponseAllowedValue converts the allowedValue to a ResponseAllowedValue for public exposure. +func (av *defaultAllowedValue) toResponseAllowedValue() *ResponseAllowedValue { + return &ResponseAllowedValue{ + Text: av.text, + Value: av.value, + } +} + +type allowedValues interface { + isAllowed(value string) bool + add(allowedValue) + toResponseAllowedValues() *ResponseAllowedValues + hasAny() bool + getValues() []string +} + +type defaultAllowedValues []allowedValue + +func (av *defaultAllowedValues) isAllowed(value string) bool { + if len(*av) == 0 { + return true + } + + for _, v := range *av { + if v.getValue().(string) == value { + return true + } + } + + return false +} + +func (av *defaultAllowedValues) add(value allowedValue) { + *av = append(*av, value) +} + +func (av *defaultAllowedValues) hasAny() bool { + return len(*av) > 0 +} + +func (av *defaultAllowedValues) getValues() []string { + values := make([]string, len(*av)) + for i, v := range *av { + values[i] = v.getValue().(string) + } + return values +} + +func (av *defaultAllowedValues) toResponseAllowedValues() *ResponseAllowedValues { + values := make(ResponseAllowedValues, len(*av)) + for i, v := range *av { + values[i] = v.toResponseAllowedValue() + } + return &values +} diff --git a/backend/flowpilot/input_schema.go b/backend/flowpilot/input_schema.go new file mode 100644 index 000000000..502b87975 --- /dev/null +++ b/backend/flowpilot/input_schema.go @@ -0,0 +1,159 @@ +package flowpilot + +import ( + "github.com/tidwall/gjson" + "strings" +) + +// initializationInputSchema represents an interface for managing input data schemas. +type initializationInputSchema interface { + AddInputs(inputList ...Input) +} + +// executionInputSchema represents an interface for managing method execution schemas. +type executionInputSchema interface { + Get(path string) gjson.Result + Set(path string, value interface{}) error + SetError(inputName string, inputError InputError) + + getInput(name string) Input + getOutputData() readOnlyActionInput + validateInputData() bool + forInitializationContext() initializationInputSchema + toResponseInputs() ResponseInputs +} + +// inputs represents a collection of Input instances. +type inputs []Input + +func (il *inputs) exists(input Input) bool { + for _, existingInput := range *il { + if existingInput.getName() == input.getName() { + return true + } + } + return false +} + +// ResponseInputs represents a collection of ResponseInput instances. +type ResponseInputs map[string]*ResponseInput + +// defaultSchema implements the initializationInputSchema interface and holds a collection of input fields. +type defaultSchema struct { + inputs + inputData actionInput + outputData actionInput +} + +// newSchemaWithInputData creates a new executionInputSchema with input data. +func newSchemaWithInputData(inputData actionInput) executionInputSchema { + outputData := newActionInput() + return &defaultSchema{ + inputData: inputData, + outputData: outputData, + } +} + +// newSchema creates a new executionInputSchema with no input data. +func newSchema() executionInputSchema { + inputData := newActionInput() + return newSchemaWithInputData(inputData) +} + +// toInitializationSchema converts executionInputSchema to initializationInputSchema. +func (s *defaultSchema) forInitializationContext() initializationInputSchema { + return s +} + +// Get retrieves a value at the specified path in the input data. +func (s *defaultSchema) Get(path string) gjson.Result { + return s.inputData.Get(path) +} + +// Set updates the JSON data at the specified path with the provided value. +func (s *defaultSchema) Set(path string, value interface{}) error { + return s.outputData.Set(path, value) +} + +// AddInputs adds input fields to the defaultSchema and returns the updated inputSchema. +func (s *defaultSchema) AddInputs(inputList ...Input) { + for _, input := range inputList { + if !s.inputs.exists(input) { + s.inputs = append(s.inputs, input) + } else { + for i, existingInput := range s.inputs { + if existingInput.getName() == input.getName() { + input.setError(existingInput.getError()) + s.inputs[i] = input + } + } + } + } +} + +// getInput retrieves an input field from the inputSchema based on its name. +func (s *defaultSchema) getInput(name string) Input { + for _, input := range s.inputs { + if input.getName() == name { + return input + } + } + + return nil +} + +// SetError sets an error for an input field in the inputSchema. +func (s *defaultSchema) SetError(inputName string, inputError InputError) { + if input := s.getInput(inputName); input != nil { + input.setError(inputError) + } +} + +// validateInputData validates the input data based on the input definitions in the inputSchema. +func (s *defaultSchema) validateInputData() bool { + for _, input := range s.inputs { + name := input.getName() + + if input.shouldTrimSpace() { + v := strings.TrimSpace(s.inputData.Get(name).String()) + _ = s.inputData.Set(name, v) + } + + if input.shouldConvertToLowerCase() { + v := strings.ToLower(s.inputData.Get(name).String()) + _ = s.inputData.Set(name, v) + } + } + + valid := true + + for _, input := range s.inputs { + if !input.validate(s.inputData) && valid { + valid = false + } + } + + return valid +} + +// getOutputData returns the output data from the inputSchema. +func (s *defaultSchema) getOutputData() readOnlyActionInput { + return s.outputData +} + +// toResponseInputs converts defaultSchema to ResponseInputs for public exposure. +func (s *defaultSchema) toResponseInputs() ResponseInputs { + var publicSchema = make(ResponseInputs) + + for _, input := range s.inputs { + if s.outputData.Get(input.getName()).Exists() { + input.setValue(s.outputData.Get(input.getName()).Value()) + } else if input.shouldPreserve() { + input.setValue(s.inputData.Get(input.getName()).Value()) + } + + publicSchema[input.getName()] = input.toResponseInput() + } + + return publicSchema +} diff --git a/backend/flowpilot/jsonmanager/manager.go b/backend/flowpilot/jsonmanager/manager.go new file mode 100644 index 000000000..c882bce4e --- /dev/null +++ b/backend/flowpilot/jsonmanager/manager.go @@ -0,0 +1,84 @@ +package jsonmanager + +import ( + "errors" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" +) + +// ReadJSONManager is the interface that allows read operations. +type ReadJSONManager interface { + Get(path string) gjson.Result // Get retrieves the value at the specified path in the JSON data. + String() string // String returns the JSON data as a string. + Unmarshal() interface{} // Unmarshal parses the JSON data and returns it as an interface{}. +} + +// JSONManager is the interface that defines methods for reading, writing, and deleting JSON data. +type JSONManager interface { + ReadJSONManager + Set(path string, value interface{}) error // Set updates the JSON data at the specified path with the provided value. + Delete(path string) error // Delete removes a value from the JSON data at the specified path. +} + +// ReadOnlyJSONManager is the interface that allows only read operations. +type ReadOnlyJSONManager interface { + ReadJSONManager +} + +// DefaultJSONManager is the default implementation of the JSONManager interface. +type DefaultJSONManager struct { + data string // The JSON data stored as a string. +} + +// NewJSONManager creates a new instance of DefaultJSONManager with empty JSON data. +func NewJSONManager() JSONManager { + return &DefaultJSONManager{data: "{}"} +} + +// NewJSONManagerFromString creates a new instance of DefaultJSONManager with the given JSON data. +// It checks if the provided data is valid JSON before creating the instance. +func NewJSONManagerFromString(data string) (JSONManager, error) { + if !gjson.Valid(data) { + return nil, errors.New("invalid json") + } + return &DefaultJSONManager{data: data}, nil +} + +// Get retrieves the value at the specified path in the JSON data. +func (jm *DefaultJSONManager) Get(path string) gjson.Result { + return gjson.Get(jm.data, path) +} + +// Set updates the JSON data at the specified path with the provided value. +func (jm *DefaultJSONManager) Set(path string, value interface{}) error { + newData, err := sjson.Set(jm.data, path, value) + if err != nil { + return err + } + jm.data = newData + return nil +} + +// Delete removes a value from the JSON data at the specified path. +func (jm *DefaultJSONManager) Delete(path string) error { + newData, err := sjson.Delete(jm.data, string(path)) + if err != nil { + return err + } + jm.data = newData + return nil +} + +// String returns the JSON data as a string. +func (jm *DefaultJSONManager) String() string { + return jm.data +} + +// Unmarshal parses the JSON data and returns it as an interface{}. +func (jm *DefaultJSONManager) Unmarshal() interface{} { + m, ok := gjson.Parse(jm.data).Value().(interface{}) + if !ok { + return nil + } + return m +} diff --git a/backend/flowpilot/link.go b/backend/flowpilot/link.go new file mode 100644 index 000000000..dfd1724e2 --- /dev/null +++ b/backend/flowpilot/link.go @@ -0,0 +1,55 @@ +package flowpilot + +// LinkCategory represents the category of the link. +type LinkCategory string + +// LinkTarget represents the html target attribute. +type LinkTarget string + +// Link targets enumeration. +const ( + LinkTargetSelf LinkTarget = "_self" + LinkTargetBlank LinkTarget = "_blank" + LinkTargetParent LinkTarget = "_parent" + LinkTargetTop LinkTarget = "_top" +) + +// Link defines the interface for links. +type Link interface { + Target(LinkTarget) Link + + toResponseLink() ResponseLink +} + +// defaultLink represents a link with its options. +type defaultLink struct { + name string + href string + category LinkCategory + target LinkTarget +} + +// Target sets the target attribute of the link. +func (l *defaultLink) Target(target LinkTarget) Link { + l.target = target + return l +} + +func (l *defaultLink) toResponseLink() ResponseLink { + return ResponseLink{ + Name: l.name, + Href: l.href, + Category: l.category, + Target: l.target, + } +} + +// NewLink creates a new defaultLink instance with provided parameters. +func NewLink(name string, category LinkCategory, href string) Link { + return &defaultLink{ + name: name, + href: href, + category: category, + target: LinkTargetSelf, + } +} diff --git a/backend/flowpilot/payload.go b/backend/flowpilot/payload.go new file mode 100644 index 000000000..70b7c64e2 --- /dev/null +++ b/backend/flowpilot/payload.go @@ -0,0 +1,12 @@ +package flowpilot + +import "github.com/teamhanko/hanko/backend/flowpilot/jsonmanager" + +type payload interface { + jsonmanager.JSONManager +} + +// newPayload creates a new instance of Payload with empty JSON data. +func newPayload() payload { + return jsonmanager.NewJSONManager() +} diff --git a/backend/flowpilot/query_param.go b/backend/flowpilot/query_param.go new file mode 100644 index 000000000..82f57a1ff --- /dev/null +++ b/backend/flowpilot/query_param.go @@ -0,0 +1,88 @@ +package flowpilot + +import ( + "fmt" + "github.com/gofrs/uuid" + "net/url" + "strings" +) + +type queryParam interface { + getKey() string + getValue() string + getActionName() ActionName + getFlowID() uuid.UUID + getURLValues() url.Values +} + +// parsedQueryParamValue represents a parsed action from an input string. +type parsedQueryParamValue struct { + actionName ActionName // The actionName of the action extracted from the input string. + flowID uuid.UUID // The UUID representing the flow ID extracted from the input string. +} + +// defaultQueryParam represents a parsed action from an input string. +type defaultQueryParam struct { + key string + + *parsedQueryParamValue +} + +func createQueryParamValue(actionName ActionName, flowID uuid.UUID) string { + return fmt.Sprintf("%s@%s", actionName, flowID) +} + +// parseValue parses an input string to extract action name and flow ID. +func parseQueryParamValue(value string) (*parsedQueryParamValue, error) { + if value == "" { + return nil, fmt.Errorf("query param value is empty") + } + + // Split the input string into action and flow ID parts using "@" as separator. + parts := strings.SplitN(value, "@", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid query param value format") + } + + // Extract action name from the first part of the split. + action := parts[0] + if len(action) == 0 { + return nil, fmt.Errorf("first part of the query param value is empty") + } + + // Parse the second part of the input string into a UUID representing the flow ID. + flowID, err := uuid.FromString(parts[1]) + if err != nil { + return nil, fmt.Errorf("failed to parse second part of the query param value: %w", err) + } + + // Return a defaultQueryParam instance with extracted action name and flow ID. + return &parsedQueryParamValue{actionName: ActionName(action), flowID: flowID}, nil +} + +func newQueryParam(key, value string) (queryParam, error) { + v, err := parseQueryParamValue(value) + return &defaultQueryParam{key: key, parsedQueryParamValue: v}, err +} + +func (q *defaultQueryParam) getKey() string { + return q.key +} + +func (q *defaultQueryParam) getValue() string { + return createQueryParamValue(q.getActionName(), q.getFlowID()) +} + +func (q *defaultQueryParam) getURLValues() url.Values { + values := url.Values{} + values.Add(q.getKey(), q.getValue()) + return values +} + +func (q *defaultQueryParam) getActionName() ActionName { + return q.parsedQueryParamValue.actionName +} + +func (q *defaultQueryParam) getFlowID() uuid.UUID { + return q.parsedQueryParamValue.flowID +} diff --git a/backend/flowpilot/random.go b/backend/flowpilot/random.go new file mode 100644 index 000000000..3bcaccada --- /dev/null +++ b/backend/flowpilot/random.go @@ -0,0 +1,36 @@ +package flowpilot + +import ( + "crypto/rand" + "fmt" + "io" + "math/big" +) + +const letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + +func init() { + assertAvailablePRNG() +} + +func assertAvailablePRNG() { + // Assert that a cryptographically secure PRNG is available. + // Panic otherwise. + buf := make([]byte, 1) + _, err := io.ReadFull(rand.Reader, buf) + if err != nil { + panic(fmt.Sprintf("crypto/rand is unavailable: Read() failed with %#v", err)) + } +} + +func generateRandomString(n int) (string, error) { + ret := make([]byte, n) + for i := 0; i < n; i++ { + num, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) + if err != nil { + return "", err + } + ret[i] = letters[num.Int64()] + } + return string(ret), nil +} diff --git a/backend/flowpilot/response.go b/backend/flowpilot/response.go new file mode 100644 index 000000000..cc0355e8c --- /dev/null +++ b/backend/flowpilot/response.go @@ -0,0 +1,228 @@ +package flowpilot + +import ( + "fmt" + "net/http" +) + +// ResponseAction represents a link to an action. +type ResponseAction struct { + Href string `json:"href"` + Inputs ResponseInputs `json:"inputs"` + Name ActionName `json:"action"` + Description string `json:"description"` +} + +// ResponseActions is a collection of ResponseAction instances. +type ResponseActions map[ActionName]ResponseAction + +// ResponseError represents an error for public exposure. +type ResponseError struct { + Code string `json:"code"` + Message string `json:"message"` + Cause *string `json:"cause,omitempty"` + Internal *string `json:"-"` +} + +type ResponseAllowedValue struct { + Value interface{} `json:"value"` + Text string `json:"name"` +} + +type ResponseAllowedValues []*ResponseAllowedValue + +// ResponseInput represents an input field for public exposure. +type ResponseInput struct { + Name string `json:"name"` + Type inputType `json:"type"` + Value interface{} `json:"value,omitempty"` + MinLength *int `json:"min_length,omitempty"` + MaxLength *int `json:"max_length,omitempty"` + Required *bool `json:"required,omitempty"` + Hidden *bool `json:"hidden,omitempty"` + Error *ResponseError `json:"error,omitempty"` + AllowedValues *ResponseAllowedValues `json:"allowed_values,omitempty"` +} + +// ResponseLinks is a collection of Link instances. +type ResponseLinks []ResponseLink + +// ResponseLink represents a link for public exposure. +type ResponseLink struct { + Name string `json:"name"` // tos, privacy, google, apple, microsoft, login, registration ... // how can we insert custom oauth provider here + Href string `json:"href"` + Category LinkCategory `json:"category"` // oauth, legal, other, ... + Target LinkTarget `json:"target"` // can be used to add the target of the a-tag e.g. _blank +} + +// Response represents the response of an action execution. +type Response struct { + Name StateName `json:"name"` + Status int `json:"status"` + Payload interface{} `json:"payload,omitempty"` + CSRFToken string `json:"csrf_token"` + Actions ResponseActions `json:"actions"` + Error *ResponseError `json:"error,omitempty"` + Links ResponseLinks `json:"links"` +} + +// FlowResult interface defines methods for obtaining response and status. +type FlowResult interface { + GetResponse() Response + GetStatus() int +} + +// defaultFlowResult implements FlowResult interface. +type defaultFlowResult struct { + response Response +} + +// newFlowResultFromResponse creates a FlowResult from a Response. +func newFlowResultFromResponse(response Response) FlowResult { + return defaultFlowResult{response: response} +} + +// newFlowResultFromError creates a FlowResult from a FlowError. +func newFlowResultFromError(stateName StateName, flowError FlowError, debug bool) FlowResult { + e := flowError.toResponseError(debug) + status := flowError.Status() + + response := Response{ + Name: stateName, + Status: status, + Error: e, + Actions: ResponseActions{}, + } + + return defaultFlowResult{response: response} +} + +// GetResponse returns the Response. +func (r defaultFlowResult) GetResponse() Response { + return r.response +} + +// GetStatus returns the HTTP status code. +func (r defaultFlowResult) GetStatus() int { + return r.response.Status +} + +// actionExecutionResult holds the result of a method execution. +type actionExecutionResult struct { + actionName ActionName + inputSchema executionInputSchema + isSuspended bool +} + +// executionResult holds the result of an action execution. +type executionResult struct { + nextStateName StateName + flowError FlowError + links []Link + + *actionExecutionResult +} + +// generateResponse generates a response based on the execution result. +func (er *executionResult) generateResponse(fc *defaultFlowContext) FlowResult { + // Generate actions for the response. + actions := er.generateActions(fc) + + // Unmarshal the generated payload for the response. + p := fc.payload.Unmarshal() + + // Generate links for the response. + links := er.generateLinks() + + // Create the response object. + resp := Response{ + Name: er.nextStateName, + Status: http.StatusOK, + Payload: p, + Actions: actions, + Links: links, + CSRFToken: fc.flowModel.CSRFToken, + } + + // Include flow error if present. + if er.flowError != nil { + status := er.flowError.Status() + e := er.flowError.toResponseError(fc.flow.debug) + + resp.Status = status + resp.Error = e + } + + return newFlowResultFromResponse(resp) +} + +func (er *executionResult) generateLinks() ResponseLinks { + var links ResponseLinks + + for _, link := range er.links { + l := link.toResponseLink() + links = append(links, l) + } + + return links +} + +// generateActions generates a collection of links based on the execution result. +func (er *executionResult) generateActions(fc *defaultFlowContext) ResponseActions { + var actions = make(ResponseActions) + + // Get actions for the next addState. + state, _ := fc.flow.getState(er.nextStateName) + + if state != nil { + for _, ad := range state.getActionDetails() { + actionName := ad.getAction().GetName() + actionDescription := ad.getAction().GetDescription() + + // Create action HREF based on the current flow context and method name. + href, _ := er.createHref(fc, actionName) + inputSchema := er.getInputSchema(fc, ad) + + // (Re-)Initialize each action + aic := defaultActionInitializationContext{ + inputSchema: inputSchema.forInitializationContext(), + defaultFlowContext: fc, + } + + ad.getAction().Initialize(&aic) + + if aic.isSuspended { + continue + } + + inputSchemaResponse := inputSchema.toResponseInputs() + + // Create the action instance. + action := ResponseAction{ + Href: href, + Inputs: inputSchemaResponse, + Name: actionName, + Description: actionDescription, + } + + actions[actionName] = action + } + } + + return actions +} + +// getInputSchema returns the inputSchema for a given method name. +func (er *executionResult) getInputSchema(fc *defaultFlowContext, actionDetail actionDetail) executionInputSchema { + actionName := actionDetail.getAction().GetName() + if er.actionExecutionResult == nil || actionName != er.actionExecutionResult.actionName { + return newSchema() + } + return er.actionExecutionResult.inputSchema +} + +// createHref creates a link HREF based on the current flow context and method name. +func (er *executionResult) createHref(fc *defaultFlowContext, actionName ActionName) (string, error) { + q, err := newQueryParam(fc.flow.queryParamKey, createQueryParamValue(actionName, fc.GetFlowID())) + return fmt.Sprintf("/%s?%s", fc.GetFlowName(), q.getURLValues().Encode()), err +} diff --git a/backend/flowpilot/stash.go b/backend/flowpilot/stash.go new file mode 100644 index 000000000..2fd8e66d5 --- /dev/null +++ b/backend/flowpilot/stash.go @@ -0,0 +1,311 @@ +package flowpilot + +import ( + "bytes" + "compress/gzip" + "encoding/base64" + "errors" + "fmt" + "github.com/teamhanko/hanko/backend/flowpilot/jsonmanager" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + "io" +) + +const ( + stashKeyState = "state" + stashKeyPreviousState = "prev_state" + stashKeyScheduledStates = "scheduled" + stashKeyData = "data" + stashKeyHistory = "hist" + stashKeyRevertible = "revertible" + stashKeySticky = "sticky" +) + +type stash interface { + pushState(bool) error + pushErrorState(StateName) error + revertState() error + isRevertible() bool + getStateName() StateName + getPreviousStateName() StateName + addScheduledStateNames(...StateName) + getNextStateName() StateName + useCompression(bool) + + jsonmanager.JSONManager +} + +type defaultStash struct { + jm jsonmanager.JSONManager + data jsonmanager.JSONManager + scheduledStateNames []StateName + compressionEnabled bool +} + +// newStashFromJSONManager creates a new instance of stash with a given JSONManager. +func newStashFromJSONManager(jm jsonmanager.JSONManager) stash { + data, _ := jsonmanager.NewJSONManagerFromString(jm.Get(stashKeyData).String()) + return &defaultStash{ + jm: jm, + data: data, + scheduledStateNames: make([]StateName, 0), + compressionEnabled: false, + } +} + +// newStash creates a new instance of Stash with empty JSON data. +func newStash(nextStates ...StateName) (stash, error) { + jm := jsonmanager.NewJSONManager() + + if len(nextStates) == 0 { + return nil, errors.New("can't create a new stash without a state name") + } + + if err := jm.Set(stashKeyState, nextStates[0]); err != nil { + return nil, err + } + + if err := jm.Set(stashKeyScheduledStates, reverseStateNames(nextStates[1:])); err != nil { + return nil, err + } + + if err := jm.Set(stashKeyData, "{}"); err != nil { + return nil, err + } + + return newStashFromJSONManager(jm), nil +} + +// newStashFromString creates a new instance of Stash with the given JSON data. +func newStashFromString(data string) (stash, error) { + var err error + + if len(data) > 0 && !startsWithCurlyBrace(data) { + if data, err = decodeData(data); err != nil { + return nil, fmt.Errorf("faiiled to decode stash data: %w", err) + } + } + + jm, err := jsonmanager.NewJSONManagerFromString(data) + return newStashFromJSONManager(jm), err +} + +func reverseStateNames(slice []StateName) []StateName { + reversed := make([]StateName, len(slice)) + for i, v := range slice { + reversed[len(slice)-1-i] = v + } + return reversed +} + +func startsWithCurlyBrace(s string) bool { + // Check if the string is not empty + if len(s) == 0 { + return false + } + // Check if the first character is '{' + return s[0] == '{' +} + +func encodeData(jsonData string) (string, error) { + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + if _, err := gw.Write([]byte(jsonData)); err != nil { + return "", err + } + + if err := gw.Close(); err != nil { + return "", err + } + + gzippedData := buf.Bytes() + base64GzippedData := base64.StdEncoding.EncodeToString(gzippedData) + return base64GzippedData, nil +} + +func decodeData(base64GzippedData string) (string, error) { + gzippedData, err := base64.StdEncoding.DecodeString(base64GzippedData) + if err != nil { + return "", err + } + + buf := bytes.NewBuffer(gzippedData) + gr, err := gzip.NewReader(buf) + if err != nil { + return "", err + } + + defer gr.Close() + + decompressedData, err := io.ReadAll(gr) + if err != nil { + return "", err + } + + return string(decompressedData), nil +} + +// Get retrieves the value at the specified path in the JSON data. +func (h *defaultStash) Get(path string) gjson.Result { + return h.data.Get(path) +} + +// Set updates the JSON data at the specified path with the provided value. +func (h *defaultStash) Set(path string, value interface{}) error { + return h.data.Set(path, value) +} + +// Delete removes a value from the JSON data at the specified path. +func (h *defaultStash) Delete(path string) error { + return h.data.Delete(path) +} + +// String returns the JSON data as a string. +func (h *defaultStash) String() string { + if h.compressionEnabled { + s, _ := encodeData(h.jm.String()) + return s + } + return h.jm.String() +} + +// Unmarshal parses the JSON data and returns it as an interface{}. +func (h *defaultStash) Unmarshal() interface{} { + return h.jm.Unmarshal() +} + +func (h *defaultStash) pushState(revertible bool) error { + return h.push(h.data.String(), revertible, h.getStateName() != h.getNextStateName()) +} + +func (h *defaultStash) pushErrorState(nextState StateName) error { + return h.push(h.jm.Get(stashKeyData).String(), h.isRevertible(), false, nextState) +} + +func (h *defaultStash) push(newData string, revertible, writeHistory bool, nextStates ...StateName) error { + var err error + + data := h.jm.Get(stashKeyData) + scheduledStates := h.jm.Get(stashKeyScheduledStates) + scheduledStatesArr := scheduledStates.Array() + stateStr := h.jm.Get(stashKeyState).String() + prevStateStr := h.jm.Get(stashKeyPreviousState).String() + + scheduledStatesUpdated := make([]StateName, len(scheduledStatesArr)) + maxIndex := len(scheduledStatesUpdated) - 1 + for index := range scheduledStatesUpdated { + scheduledStatesUpdated[maxIndex-index] = StateName(scheduledStatesArr[index].String()) + } + + scheduledStatesUpdated = append(nextStates, append(h.scheduledStateNames, scheduledStatesUpdated...)...) + if len(scheduledStatesUpdated) == 0 { + return errors.New("no state left to be used as the next state") + } + + nextStateName := scheduledStatesUpdated[0] + scheduledStatesUpdated = reverseStateNames(scheduledStatesUpdated[1:]) + + if writeHistory { + histItem := "{}" + for key, value := range map[string]interface{}{ + stashKeyState: stateStr, + stashKeyPreviousState: prevStateStr, + stashKeyData: data.Value(), + stashKeyRevertible: revertible, + stashKeyScheduledStates: scheduledStates.Value(), + } { + if histItem, err = sjson.Set(histItem, key, value); err != nil { + return err + } + } + + stashKeyNewHistItem := fmt.Sprintf("%s.-1", stashKeyHistory) + if err = h.jm.Set(stashKeyNewHistItem, gjson.Parse(histItem).Value()); err != nil { + return err + } + } + + for key, value := range map[string]interface{}{ + stashKeyState: nextStateName, + stashKeyPreviousState: stateStr, + stashKeyData: gjson.Parse(newData).Value(), + stashKeyScheduledStates: scheduledStatesUpdated, + } { + if err = h.jm.Set(key, value); err != nil { + return err + } + } + + return nil +} + +func (h *defaultStash) revertState() error { + var err error + + lastHistItemIndex := h.jm.Get(fmt.Sprintf("%s.#", stashKeyHistory)).Int() - 1 + lastHistItem := h.jm.Get(fmt.Sprintf("%s.%d", stashKeyHistory, lastHistItemIndex)) + + if !lastHistItem.Exists() { + return errors.New("no state to revert to") + } + + if !lastHistItem.Get(stashKeyRevertible).Bool() { + return errors.New("state is not revertible") + } + + dataUpdated := lastHistItem.Get(stashKeyData) + h.data.Get(stashKeySticky).ForEach(func(key, value gjson.Result) bool { + path := fmt.Sprintf("%s.%s", stashKeySticky, key.String()) + updated, _ := sjson.Set(dataUpdated.String(), path, value.Value()) + dataUpdated = gjson.Parse(updated) + return true + }) + + if err = h.jm.Delete(fmt.Sprintf("%s.-1", stashKeyHistory)); err != nil { + return err + } + + for key, value := range map[string]interface{}{ + stashKeyScheduledStates: lastHistItem.Get(stashKeyScheduledStates).Value(), + stashKeyState: lastHistItem.Get(stashKeyState).Value(), + stashKeyPreviousState: lastHistItem.Get(stashKeyPreviousState).Value(), + stashKeyData: dataUpdated.Value(), + } { + if err = h.jm.Set(key, value); err != nil { + return err + } + } + + return nil +} + +func (h *defaultStash) getStateName() StateName { + return StateName(h.jm.Get(stashKeyState).String()) +} + +func (h *defaultStash) getPreviousStateName() StateName { + return StateName(h.jm.Get(stashKeyPreviousState).String()) +} + +func (h *defaultStash) addScheduledStateNames(names ...StateName) { + h.scheduledStateNames = append(h.scheduledStateNames, names...) +} + +func (h *defaultStash) getNextStateName() StateName { + if len(h.scheduledStateNames) > 0 { + return h.scheduledStateNames[0] + } + + lastScheduledIndex := h.jm.Get(fmt.Sprintf("%s.#", stashKeyScheduledStates)).Int() - 1 + return StateName(h.jm.Get(fmt.Sprintf("%s.%d", stashKeyScheduledStates, lastScheduledIndex)).String()) +} + +func (h *defaultStash) isRevertible() bool { + lastHistItemIndex := h.jm.Get(fmt.Sprintf("%s.#", stashKeyHistory)).Int() - 1 + return h.jm.Get(fmt.Sprintf("%s.%d.%s", stashKeyHistory, lastHistItemIndex, stashKeyRevertible)).Bool() +} + +func (h *defaultStash) useCompression(b bool) { + h.compressionEnabled = b +} diff --git a/backend/flowpilot/state_action.go b/backend/flowpilot/state_action.go new file mode 100644 index 000000000..8ffd4496b --- /dev/null +++ b/backend/flowpilot/state_action.go @@ -0,0 +1,22 @@ +package flowpilot + +type actionDetail interface { + getAction() Action + getFlowName() FlowName +} + +type defaultActionDetail struct { + action Action + flowName FlowName +} + +// actions represents a list of action +type defaultActionDetails []actionDetail + +func (ad *defaultActionDetail) getAction() Action { + return ad.action +} + +func (ad *defaultActionDetail) getFlowName() FlowName { + return ad.flowName +} diff --git a/backend/flowpilot/state_detail.go b/backend/flowpilot/state_detail.go new file mode 100644 index 000000000..625c58d7b --- /dev/null +++ b/backend/flowpilot/state_detail.go @@ -0,0 +1,57 @@ +package flowpilot + +import "fmt" + +type stateDetail interface { + getName() StateName + getFlow() stateActions + getFlowName() FlowName + getSubFlows() SubFlows + getActionDetails() defaultActionDetails + getActionDetail(actionName ActionName) (actionDetail, error) +} + +// state represents details for a state, including the associated actions, available sub-flows and more. +type defaultStateDetail struct { + name StateName + flowName FlowName + flow stateActions + subFlows SubFlows + actionDetails defaultActionDetails +} + +func (sd *defaultStateDetail) getName() StateName { + return sd.name +} + +func (sd *defaultStateDetail) getFlow() stateActions { + return sd.flow +} + +func (sd *defaultStateDetail) getFlowName() FlowName { + return sd.flowName +} + +func (sd *defaultStateDetail) getSubFlows() SubFlows { + return sd.subFlows +} + +func (sd *defaultStateDetail) getActionDetails() defaultActionDetails { + return sd.actionDetails +} + +// getActionDetail returns the Action with the specified name. +func (sd *defaultStateDetail) getActionDetail(actionName ActionName) (actionDetail, error) { + for _, ad := range sd.actionDetails { + currentActionName := ad.getAction().GetName() + + if currentActionName == actionName { + return ad, nil + } + } + + return nil, fmt.Errorf("action '%s' not found", actionName) +} + +// stateDetails maps states to associated Actions, flows and sub-flows. +type stateDetails map[StateName]stateDetail diff --git a/backend/go.mod b/backend/go.mod index 0e9d9a499..1ab92fe06 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -39,6 +39,8 @@ require ( github.com/sethvargo/go-redisstore v0.3.0 github.com/spf13/cobra v1.8.1 github.com/stretchr/testify v1.9.0 + github.com/tidwall/gjson v1.16.0 + github.com/tidwall/sjson v1.2.5 golang.org/x/crypto v0.24.0 golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 golang.org/x/oauth2 v0.21.0 @@ -144,6 +146,8 @@ require ( github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d // indirect github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect diff --git a/backend/go.sum b/backend/go.sum index 0db74803b..861c3e1fa 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -592,7 +592,16 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.16.0 h1:SyXa+dsSPpUlcwEDuKuEBJEz5vzTvOea+9rjyYodQFg= +github.com/tidwall/gjson v1.16.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= diff --git a/backend/handler/email.go b/backend/handler/email.go index 10d72d3e4..ccb49c39b 100644 --- a/backend/handler/email.go +++ b/backend/handler/email.go @@ -83,7 +83,7 @@ func (h *EmailHandler) Create(c echo.Context) error { return fmt.Errorf("failed to count user emails: %w", err) } - if emailCount >= h.cfg.Emails.MaxNumOfAddresses { + if emailCount >= h.cfg.Email.Limit { return echo.NewHTTPError(http.StatusConflict).SetInternal(errors.New("max number of email addresses reached")) } @@ -107,7 +107,7 @@ func (h *EmailHandler) Create(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest).SetInternal(errors.New("email address already exists")) } - if !h.cfg.Emails.RequireVerification { + if !h.cfg.Email.RequireVerification { // Email verification is currently not required and there is no user assigned to the existing email // address. This can happen, when email verification was turned on before, because then the email // address will be assigned to the user only after passcode verification. The email was left unassigned @@ -121,7 +121,7 @@ func (h *EmailHandler) Create(c echo.Context) error { } } else { // The email address has not been registered so far. - if h.cfg.Emails.RequireVerification { + if h.cfg.Email.RequireVerification { // The email address will be assigned to the user only after passcode verification. email = models.NewEmail(nil, newEmailAddress) } else { @@ -140,7 +140,7 @@ func (h *EmailHandler) Create(c echo.Context) error { return fmt.Errorf("failed to create audit log: %w", err) } - if !h.cfg.Emails.RequireVerification { + if !h.cfg.Email.RequireVerification { var evt events.Event if len(user.Emails) >= 1 { diff --git a/backend/handler/email_admin.go b/backend/handler/email_admin.go index 355df36ed..67c09cffe 100644 --- a/backend/handler/email_admin.go +++ b/backend/handler/email_admin.go @@ -100,7 +100,7 @@ func (h *emailAdminHandler) Create(ctx echo.Context) error { return fmt.Errorf("failed to count user emails: %w", err) } - if emailCount >= h.cfg.Emails.MaxNumOfAddresses { + if emailCount >= h.cfg.Email.Limit { return echo.NewHTTPError(http.StatusConflict).SetInternal(errors.New("max number of email addresses reached")) } diff --git a/backend/handler/email_admin_test.go b/backend/handler/email_admin_test.go index 047c0c8ba..094ed5a0c 100644 --- a/backend/handler/email_admin_test.go +++ b/backend/handler/email_admin_test.go @@ -199,7 +199,7 @@ func (s *emailAdminSuite) TestEmailAdminHandler_Create() { for _, currentTest := range tests { s.Run(currentTest.name, func() { cfg := test.DefaultConfig - cfg.Emails.MaxNumOfAddresses = currentTest.maxNumberOfAddresses + cfg.Email.Limit = currentTest.maxNumberOfAddresses e := NewAdminRouter(&cfg, s.Storage, nil) diff --git a/backend/handler/email_test.go b/backend/handler/email_test.go index 632ede120..4fc4e3173 100644 --- a/backend/handler/email_test.go +++ b/backend/handler/email_test.go @@ -169,8 +169,8 @@ func (s *emailSuite) TestEmailHandler_Create() { s.Run(currentTest.name, func() { cfg := test.DefaultConfig cfg.AuditLog.Storage.Enabled = true - cfg.Emails.RequireVerification = currentTest.requiresVerification - cfg.Emails.MaxNumOfAddresses = currentTest.maxNumberOfAddresses + cfg.Email.RequireVerification = currentTest.requiresVerification + cfg.Email.Limit = currentTest.maxNumberOfAddresses e := NewPublicRouter(&cfg, s.Storage, nil, nil) jwkManager, err := jwk.NewDefaultManager(cfg.Secrets.Keys, s.Storage.GetJwkPersister()) s.Require().NoError(err) diff --git a/backend/handler/passcode.go b/backend/handler/passcode.go index d968f8b88..9d082f0f2 100644 --- a/backend/handler/passcode.go +++ b/backend/handler/passcode.go @@ -33,7 +33,7 @@ type PasscodeHandler struct { renderer *mail.Renderer passcodeGenerator crypto.PasscodeGenerator persister persistence.Persister - emailConfig config.Email + emailConfig config.EmailDelivery serviceConfig config.Service TTL int sessionManager session.Manager @@ -58,9 +58,9 @@ func NewPasscodeHandler(cfg *config.Config, persister persistence.Persister, ses renderer: renderer, passcodeGenerator: crypto.NewPasscodeGenerator(), persister: persister, - emailConfig: cfg.Passcode.Email, + emailConfig: cfg.EmailDelivery, serviceConfig: cfg.Service, - TTL: cfg.Passcode.TTL, + TTL: cfg.Email.PasscodeTtl, sessionManager: sessionManager, cfg: cfg, auditLogger: auditLogger, @@ -69,16 +69,16 @@ func NewPasscodeHandler(cfg *config.Config, persister persistence.Persister, ses } func (h *PasscodeHandler) Init(c echo.Context) error { - var body dto.PasscodeInitRequest - if err := (&echo.DefaultBinder{}).BindBody(c, &body); err != nil { + var request dto.PasscodeInitRequest + if err := (&echo.DefaultBinder{}).BindBody(c, &request); err != nil { return dto.ToHttpError(err) } - if err := c.Validate(body); err != nil { + if err := c.Validate(request); err != nil { return dto.ToHttpError(err) } - userId, err := uuid.FromString(body.UserId) + userId, err := uuid.FromString(request.UserId) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, "failed to parse userId as uuid").SetInternal(err) } @@ -103,8 +103,8 @@ func (h *PasscodeHandler) Init(c echo.Context) error { } var emailId uuid.UUID - if body.EmailId != nil { - emailId, err = uuid.FromString(*body.EmailId) + if request.EmailId != nil { + emailId, err = uuid.FromString(*request.EmailId) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, "failed to parse emailId as uuid").SetInternal(err) } @@ -146,7 +146,7 @@ func (h *PasscodeHandler) Init(c echo.Context) error { sessionToken := h.GetSessionToken(c) if sessionToken != nil && sessionToken.Subject() != user.ID.String() { - // if the user is logged in and the requested user in the body does not match the user from the session then sending and finalizing passcodes is not allowed + // if the user is logged in and the requested user in the request does not match the user from the session then sending and finalizing passcodes is not allowed return echo.NewHTTPError(http.StatusForbidden).SetInternal(errors.New("session.userId does not match requested userId")) } @@ -170,8 +170,8 @@ func (h *PasscodeHandler) Init(c echo.Context) error { } passcodeModel := models.Passcode{ ID: passcodeId, - UserId: userId, - EmailID: email.ID, + UserId: &userId, + EmailID: &email.ID, Ttl: h.TTL, Code: string(hashedPasscode), CreatedAt: now, @@ -191,15 +191,17 @@ func (h *PasscodeHandler) Init(c echo.Context) error { } lang := c.Request().Header.Get("Accept-Language") + subject := h.renderer.Translate(lang, "email_subject_login", data) - bodyPlain, err := h.renderer.Render("loginTextMail", lang, data) + + body, err := h.renderer.Render("login_text.tmpl", lang, data) if err != nil { return fmt.Errorf("failed to render email template: %w", err) } webhookData := webhook.EmailSend{ Subject: subject, - BodyPlain: bodyPlain, + BodyPlain: body, ToEmailAddress: email.Address, DeliveredByHanko: true, AcceptLanguage: lang, @@ -219,7 +221,7 @@ func (h *PasscodeHandler) Init(c echo.Context) error { message.SetHeader("Subject", subject) - message.SetBody("text/plain", bodyPlain) + message.SetBody("text/plain", body) err = h.mailer.Send(message) if err != nil { @@ -288,14 +290,14 @@ func (h *PasscodeHandler) Finish(c echo.Context) error { return nil } - user, err := userPersister.Get(passcode.UserId) + userModel, err := userPersister.Get(*passcode.UserId) if err != nil { return fmt.Errorf("failed to get user: %w", err) } lastVerificationTime := passcode.CreatedAt.Add(time.Duration(passcode.Ttl) * time.Second) if lastVerificationTime.Before(startTime) { - err = h.auditLogger.CreateWithConnection(tx, c, models.AuditLogPasscodeLoginFinalFailed, user, fmt.Errorf("timed out passcode")) + err = h.auditLogger.CreateWithConnection(tx, c, models.AuditLogPasscodeLoginFinalFailed, userModel, fmt.Errorf("timed out passcode")) if err != nil { return fmt.Errorf("failed to create audit log: %w", err) } @@ -312,7 +314,7 @@ func (h *PasscodeHandler) Finish(c echo.Context) error { if err != nil { return fmt.Errorf("failed to delete passcode: %w", err) } - err = h.auditLogger.CreateWithConnection(tx, c, models.AuditLogPasscodeLoginFinalFailed, user, fmt.Errorf("max attempts reached")) + err = h.auditLogger.CreateWithConnection(tx, c, models.AuditLogPasscodeLoginFinalFailed, userModel, fmt.Errorf("max attempts reached")) if err != nil { return fmt.Errorf("failed to create audit log: %w", err) } @@ -325,7 +327,7 @@ func (h *PasscodeHandler) Finish(c echo.Context) error { return fmt.Errorf("failed to update passcode: %w", err) } - err = h.auditLogger.CreateWithConnection(tx, c, models.AuditLogPasscodeLoginFinalFailed, user, fmt.Errorf("passcode invalid")) + err = h.auditLogger.CreateWithConnection(tx, c, models.AuditLogPasscodeLoginFinalFailed, userModel, fmt.Errorf("passcode invalid")) if err != nil { return fmt.Errorf("failed to create audit log: %w", err) } @@ -338,12 +340,12 @@ func (h *PasscodeHandler) Finish(c echo.Context) error { return fmt.Errorf("failed to delete passcode: %w", err) } - if passcode.Email.User != nil && passcode.Email.User.ID.String() != user.ID.String() { + if passcode.Email.User != nil && passcode.Email.User.ID.String() != userModel.ID.String() { return echo.NewHTTPError(http.StatusForbidden, "email address has been claimed by another user") } emailExistsForUser := false - for _, email := range user.Emails { + for _, email := range userModel.Emails { emailExistsForUser = email.ID == passcode.Email.ID if emailExistsForUser { break @@ -353,53 +355,53 @@ func (h *PasscodeHandler) Finish(c echo.Context) error { existingSessionToken := h.GetSessionToken(c) // return forbidden when none of these cases matches if !((existingSessionToken == nil && emailExistsForUser) || // normal login: when user logs in and the email used is associated with the user - (existingSessionToken == nil && len(user.Emails) == 0) || // register: when user register and the user has no emails - (existingSessionToken != nil && existingSessionToken.Subject() == user.ID.String())) { // add email through profile: when the user adds an email while having a session and the userIds requested in the passcode and the one in the session matches + (existingSessionToken == nil && len(userModel.Emails) == 0) || // register: when user register and the user has no emails + (existingSessionToken != nil && existingSessionToken.Subject() == userModel.ID.String())) { // add email through profile: when the user adds an email while having a session and the userIds requested in the passcode and the one in the session matches return echo.NewHTTPError(http.StatusForbidden).SetInternal(errors.New("passcode finalization not allowed")) } wasUnverified := false - hasEmails := len(user.Emails) >= 1 // check if we need to trigger a UserCreate webhook or a UserEmailCreate one + hasEmails := len(userModel.Emails) >= 1 // check if we need to trigger a UserCreate webhook or a EmailCreate one if !passcode.Email.Verified { wasUnverified = true // Update email verified status and assign the email address to the user. passcode.Email.Verified = true - passcode.Email.UserID = &user.ID + passcode.Email.UserID = &userModel.ID err = emailPersister.Update(passcode.Email) if err != nil { return fmt.Errorf("failed to update the email verified status: %w", err) } - if user.Emails.GetPrimary() == nil { - primaryEmail := models.NewPrimaryEmail(passcode.Email.ID, user.ID) + if userModel.Emails.GetPrimary() == nil { + primaryEmail := models.NewPrimaryEmail(passcode.Email.ID, userModel.ID) err = primaryEmailPersister.Create(*primaryEmail) if err != nil { return fmt.Errorf("failed to create primary email: %w", err) } - user.Emails = models.Emails{passcode.Email} - user.Emails.SetPrimary(primaryEmail) - err = h.auditLogger.CreateWithConnection(tx, c, models.AuditLogPrimaryEmailChanged, user, nil) + userModel.Emails = models.Emails{passcode.Email} + userModel.SetPrimaryEmail(primaryEmail) + err = h.auditLogger.CreateWithConnection(tx, c, models.AuditLogPrimaryEmailChanged, userModel, nil) if err != nil { return fmt.Errorf("failed to create audit log: %w", err) } } - err = h.auditLogger.CreateWithConnection(tx, c, models.AuditLogEmailVerified, user, nil) + err = h.auditLogger.CreateWithConnection(tx, c, models.AuditLogEmailVerified, userModel, nil) if err != nil { return fmt.Errorf("failed to create audit log: %w", err) } } var emailJwt *dto.EmailJwt - if e := user.Emails.GetPrimary(); e != nil { + if e := userModel.Emails.GetPrimary(); e != nil { emailJwt = dto.JwtFromEmailModel(e) } - token, err := h.sessionManager.GenerateJWT(passcode.UserId, emailJwt) + token, err := h.sessionManager.GenerateJWT(*passcode.UserId, emailJwt) if err != nil { return fmt.Errorf("failed to generate jwt: %w", err) } @@ -417,13 +419,13 @@ func (h *PasscodeHandler) Finish(c echo.Context) error { c.SetCookie(cookie) } - err = h.auditLogger.CreateWithConnection(tx, c, models.AuditLogPasscodeLoginFinalSucceeded, user, nil) + err = h.auditLogger.CreateWithConnection(tx, c, models.AuditLogPasscodeLoginFinalSucceeded, userModel, nil) if err != nil { return fmt.Errorf("failed to create audit log: %w", err) } // notify about email verification result. Last step to prevent a trigger and rollback scenario - if h.cfg.Emails.RequireVerification && wasUnverified { + if h.cfg.Email.RequireVerification && wasUnverified { var evt events.Event if hasEmails { @@ -432,7 +434,7 @@ func (h *PasscodeHandler) Finish(c echo.Context) error { evt = events.UserCreate } - utils.NotifyUserChange(c, tx, h.persister, evt, user.ID) + utils.NotifyUserChange(c, tx, h.persister, evt, userModel.ID) } return c.JSON(http.StatusOK, dto.PasscodeReturn{ diff --git a/backend/handler/passcode_test.go b/backend/handler/passcode_test.go index 3be799781..a094ba921 100644 --- a/backend/handler/passcode_test.go +++ b/backend/handler/passcode_test.go @@ -38,8 +38,8 @@ func (s *passcodeSuite) TestPasscodeHandler_Init() { cfg := func() *config.Config { cfg := &test.DefaultConfig - cfg.Smtp.Host = s.EmailServer.SmtpHost - cfg.Smtp.Port = s.EmailServer.SmtpPort + cfg.EmailDelivery.SMTP.Host = s.EmailServer.SmtpHost + cfg.EmailDelivery.SMTP.Port = s.EmailServer.SmtpPort return cfg } @@ -122,10 +122,12 @@ func (s *passcodeSuite) TestPasscodeHandler_Finish() { hashedPasscode, err := bcrypt.GenerateFromPassword([]byte("123456"), 12) + userId := uuid.FromStringOrNil("b5dd5267-b462-48be-b70d-bcd6f1bbe7a5") + emailId := uuid.FromStringOrNil("51b7c175-ceb6-45ba-aae6-0092221c1b84") passcode := models.Passcode{ ID: uuid.FromStringOrNil("a2383922-dea3-46c8-be17-85b267c0d135"), - UserId: uuid.FromStringOrNil("b5dd5267-b462-48be-b70d-bcd6f1bbe7a5"), - EmailID: uuid.FromStringOrNil("51b7c175-ceb6-45ba-aae6-0092221c1b84"), + UserId: &userId, + EmailID: &emailId, Ttl: 300, Code: string(hashedPasscode), TryCount: 0, @@ -135,8 +137,8 @@ func (s *passcodeSuite) TestPasscodeHandler_Finish() { passcodeWithExpiredTimeout := models.Passcode{ ID: uuid.FromStringOrNil("a2383922-dea3-46c8-be17-85b267c0d135"), - UserId: uuid.FromStringOrNil("b5dd5267-b462-48be-b70d-bcd6f1bbe7a5"), - EmailID: uuid.FromStringOrNil("51b7c175-ceb6-45ba-aae6-0092221c1b84"), + UserId: &userId, + EmailID: &emailId, Ttl: 300, Code: string(hashedPasscode), TryCount: 0, @@ -144,10 +146,11 @@ func (s *passcodeSuite) TestPasscodeHandler_Finish() { UpdatedAt: now, } + emailIdNotAssigned := uuid.FromStringOrNil("7c4473b8-ddcc-480b-b01f-df89e99f74c9") passcodeForNonAssignedEmail := models.Passcode{ ID: uuid.FromStringOrNil("494129d5-76de-4fae-b07d-f2a521e1804d"), - UserId: uuid.FromStringOrNil("b5dd5267-b462-48be-b70d-bcd6f1bbe7a5"), - EmailID: uuid.FromStringOrNil("7c4473b8-ddcc-480b-b01f-df89e99f74c9"), + UserId: &userId, + EmailID: &emailIdNotAssigned, Ttl: 300, Code: string(hashedPasscode), TryCount: 0, diff --git a/backend/handler/password.go b/backend/handler/password.go index bb638a7af..593f75d46 100644 --- a/backend/handler/password.go +++ b/backend/handler/password.go @@ -73,12 +73,12 @@ func (h *PasswordHandler) Set(c echo.Context) error { } pwBytes := []byte(body.Password) - if utf8.RuneCountInString(body.Password) < h.cfg.Password.MinPasswordLength { // use utf8.RuneCountInString, so utf8 characters would count as 1 + if utf8.RuneCountInString(body.Password) < h.cfg.Password.MinLength { // use utf8.RuneCountInString, so utf8 characters would count as 1 err = h.auditLogger.Create(c, models.AuditLogPasswordSetFailed, user, fmt.Errorf("password too short")) if err != nil { return fmt.Errorf("failed to create audit log: %w", err) } - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("password must be at least %d characters long", h.cfg.Password.MinPasswordLength)) + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("password must be at least %d characters long", h.cfg.Password.MinLength)) } if len(pwBytes) > 72 { diff --git a/backend/handler/password_test.go b/backend/handler/password_test.go index a80c747b0..1d934a993 100644 --- a/backend/handler/password_test.go +++ b/backend/handler/password_test.go @@ -36,7 +36,7 @@ func (s *passwordSuite) TestPasswordHandler_Set_Create() { cfg := &test.DefaultConfig cfg.Password.Enabled = true - cfg.Password.MinPasswordLength = 8 + cfg.Password.MinLength = 8 tests := []struct { name string diff --git a/backend/handler/public_router.go b/backend/handler/public_router.go index ed750d030..16714b134 100644 --- a/backend/handler/public_router.go +++ b/backend/handler/public_router.go @@ -4,23 +4,83 @@ import ( "fmt" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" + "github.com/sethvargo/go-limiter" "github.com/sethvargo/go-limiter/httplimit" "github.com/teamhanko/hanko/backend/audit_log" "github.com/teamhanko/hanko/backend/config" "github.com/teamhanko/hanko/backend/crypto/jwk" "github.com/teamhanko/hanko/backend/dto" "github.com/teamhanko/hanko/backend/ee/saml" + "github.com/teamhanko/hanko/backend/flow_api" + "github.com/teamhanko/hanko/backend/flow_api/services" "github.com/teamhanko/hanko/backend/mail" "github.com/teamhanko/hanko/backend/mapper" hankoMiddleware "github.com/teamhanko/hanko/backend/middleware" "github.com/teamhanko/hanko/backend/persistence" + "github.com/teamhanko/hanko/backend/rate_limiter" "github.com/teamhanko/hanko/backend/session" "github.com/teamhanko/hanko/backend/template" ) func NewPublicRouter(cfg *config.Config, persister persistence.Persister, prometheus echo.MiddlewareFunc, authenticatorMetadata mapper.AuthenticatorMetadata) *echo.Echo { e := echo.New() + e.Renderer = template.NewTemplateRenderer() + + e.Static("/flowpilot", "flow_api/static") // TODO: remove! + + emailService, err := services.NewEmailService(*cfg) + passcodeService := services.NewPasscodeService(*cfg, *emailService, persister) + passwordService := services.NewPasswordService(*cfg, persister) + webauthnService := services.NewWebauthnService(*cfg, persister) + + jwkManager, err := jwk.NewDefaultManager(cfg.Secrets.Keys, persister.GetJwkPersister()) + if err != nil { + panic(fmt.Errorf("failed to create jwk manager: %w", err)) + } + sessionManager, err := session.NewManager(jwkManager, *cfg) + if err != nil { + panic(fmt.Errorf("failed to create session generator: %w", err)) + } + + var passcodeRateLimiter limiter.Store + var passwordRateLimiter limiter.Store + var tokenExchangeRateLimiter limiter.Store + if cfg.RateLimiter.Enabled { + passcodeRateLimiter = rate_limiter.NewRateLimiter(cfg.RateLimiter, cfg.RateLimiter.PasscodeLimits) + passwordRateLimiter = rate_limiter.NewRateLimiter(cfg.RateLimiter, cfg.RateLimiter.PasswordLimits) + tokenExchangeRateLimiter = rate_limiter.NewRateLimiter(cfg.RateLimiter, cfg.RateLimiter.TokenLimits) + } + + auditLogger := auditlog.NewLogger(persister, cfg.AuditLog) + + samlService := saml.NewSamlService(cfg, persister) + + flowAPIHandler := flow_api.FlowPilotHandler{ + Persister: persister, + Cfg: *cfg, + PasscodeService: passcodeService, + PasswordService: passwordService, + WebauthnService: webauthnService, + SessionManager: sessionManager, + PasscodeRateLimiter: passcodeRateLimiter, + PasswordRateLimiter: passwordRateLimiter, + TokenExchangeRateLimiter: tokenExchangeRateLimiter, + AuthenticatorMetadata: authenticatorMetadata, + AuditLogger: auditLogger, + SamlService: samlService, + } + + if cfg.Saml.Enabled { + saml.CreateSamlRoutes(e, sessionManager, auditLogger, samlService) + } + + sessionMiddleware := hankoMiddleware.Session(cfg, sessionManager) + + e.POST("/registration", flowAPIHandler.RegistrationFlowHandler) + e.POST("/login", flowAPIHandler.LoginFlowHandler) + e.POST("/profile", flowAPIHandler.ProfileFlowHandler) + e.HideBanner = true g := e.Group("") @@ -59,24 +119,11 @@ func NewPublicRouter(cfg *config.Config, persister persistence.Persister, promet e.Validator = dto.NewCustomValidator() - jwkManager, err := jwk.NewDefaultManager(cfg.Secrets.Keys, persister.GetJwkPersister()) - if err != nil { - panic(fmt.Errorf("failed to create jwk manager: %w", err)) - } - sessionManager, err := session.NewManager(jwkManager, *cfg) - if err != nil { - panic(fmt.Errorf("failed to create session generator: %w", err)) - } - - sessionMiddleware := hankoMiddleware.Session(cfg, sessionManager) - - mailer, err := mail.NewMailer(cfg.Smtp) + mailer, err := mail.NewMailer(cfg.EmailDelivery.SMTP) if err != nil { panic(fmt.Errorf("failed to create mailer: %w", err)) } - auditLogger := auditlog.NewLogger(persister, cfg.AuditLog) - if cfg.Password.Enabled { passwordHandler := NewPasswordHandler(persister, sessionManager, cfg, auditLogger) @@ -105,14 +152,6 @@ func NewPublicRouter(cfg *config.Config, persister persistence.Persister, promet } healthHandler := NewHealthHandler() - webauthnHandler, err := NewWebauthnHandler(cfg, persister, sessionManager, auditLogger, authenticatorMetadata) - if err != nil { - panic(fmt.Errorf("failed to create public webauthn handler: %w", err)) - } - passcodeHandler, err := NewPasscodeHandler(cfg, persister, sessionManager, mailer, auditLogger) - if err != nil { - panic(fmt.Errorf("failed to create public passcode handler: %w", err)) - } health := e.Group("/health") health.GET("/alive", healthHandler.Alive) @@ -128,24 +167,36 @@ func NewPublicRouter(cfg *config.Config, persister persistence.Persister, promet emailHandler := NewEmailHandler(cfg, persister, sessionManager, auditLogger) - webauthn := g.Group("/webauthn") - webauthnRegistration := webauthn.Group("/registration", sessionMiddleware) - webauthnRegistration.POST("/initialize", webauthnHandler.BeginRegistration) - webauthnRegistration.POST("/finalize", webauthnHandler.FinishRegistration) - - webauthnLogin := webauthn.Group("/login") - webauthnLogin.POST("/initialize", webauthnHandler.BeginAuthentication) - webauthnLogin.POST("/finalize", webauthnHandler.FinishAuthentication) - - webauthnCredentials := webauthn.Group("/credentials", sessionMiddleware) - webauthnCredentials.GET("", webauthnHandler.ListCredentials) - webauthnCredentials.PATCH("/:id", webauthnHandler.UpdateCredential) - webauthnCredentials.DELETE("/:id", webauthnHandler.DeleteCredential) + if cfg.Passkey.Enabled { + webauthnHandler, err := NewWebauthnHandler(cfg, persister, sessionManager, auditLogger, authenticatorMetadata) + if err != nil { + panic(fmt.Errorf("failed to create public webauthn handler: %w", err)) + } + webauthn := g.Group("/webauthn") + webauthnRegistration := webauthn.Group("/registration", sessionMiddleware) + webauthnRegistration.POST("/initialize", webauthnHandler.BeginRegistration) + webauthnRegistration.POST("/finalize", webauthnHandler.FinishRegistration) + + webauthnLogin := webauthn.Group("/login") + webauthnLogin.POST("/initialize", webauthnHandler.BeginAuthentication) + webauthnLogin.POST("/finalize", webauthnHandler.FinishAuthentication) + + webauthnCredentials := webauthn.Group("/credentials", sessionMiddleware) + webauthnCredentials.GET("", webauthnHandler.ListCredentials) + webauthnCredentials.PATCH("/:id", webauthnHandler.UpdateCredential) + webauthnCredentials.DELETE("/:id", webauthnHandler.DeleteCredential) + } - passcode := g.Group("/passcode") - passcodeLogin := passcode.Group("/login", webhookMiddlware) - passcodeLogin.POST("/initialize", passcodeHandler.Init) - passcodeLogin.POST("/finalize", passcodeHandler.Finish) + if cfg.Email.Enabled && cfg.Email.UseForAuthentication { + passcodeHandler, err := NewPasscodeHandler(cfg, persister, sessionManager, mailer, auditLogger) + if err != nil { + panic(fmt.Errorf("failed to create public passcode handler: %w", err)) + } + passcode := g.Group("/passcode") + passcodeLogin := passcode.Group("/login", webhookMiddlware) + passcodeLogin.POST("/initialize", passcodeHandler.Init) + passcodeLogin.POST("/finalize", passcodeHandler.Finish) + } email := g.Group("/emails", sessionMiddleware, webhookMiddlware) email.GET("", emailHandler.List) @@ -162,9 +213,5 @@ func NewPublicRouter(cfg *config.Config, persister persistence.Persister, promet tokenHandler := NewTokenHandler(cfg, persister, sessionManager, auditLogger) g.POST("/token", tokenHandler.Validate) - if cfg.Saml.Enabled { - saml.CreateSamlRoutes(e, cfg, persister, sessionManager, auditLogger) - } - return e } diff --git a/backend/handler/thirdparty.go b/backend/handler/thirdparty.go index 015f8fa95..f4437848e 100644 --- a/backend/handler/thirdparty.go +++ b/backend/handler/thirdparty.go @@ -143,13 +143,20 @@ func (h *ThirdPartyHandler) Callback(c echo.Context) error { return thirdparty.ErrorInvalidRequest("could not retrieve user data from provider").WithCause(terr) } - linkingResult, terr := thirdparty.LinkAccount(tx, h.cfg, h.persister, userData, provider.Name(), false) + linkingResult, terr := thirdparty.LinkAccount(tx, h.cfg, h.persister, userData, provider.Name(), false, state.IsFlow) if terr != nil { return terr } accountLinkingResult = linkingResult - token, terr := models.NewToken(linkingResult.User.ID) + emailModel := linkingResult.User.Emails.GetEmailByAddress(userData.Metadata.Email) + identityModel := emailModel.Identities.GetIdentity(provider.Name(), userData.Metadata.Subject) + + token, terr := models.NewToken( + linkingResult.User.ID, + models.TokenForFlowAPI(state.IsFlow), + models.TokenWithIdentityID(identityModel.ID), + models.TokenUserCreated(linkingResult.UserCreated)) if terr != nil { return thirdparty.ErrorServer("could not create token").WithCause(terr) } diff --git a/backend/handler/thirdparty_callback_error_test.go b/backend/handler/thirdparty_callback_error_test.go index d437d4d3b..cc2af7c84 100644 --- a/backend/handler/thirdparty_callback_error_test.go +++ b/backend/handler/thirdparty_callback_error_test.go @@ -362,7 +362,7 @@ func (s *thirdPartySuite) TestThirdPartyHandler_Callback_Error_VerificationRequi }) cfg := s.setUpConfig([]string{"google"}, []string{"https://example.com"}) - cfg.Emails.RequireVerification = true + cfg.Email.RequireVerification = true state, err := thirdparty.GenerateState(cfg, "google", "https://example.com") s.NoError(err) diff --git a/backend/handler/thirdparty_test.go b/backend/handler/thirdparty_test.go index 362c9a2aa..9261bc6af 100644 --- a/backend/handler/thirdparty_test.go +++ b/backend/handler/thirdparty_test.go @@ -56,58 +56,50 @@ func (s *thirdPartySuite) setUpHandler(cfg *config.Config) *ThirdPartyHandler { func (s *thirdPartySuite) setUpConfig(enabledProviders []string, allowedRedirectURLs []string) *config.Config { s.T().Helper() - cfg := &config.Config{ - ThirdParty: config.ThirdParty{ - Providers: config.ThirdPartyProviders{ - Apple: config.ThirdPartyProvider{ - Enabled: false, - ClientID: "fakeClientID", - Secret: "fakeClientSecret", - AllowLinking: true, - }, - Google: config.ThirdPartyProvider{ - Enabled: false, - ClientID: "fakeClientID", - Secret: "fakeClientSecret", - AllowLinking: true, - }, - GitHub: config.ThirdPartyProvider{ - Enabled: false, - ClientID: "fakeClientID", - Secret: "fakeClientSecret", - AllowLinking: true, - }, - Discord: config.ThirdPartyProvider{ - Enabled: false, - ClientID: "fakeClientID", - Secret: "fakeClientSecret", - AllowLinking: true, - }, - Microsoft: config.ThirdPartyProvider{ - Enabled: false, - ClientID: "fakeClientID", - Secret: "fakeClientSecret", - AllowLinking: false, - }, + cfg := config.DefaultConfig() + cfg.ThirdParty = config.ThirdParty{ + Providers: config.ThirdPartyProviders{ + Apple: config.ThirdPartyProvider{ + Enabled: false, + ClientID: "fakeClientID", + Secret: "fakeClientSecret", + AllowLinking: true, + }, + Google: config.ThirdPartyProvider{ + Enabled: false, + ClientID: "fakeClientID", + Secret: "fakeClientSecret", + AllowLinking: true, + }, + GitHub: config.ThirdPartyProvider{ + Enabled: false, + ClientID: "fakeClientID", + Secret: "fakeClientSecret", + AllowLinking: true, + }, + Discord: config.ThirdPartyProvider{ + Enabled: false, + ClientID: "fakeClientID", + Secret: "fakeClientSecret", + AllowLinking: true, + }, + Microsoft: config.ThirdPartyProvider{ + Enabled: false, + ClientID: "fakeClientID", + Secret: "fakeClientSecret", + AllowLinking: false, }, - ErrorRedirectURL: "https://error.test.example", - RedirectURL: "https://api.test.example/callback", - AllowedRedirectURLS: allowedRedirectURLs, - }, - Secrets: config.Secrets{ - Keys: []string{"thirty-two-byte-long-test-secret"}, - }, - AuditLog: config.AuditLog{ - Storage: config.AuditLogStorage{Enabled: true}, - }, - Emails: config.Emails{ - MaxNumOfAddresses: 5, - }, - Account: config.Account{ - AllowSignup: true, }, + ErrorRedirectURL: "https://error.test.example", + RedirectURL: "https://api.test.example/callback", + AllowedRedirectURLS: allowedRedirectURLs, } + cfg.AuditLog.Storage.Enabled = true + cfg.AuditLog.Mask = false + cfg.Email.Limit = 5 + cfg.Account.AllowSignup = true + for _, provider := range enabledProviders { switch provider { case "apple": diff --git a/backend/handler/user.go b/backend/handler/user.go index 1cdaddcbc..e360b4d43 100644 --- a/backend/handler/user.go +++ b/backend/handler/user.go @@ -74,7 +74,7 @@ func (h *UserHandler) Create(c echo.Context) error { return echo.NewHTTPError(http.StatusConflict).SetInternal(errors.New(fmt.Sprintf("user with email %s already exists", body.Email))) } - if !h.cfg.Emails.RequireVerification { + if !h.cfg.Email.RequireVerification { // Assign the email address to the user because it's currently unassigned and email verification is turned off. email.UserID = &newUser.ID @@ -85,7 +85,7 @@ func (h *UserHandler) Create(c echo.Context) error { } } else { // The email address does not exist, create a new one. - if h.cfg.Emails.RequireVerification { + if h.cfg.Email.RequireVerification { // The email can only be assigned to the user via passcode verification. email = models.NewEmail(nil, body.Email) } else { @@ -98,7 +98,7 @@ func (h *UserHandler) Create(c echo.Context) error { } } - if !h.cfg.Emails.RequireVerification { + if !h.cfg.Email.RequireVerification { primaryEmail := models.NewPrimaryEmail(email.ID, newUser.ID) err = h.persister.GetPrimaryEmailPersisterWithConnection(tx).Create(*primaryEmail) if err != nil { @@ -157,7 +157,7 @@ func (h *UserHandler) Create(c echo.Context) error { EmailID: email.ID, } - if !h.cfg.Emails.RequireVerification { + if !h.cfg.Email.RequireVerification { err = utils.TriggerWebhooks(c, events.UserCreate, admin.FromUserModel(newUser)) if err != nil { c.Logger().Warn(err) diff --git a/backend/handler/webauthn.go b/backend/handler/webauthn.go index b4e496bf5..5c9bb9f0c 100644 --- a/backend/handler/webauthn.go +++ b/backend/handler/webauthn.go @@ -59,11 +59,11 @@ func NewWebauthnHandler(cfg *config.Config, persister persistence.Persister, ses Debug: false, Timeouts: webauthn.TimeoutsConfig{ Login: webauthn.TimeoutConfig{ - Timeout: time.Duration(cfg.Webauthn.Timeout) * time.Millisecond, + Timeout: time.Duration(cfg.Webauthn.Timeouts.Login) * time.Millisecond, Enforce: true, }, Registration: webauthn.TimeoutConfig{ - Timeout: time.Duration(cfg.Webauthn.Timeout) * time.Millisecond, + Timeout: time.Duration(cfg.Webauthn.Timeouts.Registration) * time.Millisecond, Enforce: true, }, }, @@ -111,9 +111,10 @@ func (h *WebauthnHandler) BeginRegistration(c echo.Context) error { webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{ RequireResidentKey: &t, ResidentKey: protocol.ResidentKeyRequirementRequired, - UserVerification: protocol.UserVerificationRequirement(h.cfg.Webauthn.UserVerification), + UserVerification: protocol.UserVerificationRequirement(h.cfg.Passkey.UserVerification), }), - webauthn.WithConveyancePreference(protocol.PreferDirectAttestation), + + webauthn.WithConveyancePreference(protocol.ConveyancePreference(h.cfg.Passkey.AttestationPreference)), // don't set the excludeCredentials list, so an already registered device can be re-registered ) @@ -271,7 +272,7 @@ func (h *WebauthnHandler) BeginAuthentication(c echo.Context) error { if len(webauthnUser.WebAuthnCredentials()) > 0 { options, sessionData, err = h.webauthn.BeginLogin( webauthnUser, - webauthn.WithUserVerification(protocol.UserVerificationRequirement(h.cfg.Webauthn.UserVerification)), + webauthn.WithUserVerification(protocol.UserVerificationRequirement(h.cfg.Passkey.UserVerification)), ) if err != nil { return fmt.Errorf("failed to create webauthn assertion options: %w", err) @@ -281,7 +282,7 @@ func (h *WebauthnHandler) BeginAuthentication(c echo.Context) error { if options == nil && sessionData == nil { var err error options, sessionData, err = h.webauthn.BeginDiscoverableLogin( - webauthn.WithUserVerification(protocol.UserVerificationRequirement(h.cfg.Webauthn.UserVerification)), + webauthn.WithUserVerification(protocol.UserVerificationRequirement(h.cfg.Passkey.UserVerification)), ) if err != nil { return fmt.Errorf("failed to create webauthn assertion options for discoverable login: %w", err) diff --git a/backend/json_schema/hanko.config.json b/backend/json_schema/hanko.config.json index 62ee1ca7c..b0a4f93e8 100644 --- a/backend/json_schema/hanko.config.json +++ b/backend/json_schema/hanko.config.json @@ -7,11 +7,12 @@ "properties": { "allow_deletion": { "type": "boolean", - "description": "Allow Deletion indicates if a user can perform self-service deletion", + "description": "`allow_deletion` determines whether users can delete their accounts.", "default": false }, "allow_signup": { "type": "boolean", + "description": "`allow_signup` determines whether users are able to create new accounts.", "default": true } }, @@ -85,10 +86,18 @@ "AuditLog": { "properties": { "console_output": { - "$ref": "#/$defs/AuditLogConsole" + "$ref": "#/$defs/AuditLogConsole", + "title": "console_output", + "description": "`console_output` controls audit log console output." + }, + "mask": { + "type": "boolean", + "description": "`mask` determines whether sensitive information (usernames, emails) should be masked in the audit log output.\n\nThis configuration applies to logs written to the console as well as persisted logs.", + "default": true }, "storage": { - "$ref": "#/$defs/AuditLogStorage" + "$ref": "#/$defs/AuditLogStorage", + "description": "`storage` controls audit log retention." } }, "additionalProperties": false, @@ -98,6 +107,7 @@ "properties": { "enabled": { "type": "boolean", + "description": "`enabled` controls whether audit log output on the console is enabled or disabled.", "default": true }, "output": { @@ -106,6 +116,7 @@ "stdout", "stderr" ], + "description": "`output` determines the output stream audit logs are sent to.", "default": "stdout" } }, @@ -116,6 +127,7 @@ "properties": { "enabled": { "type": "boolean", + "description": "`enabled` controls whether audit log should be retained (i.e. persisted).", "default": false } }, @@ -124,88 +136,156 @@ }, "Config": { "properties": { - "server": { - "$ref": "#/$defs/Server" + "account": { + "$ref": "#/$defs/Account", + "title": "account", + "description": "`account` configures settings related to user accounts." }, - "webauthn": { - "$ref": "#/$defs/WebauthnSettings" + "audit_log": { + "$ref": "#/$defs/AuditLog", + "title": "audit_log", + "description": "`audit_log` configures output and storage modalities of audit logs." }, - "smtp": { - "$ref": "#/$defs/SMTP" + "convert_legacy_config": { + "type": "boolean", + "description": "`convert_legacy_config`, if set to `true`, automatically copies the set values of deprecated configuration\noptions, to new ones. If set to `false`, these values have to be set manually if non-default values should be\nused.", + "default": false + }, + "database": { + "$ref": "#/$defs/Database", + "title": "database", + "description": "`database configures database connection settings." + }, + "debug": { + "type": "boolean", + "description": "`debug`, if set to `true`, adds additional debugging information to flow API responses." + }, + "email": { + "$ref": "#/$defs/Email", + "title": "email", + "description": "`email` configures how email addresses of user accounts are acquired and used." }, "email_delivery": { - "$ref": "#/$defs/EmailDelivery" + "$ref": "#/$defs/EmailDelivery", + "title": "email_delivery", + "description": "`email_delivery` configures how outgoing mails are delivered." + }, + "emails": { + "$ref": "#/$defs/Emails", + "title": "emails", + "description": "Deprecated. See child properties for suggested replacements." + }, + "log": { + "$ref": "#/$defs/LoggerConfig", + "title": "log", + "description": "`log` configures application logging." }, "passcode": { - "$ref": "#/$defs/Passcode" + "$ref": "#/$defs/Passcode", + "title": "passcode", + "description": "Deprecated. See child properties for suggested replacements." + }, + "passkey": { + "$ref": "#/$defs/Passkey", + "title": "passkey", + "description": "`passkey` configures how passkeys are acquired and used." }, "password": { - "$ref": "#/$defs/Password" + "$ref": "#/$defs/Password", + "title": "password", + "description": "`password` configures how passwords are acquired and used." }, - "database": { - "$ref": "#/$defs/Database" + "rate_limiter": { + "$ref": "#/$defs/RateLimiter", + "title": "rate_limiter", + "description": "`rate_limiter` configures rate limits for rate limited API operations and storage modalities for rate limit data." + }, + "saml": { + "$ref": "#/$defs/Saml", + "title": "saml", + "description": "`saml` configures modalities of SAML (Security Assertion Markup Language) SSO authentication and SAML identity\nproviders." }, "secrets": { - "$ref": "#/$defs/Secrets" + "$ref": "#/$defs/Secrets", + "title": "secrets", + "description": "`secrets` configures the keys used for cryptographically signing tokens issued by the API." + }, + "server": { + "$ref": "#/$defs/Server", + "title": "server", + "description": "`server` configures address and CORS settings of the public and admin API." }, "service": { - "$ref": "#/$defs/Service" + "$ref": "#/$defs/Service", + "title": "service", + "description": "`service` configures general service information." }, "session": { - "$ref": "#/$defs/Session" - }, - "audit_log": { - "$ref": "#/$defs/AuditLog" + "$ref": "#/$defs/Session", + "title": "session", + "description": "`session` configures settings for session JWTs and Cookies issued by the API." }, - "emails": { - "$ref": "#/$defs/Emails" - }, - "rate_limiter": { - "$ref": "#/$defs/RateLimiter" + "smtp": { + "$ref": "#/$defs/SMTP", + "title": "smtp", + "description": "Deprecated. Use `email_delivery.smtp` instead." }, "third_party": { - "$ref": "#/$defs/ThirdParty" + "$ref": "#/$defs/ThirdParty", + "title": "third_party", + "description": "`third_party` configures the modalities of third party OAuth/OIDC based authentication and available identity\nproviders." }, - "log": { - "$ref": "#/$defs/LoggerConfig" - }, - "account": { - "$ref": "#/$defs/Account" + "username": { + "$ref": "#/$defs/Username", + "title": "username", + "description": "`username` configures how usernames of user accounts are acquired and used." }, - "saml": { - "$ref": "#/$defs/Saml" + "webauthn": { + "$ref": "#/$defs/WebauthnSettings", + "title": "webauthn", + "description": "`webauthn` configures general settings for communication with the WebAuthentication API." }, "webhooks": { - "$ref": "#/$defs/WebhookSettings" + "$ref": "#/$defs/WebhookSettings", + "title": "webhooks", + "description": "`webhooks` configures HTTP-based callbacks for specific events occurring in the system." } }, "additionalProperties": false, "type": "object", - "required": [ - "passcode", - "database", - "secrets", - "service" - ], "description": "Config is the central configuration type" }, "Cookie": { "properties": { - "name": { + "domain": { "type": "string", + "description": "`domain` is the domain the cookie will be bound to. Works for subdomains, but not cross-domain.\nSee the `session.enable_auth_token_header` configuration instead if the API and the client application run on\ndifferent domains.", "default": "hanko" }, - "domain": { - "type": "string" - }, "http_only": { - "type": "boolean" + "type": "boolean", + "description": "`http_only` determines whether cookies are HTTP only or accessible by Javascript.", + "default": true + }, + "name": { + "type": "string", + "description": "`name` is the name of the cookie.", + "default": "hanko" }, "same_site": { - "type": "string" + "type": "string", + "enum": [ + "strict", + "lax", + "none" + ], + "description": "`same_site` controls whether a cookie is sent with cross-site requests.\nSee [here](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value) for\nmore details.", + "default": "strict" }, "secure": { - "type": "boolean" + "type": "boolean", + "description": "`secure` indicates whether the cookie is sent to the server only when a request is made with the https: scheme\n(except on localhost).\n\nNOTE: `secure` must be set to `false` when working on `localhost` and with the Safari browser because it does\nnot store secure cookies on `localhost`.", + "default": true } }, "additionalProperties": false, @@ -218,88 +298,122 @@ "type": "string" }, "type": "array", - "description": "AllowOrigins determines the value of the Access-Control-Allow-Origin\nresponse header. This header defines a list of origins that may access the\nresource. The wildcard characters '*' and '?' are supported and are\nconverted to regex fragments '.*' and '.' accordingly." + "title": "allow_origins", + "description": "`allow_origins` determines the value of the Access-Control-Allow-Origin\nresponse header. This header defines a list of [origins](https://developer.mozilla.org/en-US/docs/Glossary/Origin)\nthat may access the resource.\n\nThe wildcard characters `*` and `?` are supported and are converted to regex fragments `.*` and `.` accordingly.", + "default": [ + "http://localhost:8888" + ] }, "unsafe_wildcard_origin_allowed": { "type": "boolean", - "description": "UnsafeWildcardOriginWithAllowCredentials UNSAFE/INSECURE: allows wildcard '*' origin to be used with AllowCredentials\nflag. In that case we consider any origin allowed and send it back to the client with `Access-Control-Allow-Origin` header.\n\nThis is INSECURE and potentially leads to [cross-origin](https://portswigger.net/research/exploiting-cors-misconfigurations-for-bitcoins-and-bounties)\nattacks. See: https://github.com/labstack/echo/issues/2400 for discussion on the subject.\n\nOptional. Default value is false.", + "title": "unsafe_wildcard_origin_allowed", + "description": "`unsafe_wildcard_origin_allowed` allows a wildcard `*` origin to be used with AllowCredentials\nflag. In that case we consider any origin allowed and send it back to the client in an `Access-Control-Allow-Origin` header.\n\nThis is INSECURE and potentially leads to [cross-origin](https://portswigger.net/research/exploiting-cors-misconfigurations-for-bitcoins-and-bounties)\nattacks. See also https://github.com/labstack/echo/issues/2400 for discussion on the subject.\n\nOptional. Default value is `false`.", "default": false } }, "additionalProperties": false, - "type": "object", - "required": [ - "allow_origins" - ] + "type": "object" }, "Database": { - "oneOf": [ - { - "required": [ - "user", - "password", - "host", - "port", - "dialect" - ], - "title": "config" - }, - { - "required": [ - "url" - ], - "title": "url" - } - ], "properties": { "database": { "type": "string", + "description": "`database` determines the name of the database schema to use.", "default": "hanko" }, - "user": { - "type": "string" - }, - "password": { - "type": "string" - }, - "host": { - "type": "string" - }, - "port": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "integer" - } - ] - }, "dialect": { "type": "string", "enum": [ "postgres", "mysql", + "mariadb", "cockroach" - ] + ], + "description": "`dialect` is the name of the database system to use.", + "default": "postgres" + }, + "host": { + "type": "string", + "description": "`host` is the host the database system is running on.", + "default": "localhost" + }, + "password": { + "type": "string", + "description": "`password` is the password for the database user to use for connecting to the database.", + "default": "hanko" + }, + "port": { + "type": "string", + "description": "`port` is the port the database system is running on.", + "default": "5432" }, "url": { - "type": "string" + "type": "string", + "description": "`url` is a datasource connection string. It can be used instead of the rest of the database configuration\noptions. If this `url` is set then it is prioritized, i.e. the rest of the options, if set, have no effect.\n\nSchema: `dialect://username:password@host:port/database`", + "examples": [ + "postgres://hanko:hanko@localhost:5432/hanko" + ] + }, + "user": { + "type": "string", + "description": "`user` is the database user to use for connecting to the database.", + "default": "hanko" } }, "additionalProperties": false, - "type": "object", - "description": "Database connection settings" + "type": "object" }, "Email": { "properties": { - "from_address": { - "type": "string", - "default": "passcode@hanko.io" + "acquire_on_login": { + "type": "boolean", + "description": "`acquire_on_login` determines whether users, provided that they do not already have registered an email,\n\tare prompted to provide an email on login.", + "default": false }, - "from_name": { - "type": "string", - "default": "Hanko" + "acquire_on_registration": { + "type": "boolean", + "description": "`acquire_on_registration` determines whether users are prompted to provide an email on registration.", + "default": true + }, + "enabled": { + "type": "boolean", + "description": "`enabled` determines whether emails are enabled.", + "default": true + }, + "limit": { + "type": "integer", + "description": "'limit' determines the maximum number of emails a user can register.", + "default": 100 + }, + "max_length": { + "type": "integer", + "description": "`max_length` specifies the maximum allowed length of an email address.", + "default": 100 + }, + "optional": { + "type": "boolean", + "description": "`optional` determines whether users must provide an email when prompted.\nThere must always be at least one email address associated with an account. The primary email address cannot be\ndeleted if emails are required (`optional`: false`).", + "default": true + }, + "passcode_ttl": { + "type": "integer", + "description": "`passcode_ttl` specifies, in seconds, how long a passcode is valid for.", + "default": 300 + }, + "require_verification": { + "type": "boolean", + "description": "`require_verification` determines whether newly created emails must be verified by providing a passcode sent\nto respective address.", + "default": true + }, + "use_as_login_identifier": { + "type": "boolean", + "description": "`use_as_login_identifier` determines whether emails can be used as an identifier on login.", + "default": true + }, + "use_for_authentication": { + "type": "boolean", + "description": "`user_for_authentication` determines whether users can log in by providing an email address and subsequently\nproviding a passcode sent to the given email address.", + "default": true } }, "additionalProperties": false, @@ -309,23 +423,38 @@ "properties": { "enabled": { "type": "boolean", + "description": "`enabled` determines whether the API delivers emails.\nDisable if you want to send the emails yourself. To do so you must subscribe to the `email.create` webhook event.", "default": true + }, + "from_address": { + "type": "string", + "description": "`from_address` configures the sender address of emails sent to users.", + "default": "noreply@hanko.io" + }, + "from_name": { + "type": "string", + "description": "`from_name` configures the sender name of emails sent to users.", + "default": "Hanko" + }, + "smtp": { + "$ref": "#/$defs/SMTP", + "title": "smtp", + "description": "`SMTP` contains the SMTP server settings for sending mails." } }, "additionalProperties": false, - "type": "object", - "required": [ - "enabled" - ] + "type": "object" }, "Emails": { "properties": { "require_verification": { "type": "boolean", + "description": "Deprecated. Use `email.require_verification` instead.", "default": true }, "max_num_of_addresses": { "type": "integer", + "description": "Deprecated. Use `email.limit` instead.", "default": 5 } }, @@ -357,7 +486,8 @@ "type": "boolean" }, "attribute_map": { - "$ref": "#/$defs/AttributeMap" + "$ref": "#/$defs/AttributeMap", + "title": "attribute_map" } }, "additionalProperties": false, @@ -367,6 +497,7 @@ "properties": { "log_health_and_metrics": { "type": "boolean", + "description": "`log_health_and_metrics` determines whether requests of the `/health` and `/metrics` endpoints are logged.", "default": true } }, @@ -405,15 +536,114 @@ "Passcode": { "properties": { "email": { - "$ref": "#/$defs/Email" + "$ref": "#/$defs/PasscodeEmail", + "description": "Deprecated. See child properties for suggested replacements." }, "ttl": { "type": "integer", + "description": "Deprecated. Use `email.passcode_ttl` instead.", "default": 300 }, "smtp": { "$ref": "#/$defs/SMTP", - "description": "Deprecated: Use root level Smtp instead" + "description": "Deprecated. Use `email_delivery.smtp` instead." + } + }, + "additionalProperties": false, + "type": "object" + }, + "PasscodeEmail": { + "properties": { + "from_address": { + "type": "string", + "description": "Deprecated. Use `email_delivery.from_address` instead.", + "default": "passcode@hanko.io" + }, + "from_name": { + "type": "string", + "description": "Deprecated. Use `email_delivery.from_name` instead.", + "default": "Hanko" + } + }, + "additionalProperties": false, + "type": "object" + }, + "Passkey": { + "properties": { + "acquire_on_registration": { + "type": "string", + "enum": [ + "always", + "conditional", + "never" + ], + "description": "`acquire_on_registration` configures how users are prompted creating a passkey on registration.", + "default": "always", + "meta:enum": { + "always": "Indicates that users are always prompted to create a passkey on registration.", + "conditional": "Indicates that users are prompted to create a passkey on registration as long as the user does\n\t\t\t\t\t\tnot have a password.\n\n\t\t\t\t\t\tIf passwords are also conditionally acquired on registration, then users are given a choice as\n\t\t\t\t\t\tto what type of credential to create.", + "never": "Indicates that users are never prompted to create a passkey on registration." + } + }, + "acquire_on_login": { + "type": "string", + "enum": [ + "always", + "conditional", + "never" + ], + "description": "`acquire_on_login` configures how users are prompted creating a passkey on login.", + "default": "always", + "meta:enum": { + "always": "Indicates that users are always prompted to create a passkey on login\n\t\t\t\t\tprovided that they do not already have a passkey.", + "conditional": "Indicates that users are prompted to create a passkey on login provided that\n\t\t\t\t\t\tthey do not already have a passkey and do not have a password.\n\n\t\t\t\t\t\tIf passkeys are also conditionally acquired on login then users are given a choice as to what\n\t\t\t\t\t\ttype of credential to register.", + "never": "Indicates that users are never prompted to create a passkey on login." + } + }, + "attestation_preference": { + "type": "string", + "enum": [ + "direct", + "indirect", + "none" + ], + "description": "`attestation_preference` is used to specify the preference regarding attestation conveyance during\ncredential generation.", + "default": "direct", + "meta:enum": { + "direct": "Indicates that the Relying Party wants to receive the attestation statement as generated by\n\t\t\t\t\tthe authenticator.", + "indirect": "Indicates that the Relying Party prefers an attestation conveyance yielding verifiable\n\t\t\t\t\tattestation statements, but allows the client to decide how to obtain such attestation statements.", + "none": "Indicates that the Relying Party is not interested in authenticator attestation." + } + }, + "enabled": { + "type": "boolean", + "description": "`enabled` determines whether users can create or authenticate with passkeys.", + "default": true + }, + "limit": { + "type": "integer", + "description": "`limit` defines the maximum number of passkeys a user can have.", + "default": 100 + }, + "optional": { + "type": "boolean", + "description": "`optional` determines whether users must create a passkey when prompted. The last remaining passkey cannot be\ndeleted if passkeys are required (`optional: false`).\n\nIt also takes part in determining the order of password and passkey acquisition\non login and registration (see also `acquire_on_login` and `acquire_on_registration`): if one credential type is\nrequired (`optional: false`) then that one takes precedence, i.e. is acquired first.", + "default": true + }, + "user_verification": { + "type": "string", + "enum": [ + "required", + "preferred", + "discouraged" + ], + "description": "`user_verification` specifies the requirements regarding local authorization with an authenticator through\n various authorization gesture modalities; for example, through a touch plus pin code,\n password entry, or biometric recognition.\n\nThe setting applies to both WebAuthn registration and authentication ceremonies.", + "default": "preferred", + "meta:enum": { + "discouraged": "Indicates that no user verification should be performed.", + "preferred": "Indicates that user verification is preferred but will not fail the operation if no\n\t\t\t\t\t\tuser verification was performed.", + "required": "Indicates that user verification is always required." + } } }, "additionalProperties": false, @@ -421,13 +651,60 @@ }, "Password": { "properties": { + "acquire_on_registration": { + "type": "string", + "enum": [ + "always", + "conditional", + "never" + ], + "description": "`acquire_on_registration` configures how users are prompted creating a password on registration.", + "default": "never", + "meta:enum": { + "always": "Indicates that users are always prompted to create a password on registration.", + "conditional": "Indicates that users are prompted to create a password on registration as long as the user does\n\t\t\t\t\t\tnot have a passkey.\n\n\t\t\t\t\t\tIf passkeys are also conditionally acquired on registration, then users are given a choice as\n\t\t\t\t\t\tto what type of credential to register.", + "never": "Indicates that users are never prompted to create a password on registration." + } + }, + "acquire_on_login": { + "type": "string", + "enum": [ + "always", + "conditional", + "never" + ], + "description": "`acquire_on_login` configures how users are prompted creating a password on login.", + "default": "always", + "meta:enum": { + "always": "Indicates that users are always prompted to create a password on login\n\t\t\t\t\tprovided that they do not already have a password.", + "conditional": "Indicates that users are prompted to create a password on login provided that\n\t\t\t\t\t\tthey do not already have a password and do not have a passkey.\n\n\t\t\t\t\t\tIf passkeys are also conditionally acquired on login then users are given a choice as to what\n\t\t\t\t\t\ttype of credential to register.", + "never": "Indicates that users are never prompted to create a password on login." + } + }, "enabled": { "type": "boolean", + "description": "`enabled` determines whether passwords are enabled or disabled.", "default": false }, + "min_length": { + "type": "integer", + "description": "`min_length` determines the minimum password length.", + "default": 8 + }, "min_password_length": { "type": "integer", + "description": "Deprecated. Use `min_length` instead.", "default": 8 + }, + "optional": { + "type": "boolean", + "description": "`optional` determines whether users must set a password when prompted. The password cannot be deleted if\npasswords are required (`optional: false`).\n\nIt also takes part in determining the order of password and passkey acquisition\non login and registration (see also `acquire_on_login` and `acquire_on_registration`): if one credential type is\nrequired (`optional: false`) then that one takes precedence, i.e. is acquired first.", + "default": false + }, + "recovery": { + "type": "boolean", + "description": "`recovery` determines whether users can start a recovery process, e.g. in case of a forgotten password.", + "default": true } }, "additionalProperties": false, @@ -437,6 +714,7 @@ "properties": { "enabled": { "type": "boolean", + "description": "`enabled` controls whether rate limiting is enabled or disabled.", "default": true }, "store": { @@ -445,19 +723,24 @@ "in_memory", "redis" ], + "description": "`store` sets the store for the rate limiter. When you have multiple instances of Hanko running, it is recommended to use\n the `redis` store because otherwise your instances each have their own states.", "default": "in_memory" }, "redis_config": { - "$ref": "#/$defs/RedisConfig" + "$ref": "#/$defs/RedisConfig", + "description": "`redis_config` configures connection to a redis instance.\nRequired if `store` is set to `redis`" }, "passcode_limits": { - "$ref": "#/$defs/RateLimits" + "$ref": "#/$defs/RateLimits", + "description": "`passcode_limits` controls rate limits for passcode operations." }, "password_limits": { - "$ref": "#/$defs/RateLimits" + "$ref": "#/$defs/RateLimits", + "description": "`password_limits` controls rate limits for password login operations." }, "token_limits": { - "$ref": "#/$defs/RateLimits" + "$ref": "#/$defs/RateLimits", + "description": "`token_limits` controls rate limits for token exchange operations." } }, "additionalProperties": false, @@ -466,10 +749,14 @@ "RateLimits": { "properties": { "tokens": { - "type": "integer" + "type": "integer", + "description": "`tokens` determines how many operations/requests can occur in the given `interval`.", + "default": 3 }, "interval": { - "type": "integer" + "type": "string", + "description": "`interval` determines when to reset the token interval.\nIt must be a (possibly signed) sequence of decimal\nnumbers, each with optional fraction and a unit suffix, such as \"300ms\", \"-1.5h\" or \"2h45m\".\nValid time units are \"ns\", \"us\" (or \"µs\"), \"ms\", \"s\", \"m\", \"h\".", + "default": "1m" } }, "additionalProperties": false, @@ -483,10 +770,11 @@ "properties": { "address": { "type": "string", - "description": "Address of redis in the form of host[:port][/database]" + "description": "`address` is the address of the redis instance in the form of `host[:port][/database]`." }, "password": { - "type": "string" + "type": "string", + "description": "`password` is the password for the redis instance." } }, "additionalProperties": false, @@ -497,16 +785,15 @@ }, "RelyingParty": { "properties": { - "id": { - "type": "string", - "default": "localhost" - }, "display_name": { "type": "string", + "description": "`display_name` is the service's name that some WebAuthn Authenticators will display to the user during registration\nand authentication ceremonies.", "default": "Hanko Authentication Service" }, - "icon": { - "type": "string" + "id": { + "type": "string", + "description": "`id` is the [effective domain](https://html.spec.whatwg.org/multipage/browsers.html#concept-origin-effective-domain)\nthe passkey/WebAuthn credentials will be bound to.", + "default": "localhost" }, "origins": { "items": { @@ -514,6 +801,7 @@ }, "type": "array", "minItems": 1, + "description": "`origins` is a list of origins for which passkeys/WebAuthn credentials will be accepted by the server. Must\ninclude the protocol and can only be the effective domain, or a registrable domain suffix of the effective\ndomain, as specified in the [`id`](#id). Except for `localhost`, the protocol **must** always be `https` for\npasskeys/WebAuthn to work. IP Addresses will not work.\n\nFor an Android application the origin must be the base64 url encoded SHA256 fingerprint of the signing\ncertificate.", "default": [ "http://localhost:8888" ] @@ -529,14 +817,8 @@ "type": "string" }, "port": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "integer" - } - ] + "type": "string", + "default": "465" }, "user": { "type": "string" @@ -547,9 +829,6 @@ }, "additionalProperties": false, "type": "object", - "required": [ - "host" - ], "description": "SMTP Server Settings for sending passcodes" }, "Saml": { @@ -574,7 +853,8 @@ "type": "array" }, "options": { - "$ref": "#/$defs/Options" + "$ref": "#/$defs/Options", + "title": "options" }, "identity_providers": { "items": { @@ -590,40 +870,44 @@ "properties": { "keys": { "items": { - "type": "string" + "type": "string", + "minLength": 16, + "title": "keys" }, "type": "array", "minItems": 1, - "description": "Keys secrets are used to en- and decrypt the JWKs which get used to sign the JWTs.\nFor every key a JWK is generated, encrypted with the key and persisted in the database.\n\nYou can use this list for key rotation: add a new key to the beginning of the list and the corresponding\nJWK will then be used for signing JWTs. All tokens signed with the previous JWK(s) will still\nbe valid until they expire. Removing a key from the list does not remove the corresponding\ndatabase record. If you remove a key, you also have to remove the database record, otherwise\napplication startup will fail.\n\nEach key must be at least 16 characters long." + "description": "`keys` are used to en- and decrypt the JWKs which get used to sign the JWTs issued by the API.\nFor every key a JWK is generated, encrypted with the key and persisted in the database.\n\nYou can use this list for key rotation: add a new key to the beginning of the list and the corresponding\nJWK will then be used for signing JWTs. All tokens signed with the previous JWK(s) will still\nbe valid until they expire. Removing a key from the list does not remove the corresponding\ndatabase record. If you remove a key, you also have to remove the database record, otherwise\napplication startup will fail." } }, "additionalProperties": false, - "type": "object", - "required": [ - "keys" - ] + "type": "object" }, "Server": { "properties": { "public": { - "$ref": "#/$defs/ServerSettings" + "$ref": "#/$defs/ServerSettings", + "title": "public", + "description": "`public` contains the server configuration for the public API." }, "admin": { - "$ref": "#/$defs/ServerSettings" + "$ref": "#/$defs/ServerSettings", + "title": "admin", + "description": "`admin` contains the server configuration for the admin API." } }, "additionalProperties": false, - "type": "object", - "description": "Server contains the setting for the public and admin server" + "type": "object" }, "ServerSettings": { "properties": { "address": { "type": "string", - "description": "The Address to listen on in the form of host:port\nSee net.Dial for details of the address format." + "description": "`address` is the address of the server to listen on in the form of host:port.\n\nSee [net.Dial](https://pkg.go.dev/net#Dial) for details of the address format." }, "cors": { - "$ref": "#/$defs/Cors" + "$ref": "#/$defs/Cors", + "title": "cors", + "description": "`cors` contains configuration options regarding Cross-Origin-Resource-Sharing." } }, "additionalProperties": false, @@ -632,39 +916,39 @@ "Service": { "properties": { "name": { - "type": "string" + "type": "string", + "description": "`name` determines the name of the service.\nThis value is used, e.g. in the subject header of outgoing emails." } }, "additionalProperties": false, - "type": "object", - "required": [ - "name" - ] + "type": "object" }, "Session": { "properties": { + "audience": { + "items": { + "type": "string" + }, + "type": "array", + "description": "`audience` is a list of strings that identifies the recipients that the JWT is intended for.\nThe audiences are placed in the `aud` claim of the JWT.\nIf not set, it defaults to the value of the`webauthn.relying_party.id` configuration parameter." + }, + "cookie": { + "$ref": "#/$defs/Cookie", + "description": "`cookie` contains configuration for the session cookie issued on successful registration or login." + }, "enable_auth_token_header": { "type": "boolean", + "description": "`enable_auth_token_header` determines whether a session token (JWT) is returned in an `X-Auth-Token`\nheader after a successful authentication. This option should be set to `true` if API and client applications\nrun on different domains.", "default": false }, - "lifespan": { - "type": "string", - "description": "Lifespan, possibly signed sequence of decimal numbers, each with optional fraction and a unit suffix,\nsuch as \"300ms\", \"-1.5h\" or \"2h45m\". Valid time units are \"ns\", \"us\" (or \"µs\"), \"ms\", \"s\", \"m\", \"h\".", - "default": "1h" - }, - "cookie": { - "$ref": "#/$defs/Cookie" - }, "issuer": { "type": "string", - "description": "Issuer optional string to be used in the jwt iss claim." + "description": "`issuer` is a string that identifies the principal (human user, an organization, or a service)\nthat issued the JWT. Its value is set in the `iss` claim of a JWT." }, - "audience": { - "items": { - "type": "string" - }, - "type": "array", - "description": "Audience optional []string containing strings which get put into the aud claim. If not set default to Webauthn.RelyingParty.Id config parameter." + "lifespan": { + "type": "string", + "description": "`lifespan` determines how long a session token (JWT) is valid. It must be a (possibly signed) sequence of decimal\nnumbers, each with optional fraction and a unit suffix, such as \"300ms\", \"-1.5h\" or \"2h45m\".\nValid time units are \"ns\", \"us\" (or \"µs\"), \"ms\", \"s\", \"m\", \"h\".", + "default": "1h" } }, "additionalProperties": false, @@ -673,67 +957,144 @@ "ThirdParty": { "properties": { "providers": { - "$ref": "#/$defs/ThirdPartyProviders" + "$ref": "#/$defs/ThirdPartyProviders", + "title": "providers", + "description": "`providers` contains the configurations for the available OAuth/OIDC identity providers." }, "redirect_url": { - "type": "string" + "type": "string", + "description": "`redirect_url` is the URL the third party provider redirects to with an authorization code. Must consist of the base URL\nof your running Hanko backend instance and the `callback` endpoint of the API,\ni.e. `{YOUR_BACKEND_INSTANCE}/thirdparty/callback.`\n\nRequired if any of the [`providers`](#providers) are `enabled`.", + "examples": [ + "https://yourinstance.com/thirdparty/callback" + ] }, "error_redirect_url": { - "type": "string" + "type": "string", + "description": "`error_redirect_url` is the URL the backend redirects to if an error occurs during third party sign-in.\nErrors are provided as 'error' and 'error_description' query params in the redirect location URL.\n\nWhen using the Hanko web components it should be the URL of the page that embeds the web component such that\nerrors can be processed properly by the web component.\n\nYou do not have to add this URL to the 'allowed_redirect_urls', it is automatically included when validating\nredirect URLs.\n\nRequired if any of the [`providers`](#providers) are `enabled`. Must not have trailing slash." + }, + "default_redirect_url": { + "type": "string", + "description": "`default_redirect_url` is the URL the backend redirects to after it successfully verified\nthe response from any third party provider.\n\nMust not have trailing slash." }, "allowed_redirect_urls": { "items": { "type": "string" }, - "type": "array" + "type": "array", + "description": "`allowed_redirect_urls` is a list of URLs the backend is allowed to redirect to after third party sign-in was\nsuccessful.\n\nSupports wildcard matching through globbing. e.g. `https://*.example.com` will allow `https://foo.example.com`\nand `https://bar.example.com` to be accepted.\n\nGlobbing is also supported for paths, e.g. `https://foo.example.com/*` will match `https://foo.example.com/page1`\nand `https://foo.example.com/page2`.\n\nA double asterisk (`**`) acts as a \"super\"-wildcard/match-all.\n\nSee [here](https://pkg.go.dev/github.com/gobwas/glob#Compile) for more on globbing.\n\nMust not be empty if any of the [`providers`](#providers) are `enabled`. URLs in the list must not have a trailing slash." } }, "additionalProperties": false, "type": "object" }, "ThirdPartyProvider": { + "if": { + "properties": { + "enabled": { + "const": true + } + } + }, + "then": { + "required": [ + "client_id", + "secret" + ] + }, + "else": { + "required": [ + "enabled" + ] + }, "properties": { - "enabled": { - "type": "boolean" + "allow_linking": { + "type": "boolean", + "description": "`allow_linking` indicates whether existing accounts can be automatically linked with this provider.\n\nLinking is based on matching one of the email addresses of an existing user account with the (primary)\nemail address of the third party provider account." }, "client_id": { - "type": "string" + "type": "string", + "description": "`client_id` is the ID of the OAuth/OIDC client. Must be obtained from the provider.\n\nRequired if the provider is `enabled`." }, - "secret": { - "type": "string" + "enabled": { + "type": "boolean", + "description": "`enabled` determines whether this provider is enabled.", + "default": false }, - "allow_linking": { - "type": "boolean" + "secret": { + "type": "string", + "description": "`secret` is the client secret for the OAuth/OIDC client. Must be obtained from the provider.\n\nRequired if the provider is `enabled`." } }, "additionalProperties": false, "type": "object", - "required": [ - "enabled", - "client_id", - "secret", - "allow_linking" - ] + "title": "provider" }, "ThirdPartyProviders": { "properties": { - "google": { - "$ref": "#/$defs/ThirdPartyProvider" + "apple": { + "$ref": "#/$defs/ThirdPartyProvider", + "description": "`apple` contains the provider configuration for Apple." + }, + "discord": { + "$ref": "#/$defs/ThirdPartyProvider", + "description": "`discord` contains the provider configuration for Discord." }, "github": { - "$ref": "#/$defs/ThirdPartyProvider" + "$ref": "#/$defs/ThirdPartyProvider", + "description": "`github` contains the provider configuration for GitHub." }, - "apple": { - "$ref": "#/$defs/ThirdPartyProvider" + "google": { + "$ref": "#/$defs/ThirdPartyProvider", + "description": "`google` contains the provider configuration for Google." }, - "discord": { - "$ref": "#/$defs/ThirdPartyProvider" + "linkedin": { + "$ref": "#/$defs/ThirdPartyProvider", + "description": "`linkedin` contains the provider configuration for LinkedIn." }, "microsoft": { - "$ref": "#/$defs/ThirdPartyProvider" + "$ref": "#/$defs/ThirdPartyProvider", + "description": "`microsoft` contains the provider configuration for Microsoft." + } + }, + "additionalProperties": false, + "type": "object" + }, + "Username": { + "properties": { + "acquire_on_login": { + "type": "boolean", + "description": "`acquire_on_login` determines whether users, provided that they do not already have set a username,\n\tare prompted to provide a username on login.", + "default": false }, - "linkedin": { - "$ref": "#/$defs/ThirdPartyProvider" + "acquire_on_registration": { + "type": "boolean", + "description": "`acquire_on_registration` determines whether users are prompted to provide a username on registration.", + "default": false + }, + "enabled": { + "type": "boolean", + "description": "`enabled` determines whether users can set a unique username.\n\nUsernames can contain letters (a-z,A-Z), numbers (0-9), and underscores.", + "default": true + }, + "max_length": { + "type": "integer", + "description": "`max_length` specifies the maximum allowed length of a username.", + "default": 100 + }, + "min_length": { + "type": "integer", + "description": "`min_length` specifies the minimum length of a username.", + "default": 8 + }, + "optional": { + "type": "boolean", + "description": "`optional` determines whether users must provide a username when prompted. The username can only be changed but\nnot deleted if usernames are required (`optional: false`).", + "default": true + }, + "use_as_login_identifier": { + "type": "boolean", + "description": "`use_as_login_identifier` determines whether usernames, if enabled, can be used for logging in.", + "default": true } }, "additionalProperties": false, @@ -742,12 +1103,19 @@ "WebauthnSettings": { "properties": { "relying_party": { - "$ref": "#/$defs/RelyingParty" + "$ref": "#/$defs/RelyingParty", + "title": "relying_party" }, "timeout": { "type": "integer", + "description": "Deprecated, use `timeouts` instead.", "default": 60000 }, + "timeouts": { + "$ref": "#/$defs/WebauthnTimeouts", + "title": "timeouts", + "description": "`timeouts` specifies the timeouts for passkey/WebAuthn registration and login." + }, "user_verification": { "type": "string", "enum": [ @@ -755,6 +1123,7 @@ "preferred", "discouraged" ], + "description": "Deprecated, use `passkey.user_verification` instead", "default": "preferred" } }, @@ -762,30 +1131,80 @@ "type": "object", "description": "WebauthnSettings defines the settings for the webauthn authentication mechanism" }, + "WebauthnTimeouts": { + "properties": { + "registration": { + "type": "integer", + "description": "`registration` determines the time, in milliseconds, that the client is willing to wait for the credential\ncreation request to the WebAuthn API to complete.", + "default": 60000 + }, + "login": { + "type": "integer", + "description": "`login` determines the time, in milliseconds, that the client is willing to wait for the credential\n request to the WebAuthn API to complete.", + "default": 60000 + } + }, + "additionalProperties": false, + "type": "object" + }, "Webhook": { "properties": { "callback": { - "type": "string" + "type": "string", + "description": "`callback` specifies the URL to which the change data will be sent." }, "events": { - "$ref": "#/$defs/Events" + "$ref": "#/$defs/Events", + "items": { + "type": "string", + "enum": [ + "user", + "user.create", + "user.delete", + "user.update", + "user.update.email", + "user.update.email.create", + "user.update.email.delete", + "user.update.email.primary", + "email.send" + ], + "title": "events", + "meta:enum": { + "email.send": "Triggers on: an email was sent or should be sent", + "user": "Triggers on: user creation, user deletion, user update, email creation, email deletion, change of primary email", + "user.create": "Triggers on: user creation", + "user.delete": "Triggers on: user deletion", + "user.update": "Triggers on: user update, email creation, email deletion, change of primary email", + "user.update.email": "Triggers on: email creation, email deletion, change of primary email", + "user.update.email.create": "Triggers on: email creation", + "user.update.email.delete": "Triggers on: email deletion", + "user.update.email.primary": "Triggers on: change of primary email" + } + }, + "title": "events", + "description": "`events` is a list of events this hook listens for." } }, "additionalProperties": false, - "type": "object" + "type": "object", + "title": "hooks" }, "WebhookSettings": { "properties": { - "enabled": { + "allow_time_expiration": { "type": "boolean", + "description": "`allow_time_expiration` determines whether webhooks are disabled when unused for 30 days\n(only for database webhooks).", "default": false }, - "allow_time_expiration": { + "enabled": { "type": "boolean", + "description": "`enabled` enables the webhook feature.", "default": false }, "hooks": { - "$ref": "#/$defs/Webhooks" + "$ref": "#/$defs/Webhooks", + "title": "hooks", + "description": "`hooks` is a list of Webhook configurations.\n\nWhen using environment variables the value for the `WEBHOOKS_HOOKS` key must be specified in the following\nformat:\n`{\"callback\":\"http://app.com/usercb\",\"events\":[\"user\"]};{\"callback\":\"http://app.com/emailcb\",\"events\":[\"email.send\"]}`" } }, "additionalProperties": false, diff --git a/backend/mail/locales/passcode.en.yaml b/backend/mail/locales/passcode.en.yaml index 8095d7cc7..3e2420ae8 100644 --- a/backend/mail/locales/passcode.en.yaml +++ b/backend/mail/locales/passcode.en.yaml @@ -7,3 +7,33 @@ ttl_text: email_subject_login: description: "" other: "{{ .Code }} is your passcode for {{ .ServiceName }}" +subject_email_verification: + description: "" + other: "Use passcode {{ .Code }} to verify your email address" +subject_login: + description: "" + other: "Use passcode {{ .Code }} to login to your account" +subject_recovery: + description: "" + other: "Use passcode {{ .Code }} to recover your account" +email_verification_text: + description: "" + other: "Enter the following passcode to verify your email address:" +recovery_text: + description: "The content of the recovery text email." + other: "Enter the following passcode on your login screen:" + +subject_email_login_attempted: + description: "Subject for notification about a login attempt." + other: "Provided email address is not recognized" +email_login_attempted_text: + description: "Notifies the recipient that either they or someone else attempted to log in to a specific service using an unrecognized email address." + other: "You or someone else tried to sign in to {{ .ServiceName }}, but the provided email address is not recognized. Please create an account first." + +subject_email_registration_attempted: + description: "Subject for notification about a registration attempt." + other: "Provided email address already taken" +email_registration_attempted_text: + description: "Notifies the recipient that either they or someone else attempted to register for a specific service using an email address that is already in use." + other: "You or someone else tried to register an email for {{ .ServiceName }}, but the provided email address is already registered. Please try to log in instead." + diff --git a/backend/mail/render.go b/backend/mail/render.go index c50d48ae4..52c6f96d0 100644 --- a/backend/mail/render.go +++ b/backend/mail/render.go @@ -48,7 +48,8 @@ func NewRenderer() (*Renderer, error) { // translate is a helper function to translate texts in a template func (r *Renderer) translate(messageID string, templateData map[string]interface{}) string { - return r.localizer.MustLocalize(&i18n.LocalizeConfig{ + localizer := i18n.NewLocalizer(r.bundle, templateData["renderer_lang"].(string)) + return localizer.MustLocalize(&i18n.LocalizeConfig{ MessageID: messageID, TemplateData: templateData, }) @@ -58,6 +59,7 @@ func (r *Renderer) translate(messageID string, templateData map[string]interface // The lang can be the contents of Accept-Language headers as defined in http://www.ietf.org/rfc/rfc2616.txt. func (r *Renderer) Render(templateName string, lang string, data map[string]interface{}) (string, error) { r.localizer = i18n.NewLocalizer(r.bundle, lang) // set the localizer, so the test will be translated to the given language + data["renderer_lang"] = lang templateBuffer := &bytes.Buffer{} err := r.template.ExecuteTemplate(templateBuffer, templateName, data) if err != nil { diff --git a/backend/mail/render_test.go b/backend/mail/render_test.go index 65d95706f..b9206895c 100644 --- a/backend/mail/render_test.go +++ b/backend/mail/render_test.go @@ -32,7 +32,7 @@ func TestRenderer_Render(t *testing.T) { }{ { Name: "Login text template", - Template: "loginTextMail", + Template: "login_text.tmpl", Lang: "en", Expected: "Enter the following passcode to verify your identity:\n\n123456\n\nThe passcode is valid for 5 minutes.", WantErr: false, @@ -46,14 +46,14 @@ func TestRenderer_Render(t *testing.T) { }, { Name: "Login text template with unknown language", - Template: "loginTextMail", + Template: "login_text.tmpl", Lang: "xxx", Expected: "Enter the following passcode to verify your identity:\n\n123456\n\nThe passcode is valid for 5 minutes.", WantErr: false, }, { Name: "Login text template without translations for language", - Template: "loginTextMail", + Template: "login_text.tmpl", Lang: "es", Expected: "Enter the following passcode to verify your identity:\n\n123456\n\nThe passcode is valid for 5 minutes.", WantErr: false, diff --git a/backend/mail/templates/email_login_attempted_text.tmpl b/backend/mail/templates/email_login_attempted_text.tmpl new file mode 100644 index 000000000..e7a4a7d7d --- /dev/null +++ b/backend/mail/templates/email_login_attempted_text.tmpl @@ -0,0 +1 @@ +{{t "email_login_attempted_text" .}} diff --git a/backend/mail/templates/email_registration_attempted_text.tmpl b/backend/mail/templates/email_registration_attempted_text.tmpl new file mode 100644 index 000000000..4a10abe26 --- /dev/null +++ b/backend/mail/templates/email_registration_attempted_text.tmpl @@ -0,0 +1 @@ +{{t "email_registration_attempted_text" .}} diff --git a/backend/mail/templates/email_verification_text.tmpl b/backend/mail/templates/email_verification_text.tmpl new file mode 100644 index 000000000..5869f7075 --- /dev/null +++ b/backend/mail/templates/email_verification_text.tmpl @@ -0,0 +1,5 @@ +{{t "email_verification_text" .}} + +{{ .Code }} + +{{t "ttl_text" .}} diff --git a/backend/mail/templates/login.tmpl b/backend/mail/templates/login_text.tmpl similarity index 60% rename from backend/mail/templates/login.tmpl rename to backend/mail/templates/login_text.tmpl index d3094c2be..f80d4377b 100644 --- a/backend/mail/templates/login.tmpl +++ b/backend/mail/templates/login_text.tmpl @@ -1,7 +1,5 @@ -{{define "loginTextMail"}} {{t "login_text" .}} {{ .Code }} {{t "ttl_text" .}} -{{end}} diff --git a/backend/mail/templates/recovery_text.tmpl b/backend/mail/templates/recovery_text.tmpl new file mode 100644 index 000000000..27fd1850a --- /dev/null +++ b/backend/mail/templates/recovery_text.tmpl @@ -0,0 +1,5 @@ +{{t "recovery_text" .}} + +{{ .Code }} + +{{t "ttl_text" .}} diff --git a/backend/persistence/identity_persister.go b/backend/persistence/identity_persister.go index 767c43cdc..71f8a1bb7 100644 --- a/backend/persistence/identity_persister.go +++ b/backend/persistence/identity_persister.go @@ -4,12 +4,14 @@ import ( "database/sql" "fmt" "github.com/gobuffalo/pop/v6" + "github.com/gofrs/uuid" "github.com/pkg/errors" "github.com/teamhanko/hanko/backend/persistence/models" ) type IdentityPersister interface { Get(userProviderID string, providerID string) (*models.Identity, error) + GetByID(identityID uuid.UUID) (*models.Identity, error) Create(identity models.Identity) error Update(identity models.Identity) error Delete(identity models.Identity) error @@ -19,6 +21,17 @@ type identityPersister struct { db *pop.Connection } +func (p identityPersister) GetByID(identityID uuid.UUID) (*models.Identity, error) { + identity := &models.Identity{} + if err := p.db.EagerPreload("Email", "Email.User").Find(identity, identityID); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("failed to get identity: %w", err) + } + return identity, nil +} + func (p identityPersister) Get(userProviderID string, providerID string) (*models.Identity, error) { identity := &models.Identity{} if err := p.db.EagerPreload().Where("provider_id = ? AND provider_name = ?", userProviderID, providerID).First(identity); err != nil { diff --git a/backend/persistence/migrations/20230810173315_create_flows.down.fizz b/backend/persistence/migrations/20230810173315_create_flows.down.fizz new file mode 100644 index 000000000..a109d8c16 --- /dev/null +++ b/backend/persistence/migrations/20230810173315_create_flows.down.fizz @@ -0,0 +1,2 @@ +drop_table("transitions") +drop_table("flows") diff --git a/backend/persistence/migrations/20230810173315_create_flows.up.fizz b/backend/persistence/migrations/20230810173315_create_flows.up.fizz new file mode 100644 index 000000000..4efb791d5 --- /dev/null +++ b/backend/persistence/migrations/20230810173315_create_flows.up.fizz @@ -0,0 +1,20 @@ +create_table("flows") { + t.Column("id", "uuid", {primary: true}) + t.Column("current_state", "string") + t.Column("stash_data", "string", {"size": 4096}) + t.Column("version", "int") + t.Column("expires_at", "timestamp") + t.Timestamps() +} + +create_table("transitions") { + t.Column("id", "uuid", {primary: true}) + t.Column("flow_id", "uuid") + t.Column("action", "string") + t.Column("from_state", "string") + t.Column("to_state", "string") + t.Column("input_data", "string") + t.Column("error_code", "string", {"null": true}) + t.ForeignKey("flow_id", {"flows": ["id"]}, {"on_delete": "cascade", "on_update": "cascade"}) + t.Timestamps() +} diff --git a/backend/persistence/migrations/20231012141100_change_user_table.down.fizz b/backend/persistence/migrations/20231012141100_change_user_table.down.fizz new file mode 100644 index 000000000..dc19efee6 --- /dev/null +++ b/backend/persistence/migrations/20231012141100_change_user_table.down.fizz @@ -0,0 +1 @@ +drop_column("users", "username") diff --git a/backend/persistence/migrations/20231012141100_change_user_table.up.fizz b/backend/persistence/migrations/20231012141100_change_user_table.up.fizz new file mode 100644 index 000000000..67e0e3617 --- /dev/null +++ b/backend/persistence/migrations/20231012141100_change_user_table.up.fizz @@ -0,0 +1 @@ +add_column("users", "username", "string", { "null": true }) diff --git a/backend/persistence/migrations/20231013113800_change_passcode_table.down.fizz b/backend/persistence/migrations/20231013113800_change_passcode_table.down.fizz new file mode 100644 index 000000000..4e97c88a4 --- /dev/null +++ b/backend/persistence/migrations/20231013113800_change_passcode_table.down.fizz @@ -0,0 +1,5 @@ +sql("DELETE FROM passcodes WHERE user_id IS NULL") +change_column("passcodes", "user_id", "uuid", {}) + +drop_foreign_key("passcodes", "passcodes_flows_id_fk", {"if_exists": false}) +drop_column("passcodes", "flow_id") diff --git a/backend/persistence/migrations/20231013113800_change_passcode_table.up.fizz b/backend/persistence/migrations/20231013113800_change_passcode_table.up.fizz new file mode 100644 index 000000000..957ff09a7 --- /dev/null +++ b/backend/persistence/migrations/20231013113800_change_passcode_table.up.fizz @@ -0,0 +1,7 @@ +change_column("passcodes", "user_id", "uuid", {"null": true}) + +add_column("passcodes", "flow_id", "uuid", {"null":true}) +add_foreign_key("passcodes", "flow_id", {"flows": ["id"]}, { + "on_delete": "cascade", + "on_update": "cascade", +}) diff --git a/backend/persistence/migrations/20240207150616_change_audit_logs.down.fizz b/backend/persistence/migrations/20240207150616_change_audit_logs.down.fizz new file mode 100644 index 000000000..9efde1a24 --- /dev/null +++ b/backend/persistence/migrations/20240207150616_change_audit_logs.down.fizz @@ -0,0 +1 @@ +drop_column("audit_logs", "details") diff --git a/backend/persistence/migrations/20240207150616_change_audit_logs.up.fizz b/backend/persistence/migrations/20240207150616_change_audit_logs.up.fizz new file mode 100644 index 000000000..b97bbd0a9 --- /dev/null +++ b/backend/persistence/migrations/20240207150616_change_audit_logs.up.fizz @@ -0,0 +1 @@ +add_column("audit_logs", "details", "text", {"null":true}) diff --git a/backend/persistence/migrations/20240530122100_change_tokens.down.fizz b/backend/persistence/migrations/20240530122100_change_tokens.down.fizz new file mode 100644 index 000000000..9fff09b91 --- /dev/null +++ b/backend/persistence/migrations/20240530122100_change_tokens.down.fizz @@ -0,0 +1,3 @@ +drop_column("tokens", "identity_id") +drop_column("tokens", "is_flow") +drop_column("tokens", "user_created") diff --git a/backend/persistence/migrations/20240530122100_change_tokens.up.fizz b/backend/persistence/migrations/20240530122100_change_tokens.up.fizz new file mode 100644 index 000000000..73068b712 --- /dev/null +++ b/backend/persistence/migrations/20240530122100_change_tokens.up.fizz @@ -0,0 +1,8 @@ +add_column("tokens", "identity_id", "uuid", {"null":true}) +add_column("tokens", "is_flow", "bool", {"default":false}) +add_column("tokens", "user_created", "bool") + +add_foreign_key("tokens", "identity_id", {"identities": ["id"]}, { + "on_delete": "cascade", + "on_update": "cascade", +}) diff --git a/backend/persistence/migrations/20240530145724_change_users.down.fizz b/backend/persistence/migrations/20240530145724_change_users.down.fizz new file mode 100644 index 000000000..66e3c8d59 --- /dev/null +++ b/backend/persistence/migrations/20240530145724_change_users.down.fizz @@ -0,0 +1 @@ +drop_index("users", "users_username_idx") diff --git a/backend/persistence/migrations/20240530145724_change_users.up.fizz b/backend/persistence/migrations/20240530145724_change_users.up.fizz new file mode 100644 index 000000000..e9a2c5a86 --- /dev/null +++ b/backend/persistence/migrations/20240530145724_change_users.up.fizz @@ -0,0 +1 @@ +add_index("users", "username", {"unique": true}) diff --git a/backend/persistence/migrations/20240612122326_change_flows.down.fizz b/backend/persistence/migrations/20240612122326_change_flows.down.fizz new file mode 100644 index 000000000..474df6382 --- /dev/null +++ b/backend/persistence/migrations/20240612122326_change_flows.down.fizz @@ -0,0 +1,2 @@ +drop_column("flows", "csrf_token") +drop_column("flows", "previous_state") diff --git a/backend/persistence/migrations/20240612122326_change_flows.up.fizz b/backend/persistence/migrations/20240612122326_change_flows.up.fizz new file mode 100644 index 000000000..33af7bb7a --- /dev/null +++ b/backend/persistence/migrations/20240612122326_change_flows.up.fizz @@ -0,0 +1,2 @@ +add_column("flows", "csrf_token", "string", { "size": 32 }) +add_column("flows", "previous_state", "string", { "null": true }) diff --git a/backend/persistence/migrations/20240717020131_drop_transitions.down.fizz b/backend/persistence/migrations/20240717020131_drop_transitions.down.fizz new file mode 100644 index 000000000..ac7a85b42 --- /dev/null +++ b/backend/persistence/migrations/20240717020131_drop_transitions.down.fizz @@ -0,0 +1,11 @@ +create_table("transitions") { + t.Column("id", "uuid", {primary: true}) + t.Column("flow_id", "uuid") + t.Column("action", "string") + t.Column("from_state", "string") + t.Column("to_state", "string") + t.Column("input_data", "string") + t.Column("error_code", "string", {"null": true}) + t.ForeignKey("flow_id", {"flows": ["id"]}, {"on_delete": "cascade", "on_update": "cascade"}) + t.Timestamps() +} diff --git a/backend/persistence/migrations/20240717020131_drop_transitions.up.fizz b/backend/persistence/migrations/20240717020131_drop_transitions.up.fizz new file mode 100644 index 000000000..8de1aeef8 --- /dev/null +++ b/backend/persistence/migrations/20240717020131_drop_transitions.up.fizz @@ -0,0 +1,2 @@ +drop_table("transitions") + diff --git a/backend/persistence/migrations/20240717020707_change_flows.down.fizz b/backend/persistence/migrations/20240717020707_change_flows.down.fizz new file mode 100644 index 000000000..f6cefed27 --- /dev/null +++ b/backend/persistence/migrations/20240717020707_change_flows.down.fizz @@ -0,0 +1,4 @@ +drop_column("flows", "data") +add_column("flows", "stash_data", "string", {"size": 4096}) +add_column("flows", "current_state", "string") +add_column("flows", "previous_state", "string") diff --git a/backend/persistence/migrations/20240717020707_change_flows.up.fizz b/backend/persistence/migrations/20240717020707_change_flows.up.fizz new file mode 100644 index 000000000..05a339423 --- /dev/null +++ b/backend/persistence/migrations/20240717020707_change_flows.up.fizz @@ -0,0 +1,4 @@ +add_column("flows", "data", "string", {"size": 65536}) +drop_column("flows", "stash_data") +drop_column("flows", "current_state") +drop_column("flows", "previous_state") diff --git a/backend/persistence/migrations/20240723171257_change_passcodes.down.fizz b/backend/persistence/migrations/20240723171257_change_passcodes.down.fizz new file mode 100644 index 000000000..f9244a1d1 --- /dev/null +++ b/backend/persistence/migrations/20240723171257_change_passcodes.down.fizz @@ -0,0 +1,5 @@ +add_column("passcodes", "flow_id", "uuid", {"null":true}) +add_foreign_key("passcodes", "flow_id", {"flows": ["id"]}, { + "on_delete": "cascade", + "on_update": "cascade", +}) diff --git a/backend/persistence/migrations/20240723171257_change_passcodes.up.fizz b/backend/persistence/migrations/20240723171257_change_passcodes.up.fizz new file mode 100644 index 000000000..32df71031 --- /dev/null +++ b/backend/persistence/migrations/20240723171257_change_passcodes.up.fizz @@ -0,0 +1,2 @@ +drop_foreign_key("passcodes", "passcodes_flows_id_fk") +drop_column("passcodes", "flow_id") diff --git a/backend/persistence/migrations/20240723173648_create_usernames.down.fizz b/backend/persistence/migrations/20240723173648_create_usernames.down.fizz new file mode 100644 index 000000000..5045b2816 --- /dev/null +++ b/backend/persistence/migrations/20240723173648_create_usernames.down.fizz @@ -0,0 +1,3 @@ +add_column("users", "username", "string", { "null": true }) +add_index("users", "username", {"unique": true}) +drop_table("usernames") diff --git a/backend/persistence/migrations/20240723173648_create_usernames.up.fizz b/backend/persistence/migrations/20240723173648_create_usernames.up.fizz new file mode 100644 index 000000000..d508edd32 --- /dev/null +++ b/backend/persistence/migrations/20240723173648_create_usernames.up.fizz @@ -0,0 +1,10 @@ +drop_column("users", "username") +create_table("usernames") { + t.Column("id", "uuid", {primary: true}) + t.Column("user_id", "uuid", { "null": false }) + t.Column("username", "string", { "null": false }) + t.Timestamps() + t.Index("username", { "unique": true }) + t.Index("user_id", { "unique": true }) + t.ForeignKey("user_id", {"users": ["id"]}, {"on_delete": "cascade", "on_update": "cascade"}) +} diff --git a/backend/persistence/models/audit_log.go b/backend/persistence/models/audit_log.go index a8a6d04de..5bc39bf47 100644 --- a/backend/persistence/models/audit_log.go +++ b/backend/persistence/models/audit_log.go @@ -1,6 +1,8 @@ package models import ( + "fmt" + "github.com/gobuffalo/pop/v6/slices" "github.com/gofrs/uuid" "time" ) @@ -13,17 +15,61 @@ type AuditLog struct { MetaSourceIp string `db:"meta_source_ip" json:"meta_source_ip"` MetaUserAgent string `db:"meta_user_agent" json:"meta_user_agent"` ActorUserId *uuid.UUID `db:"actor_user_id" json:"actor_user_id,omitempty"` - ActorEmail *string `db:"actor_email" json:"actor_email,omitempty"` + ActorEmail *string `db:"actor_email" json:"actor_email,omitempty" mask:"email"` + Details slices.Map `db:"details" json:"details"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } +type Details map[string]interface{} + +type RequestMeta struct { + HttpRequestId string + SourceIp string + UserAgent string +} + +func NewAuditLog(auditLogType AuditLogType, requestMeta RequestMeta, details Details, user *User, logError error) (AuditLog, error) { + id, err := uuid.NewV4() + if err != nil { + return AuditLog{}, fmt.Errorf("failed to create id: %w", err) + } + + auditLog := AuditLog{ + ID: id, + Type: auditLogType, + Error: nil, + MetaHttpRequestId: requestMeta.HttpRequestId, + MetaUserAgent: requestMeta.UserAgent, + MetaSourceIp: requestMeta.SourceIp, + ActorUserId: nil, + ActorEmail: nil, + } + + if len(details) > 0 { + auditLog.Details = slices.Map(details) + } + + if user != nil { + auditLog.ActorUserId = &user.ID + + if e := user.Emails.GetPrimary(); e != nil { + auditLog.ActorEmail = &e.Address + } + } + + if logError != nil { + // check if error is not nil, because else the string (formatted with fmt.Sprintf) would not be empty but look like this: `%!s()` + tmp := fmt.Sprintf("%s", logError) + auditLog.Error = &tmp + } + return auditLog, nil +} + type AuditLogType string var ( - AuditLogUserCreated AuditLogType = "user_created" AuditLogUserLoggedOut AuditLogType = "user_logged_out" - AuditLogUserDeleted AuditLogType = "user_deleted" AuditLogPasswordSetSucceeded AuditLogType = "password_set_succeeded" AuditLogPasswordSetFailed AuditLogType = "password_set_failed" @@ -49,11 +95,6 @@ var ( AuditLogWebAuthnCredentialUpdated AuditLogType = "webauthn_credential_updated" AuditLogWebAuthnCredentialDeleted AuditLogType = "webauthn_credential_deleted" - AuditLogEmailCreated AuditLogType = "email_created" - AuditLogEmailDeleted AuditLogType = "email_deleted" - AuditLogEmailVerified AuditLogType = "email_verified" - AuditLogPrimaryEmailChanged AuditLogType = "primary_email_changed" - AuditLogThirdPartySignUpSucceeded AuditLogType = "thirdparty_signup_succeeded" AuditLogThirdPartySignInSucceeded AuditLogType = "thirdparty_signin_succeeded" AuditLogThirdPartyLinkingSucceeded AuditLogType = "thirdparty_linking_succeeded" @@ -61,4 +102,22 @@ var ( AuditLogTokenExchangeSucceeded AuditLogType = "token_exchange_succeeded" AuditLogTokenExchangeFailed AuditLogType = "token_exchange_failed" + + // Types used by old API and new/flow API + AuditLogUserCreated AuditLogType = "user_created" + AuditLogEmailCreated AuditLogType = "email_created" + AuditLogEmailVerified AuditLogType = "email_verified" + AuditLogEmailDeleted AuditLogType = "email_deleted" + AuditLogPrimaryEmailChanged AuditLogType = "primary_email_changed" + AuditLogUserDeleted AuditLogType = "user_deleted" + + // New/flow API types + AuditLogLoginSuccess AuditLogType = "login_success" + AuditLogLoginFailure AuditLogType = "login_failure" + AuditLogPasskeyCreated AuditLogType = "passkey_created" + AuditLogPasskeyDeleted AuditLogType = "passkey_deleted" + AuditLogUsernameChanged AuditLogType = "username_changed" + AuditLogUsernameDeleted AuditLogType = "username_deleted" + AuditLogPasswordChanged AuditLogType = "password_changed" + AuditLogPasswordDeleted AuditLogType = "password_deleted" ) diff --git a/backend/persistence/models/email.go b/backend/persistence/models/email.go index 5a421a59e..7349d09e0 100644 --- a/backend/persistence/models/email.go +++ b/backend/persistence/models/email.go @@ -5,13 +5,14 @@ import ( "github.com/gobuffalo/validate/v3" "github.com/gobuffalo/validate/v3/validators" "github.com/gofrs/uuid" + "golang.org/x/exp/slices" "time" ) // Email is used by pop to map your users database table to your go code. type Email struct { ID uuid.UUID `db:"id" json:"id"` - UserID *uuid.UUID `db:"user_id" json:"user_id,omitempty"` + UserID *uuid.UUID `db:"user_id" json:"user_id,omitempty"` // TODO: should not be a pointer anymore Address string `db:"address" json:"address"` Verified bool `db:"verified" json:"verified"` PrimaryEmail *PrimaryEmail `has_one:"primary_emails" json:"primary_emails,omitempty"` @@ -44,9 +45,9 @@ func (email *Email) IsPrimary() bool { return false } -func (emails Emails) GetVerified() Emails { +func (emails *Emails) GetVerified() Emails { var list Emails - for _, email := range emails { + for _, email := range *emails { if email.Verified { list = append(list, email) } @@ -54,8 +55,14 @@ func (emails Emails) GetVerified() Emails { return list } -func (emails Emails) GetPrimary() *Email { - for _, email := range emails { +func (emails *Emails) HasUnverified() bool { + return slices.ContainsFunc(*emails, func(e Email) bool { + return !e.Verified + }) +} + +func (emails *Emails) GetPrimary() *Email { + for _, email := range *emails { if email.IsPrimary() { return &email } @@ -63,18 +70,8 @@ func (emails Emails) GetPrimary() *Email { return nil } -func (emails Emails) SetPrimary(primary *PrimaryEmail) { - for i := range emails { - if emails[i].ID.String() == primary.EmailID.String() { - emails[i].PrimaryEmail = primary - return - } - } - return -} - -func (emails Emails) GetEmailByAddress(address string) *Email { - for _, email := range emails { +func (emails *Emails) GetEmailByAddress(address string) *Email { + for _, email := range *emails { if email.Address == address { return &email } @@ -82,8 +79,8 @@ func (emails Emails) GetEmailByAddress(address string) *Email { return nil } -func (emails Emails) GetEmailById(emailId uuid.UUID) *Email { - for _, email := range emails { +func (emails *Emails) GetEmailById(emailId uuid.UUID) *Email { + for _, email := range *emails { if email.ID.String() == emailId.String() { return &email } diff --git a/backend/persistence/models/flow.go b/backend/persistence/models/flow.go new file mode 100644 index 000000000..9be080bfa --- /dev/null +++ b/backend/persistence/models/flow.go @@ -0,0 +1,56 @@ +package models + +import ( + "github.com/teamhanko/hanko/backend/flowpilot" + "time" + + "github.com/gobuffalo/pop/v6" + "github.com/gobuffalo/validate/v3" + "github.com/gofrs/uuid" +) + +// Flow is used by pop to map your flows database table to your go code. +type Flow struct { + ID uuid.UUID `json:"id" db:"id"` + Data string `json:"data" db:"data"` + Version int `json:"version" db:"version"` + CSRFToken string `json:"csrf_token" db:"csrf_token"` + ExpiresAt time.Time `json:"expires_at" db:"expires_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +func (f *Flow) ToFlowpilotModel() *flowpilot.FlowModel { + flow := flowpilot.FlowModel{ + ID: f.ID, + Data: f.Data, + Version: f.Version, + CSRFToken: f.CSRFToken, + ExpiresAt: f.ExpiresAt, + CreatedAt: f.CreatedAt, + UpdatedAt: f.UpdatedAt, + } + + return &flow +} + +// Flows is not required by pop and may be deleted +type Flows []Flow + +// Validate gets run every time you call a "pop.validate*" (pop.ValidateAndSave, pop.ValidateAndCreate, pop.ValidateAndUpdate) method. +// This method is not required and may be deleted. +func (f *Flow) Validate(tx *pop.Connection) (*validate.Errors, error) { + return validate.NewErrors(), nil +} + +// ValidateCreate gets run every time you call "pop.ValidateAndCreate" method. +// This method is not required and may be deleted. +func (f *Flow) ValidateCreate(tx *pop.Connection) (*validate.Errors, error) { + return validate.NewErrors(), nil +} + +// ValidateUpdate gets run every time you call "pop.ValidateAndUpdate" method. +// This method is not required and may be deleted. +func (f *Flow) ValidateUpdate(tx *pop.Connection) (*validate.Errors, error) { + return validate.NewErrors(), nil +} diff --git a/backend/persistence/models/flowdb.go b/backend/persistence/models/flowdb.go new file mode 100644 index 000000000..ff08b5801 --- /dev/null +++ b/backend/persistence/models/flowdb.go @@ -0,0 +1,74 @@ +package models + +import ( + "errors" + "github.com/gobuffalo/pop/v6" + "github.com/gofrs/uuid" + "github.com/teamhanko/hanko/backend/flowpilot" +) + +type FlowDB struct { + tx *pop.Connection +} + +func NewFlowDB(tx *pop.Connection) flowpilot.FlowDB { + return FlowDB{tx} +} + +func (flowDB FlowDB) GetFlow(flowID uuid.UUID) (*flowpilot.FlowModel, error) { + flowModel := Flow{} + + err := flowDB.tx.Find(&flowModel, flowID) + if err != nil { + return nil, err + } + + return flowModel.ToFlowpilotModel(), nil +} + +func (flowDB FlowDB) CreateFlow(flowModel flowpilot.FlowModel) error { + f := Flow{ + ID: flowModel.ID, + Data: flowModel.Data, + Version: flowModel.Version, + CSRFToken: flowModel.CSRFToken, + ExpiresAt: flowModel.ExpiresAt, + CreatedAt: flowModel.CreatedAt, + UpdatedAt: flowModel.UpdatedAt, + } + + err := flowDB.tx.Create(&f) + if err != nil { + return err + } + + return nil +} + +func (flowDB FlowDB) UpdateFlow(flowModel flowpilot.FlowModel) error { + f := &Flow{ + ID: flowModel.ID, + Data: flowModel.Data, + Version: flowModel.Version, + CSRFToken: flowModel.CSRFToken, + ExpiresAt: flowModel.ExpiresAt, + CreatedAt: flowModel.CreatedAt, + UpdatedAt: flowModel.UpdatedAt, + } + + previousVersion := flowModel.Version - 1 + + count, err := flowDB.tx. + Where("id = ?", f.ID). + Where("version = ?", previousVersion). + UpdateQuery(f, "version", "csrf_token", "data") + if err != nil { + return err + } + + if count != 1 { + return errors.New("version conflict while updating the flow") + } + + return nil +} diff --git a/backend/persistence/models/passcode.go b/backend/persistence/models/passcode.go index a4e29f4c9..73f03b972 100644 --- a/backend/persistence/models/passcode.go +++ b/backend/persistence/models/passcode.go @@ -10,22 +10,21 @@ import ( // Passcode is used by pop to map your passcodes database table to your go code. type Passcode struct { - ID uuid.UUID `db:"id"` - UserId uuid.UUID `db:"user_id"` - EmailID uuid.UUID `db:"email_id"` - Ttl int `db:"ttl"` // in seconds - Code string `db:"code"` - TryCount int `db:"try_count"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` - Email Email `belongs_to:"email"` + ID uuid.UUID `db:"id"` + UserId *uuid.UUID `db:"user_id"` + EmailID *uuid.UUID `db:"email_id"` + Ttl int `db:"ttl"` // in seconds + Code string `db:"code"` + TryCount int `db:"try_count"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + Email Email `belongs_to:"email"` } // Validate gets run every time you call a "pop.Validate*" (pop.ValidateAndSave, pop.ValidateAndCreate, pop.ValidateAndUpdate) method. func (passcode *Passcode) Validate(tx *pop.Connection) (*validate.Errors, error) { return validate.Validate( &validators.UUIDIsPresent{Name: "ID", Field: passcode.ID}, - &validators.UUIDIsPresent{Name: "UserID", Field: passcode.UserId}, &validators.StringLengthInRange{Name: "Code", Field: passcode.Code, Min: 6}, &validators.TimeIsPresent{Name: "CreatedAt", Field: passcode.CreatedAt}, &validators.TimeIsPresent{Name: "UpdatedAt", Field: passcode.UpdatedAt}, diff --git a/backend/persistence/models/password_credential.go b/backend/persistence/models/password_credential.go index 435b3446f..7d21f379a 100644 --- a/backend/persistence/models/password_credential.go +++ b/backend/persistence/models/password_credential.go @@ -16,6 +16,17 @@ type PasswordCredential struct { UpdatedAt time.Time `db:"updated_at"` } +func NewPasswordCredential(userId uuid.UUID, password string) *PasswordCredential { + id, _ := uuid.NewV4() + return &PasswordCredential{ + ID: id, + UserId: userId, + Password: password, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } +} + func (password *PasswordCredential) Validate(tx *pop.Connection) (*validate.Errors, error) { return validate.Validate( &validators.StringIsPresent{Name: "Password", Field: password.Password}, diff --git a/backend/persistence/models/token.go b/backend/persistence/models/token.go index 0d1cb4e10..882dc78ab 100644 --- a/backend/persistence/models/token.go +++ b/backend/persistence/models/token.go @@ -12,15 +12,36 @@ import ( ) type Token struct { - ID uuid.UUID `db:"id"` - UserID uuid.UUID `db:"user_id"` - Value string `db:"value"` - ExpiresAt time.Time `db:"expires_at"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + ID uuid.UUID `db:"id"` + UserID uuid.UUID `db:"user_id"` + IdentityID *uuid.UUID `db:"identity_id"` + IsFlow bool `db:"is_flow"` + Value string `db:"value"` + UserCreated bool `db:"user_created"` + ExpiresAt time.Time `db:"expires_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` } -func NewToken(userID uuid.UUID) (*Token, error) { +func TokenWithIdentityID(identityID uuid.UUID) func(*Token) { + return func(token *Token) { + token.IdentityID = &identityID + } +} + +func TokenForFlowAPI(isFlow bool) func(*Token) { + return func(token *Token) { + token.IsFlow = isFlow + } +} + +func TokenUserCreated(userCreated bool) func(*Token) { + return func(token *Token) { + token.UserCreated = userCreated + } +} + +func NewToken(userID uuid.UUID, options ...func(*Token)) (*Token, error) { if userID.IsNil() { return nil, errors.New("userID is required") } @@ -37,14 +58,20 @@ func NewToken(userID uuid.UUID) (*Token, error) { return nil, fmt.Errorf("could not generate random string: %w", err) } - return &Token{ + token := &Token{ ID: id, UserID: userID, Value: value, ExpiresAt: now.Add(time.Minute), CreatedAt: now, UpdatedAt: now, - }, nil + } + + for _, option := range options { + option(token) + } + + return token, nil } func (token *Token) Validate(tx *pop.Connection) (*validate.Errors, error) { diff --git a/backend/persistence/models/user.go b/backend/persistence/models/user.go index 629d0bb97..559314ddb 100644 --- a/backend/persistence/models/user.go +++ b/backend/persistence/models/user.go @@ -1,20 +1,45 @@ package models import ( + "encoding/base64" + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" "github.com/gobuffalo/pop/v6" "github.com/gobuffalo/validate/v3" "github.com/gobuffalo/validate/v3/validators" "github.com/gofrs/uuid" + "golang.org/x/exp/slices" "time" ) // User is used by pop to map your users database table to your go code. type User struct { - ID uuid.UUID `db:"id" json:"id"` - WebauthnCredentials []WebauthnCredential `has_many:"webauthn_credentials" json:"webauthn_credentials,omitempty"` - Emails Emails `has_many:"emails" json:"-"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ID uuid.UUID `db:"id" json:"id"` + WebauthnCredentials WebauthnCredentials `has_many:"webauthn_credentials" json:"webauthn_credentials,omitempty"` + Emails Emails `has_many:"emails" json:"-"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Username *Username `has_one:"username" json:"username,omitempty"` + PasswordCredential *PasswordCredential `has_one:"password_credentials" json:"-"` +} + +type WebauthnCredentials []WebauthnCredential + +func (user *User) DeleteWebauthnCredential(credentialId string) { + for i := range user.WebauthnCredentials { + if user.WebauthnCredentials[i].ID == credentialId { + user.WebauthnCredentials = slices.Delete(user.WebauthnCredentials, i, i+1) + return + } + } +} + +func (user *User) GetIdentities() Identities { + var identities Identities + for _, email := range user.Emails { + identities = append(identities, email.Identities...) + } + return identities } func NewUser() User { @@ -26,6 +51,49 @@ func NewUser() User { } } +func (user *User) GetUsername() string { + if user.Username != nil { + return user.Username.Username + } + return "" +} + +func (user *User) SetUsername(username *Username) { + user.Username = username +} + +func (user *User) DeleteUsername() { + user.Username = nil +} + +func (user *User) SetPrimaryEmail(primary *PrimaryEmail) { + for i := range user.Emails { + if user.Emails[i].ID.String() == primary.EmailID.String() { + user.Emails[i].PrimaryEmail = primary + } else { + user.Emails[i].PrimaryEmail = nil + } + } +} + +func (user *User) UpdateEmail(email Email) { + for i := range user.Emails { + if user.Emails[i].ID.String() == email.ID.String() { + user.Emails[i] = email + return + } + } +} + +func (user *User) DeleteEmail(email Email) { + for i := range user.Emails { + if user.Emails[i].ID.String() == email.ID.String() { + user.Emails = slices.Delete(user.Emails, i, i+1) + return + } + } +} + func (user *User) GetEmailById(emailId uuid.UUID) *Email { return user.Emails.GetEmailById(emailId) } @@ -34,6 +102,15 @@ func (user *User) GetEmailByAddress(address string) *Email { return user.Emails.GetEmailByAddress(address) } +func (user *User) GetWebauthnCredentialById(credentialId string) *WebauthnCredential { + for i := range user.WebauthnCredentials { + if user.WebauthnCredentials[i].ID == credentialId { + return &user.WebauthnCredentials[i] + } + } + return nil +} + // Validate gets run every time you call a "pop.Validate*" (pop.ValidateAndSave, pop.ValidateAndCreate, pop.ValidateAndUpdate) method. func (user *User) Validate(tx *pop.Connection) (*validate.Errors, error) { return validate.Validate( @@ -42,3 +119,57 @@ func (user *User) Validate(tx *pop.Connection) (*validate.Errors, error) { &validators.TimeIsPresent{Name: "CreatedAt", Field: user.CreatedAt}, ), nil } + +func (user *User) WebAuthnID() []byte { + return user.ID.Bytes() +} + +func (user *User) WebAuthnName() string { + email := user.Emails.GetPrimary() + if email != nil { + return email.Address + } + return "username" // TODO +} + +func (user *User) WebAuthnDisplayName() string { + email := user.Emails.GetPrimary() + if email != nil { + return email.Address + } + return "username" // TODO +} + +func (user *User) WebAuthnIcon() string { + return "" +} + +func (user *User) WebAuthnCredentials() []webauthn.Credential { + var credentials []webauthn.Credential + + for _, credential := range user.WebauthnCredentials { + credentialID, _ := base64.RawURLEncoding.DecodeString(credential.ID) + publicKey, _ := base64.RawURLEncoding.DecodeString(credential.PublicKey) + + transport := make([]protocol.AuthenticatorTransport, len(credential.Transports)) + + for i, t := range credential.Transports { + transport[i] = protocol.AuthenticatorTransport(t.Name) + } + + c := webauthn.Credential{ + ID: credentialID, + PublicKey: publicKey, + AttestationType: credential.AttestationType, + Authenticator: webauthn.Authenticator{ + AAGUID: credential.AAGUID.Bytes(), + SignCount: uint32(credential.SignCount), + }, + Transport: transport, + } + + credentials = append(credentials, c) + } + + return credentials +} diff --git a/backend/persistence/models/username.go b/backend/persistence/models/username.go new file mode 100644 index 000000000..d60bd87bb --- /dev/null +++ b/backend/persistence/models/username.go @@ -0,0 +1,35 @@ +package models + +import ( + "github.com/gobuffalo/pop/v6" + "github.com/gobuffalo/validate/v3" + "github.com/gobuffalo/validate/v3/validators" + "github.com/gofrs/uuid" + "time" +) + +type Username struct { + ID uuid.UUID `db:"id"` + UserId uuid.UUID `db:"user_id"` + Username string `db:"username"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +func NewUsername(userId uuid.UUID, username string) *Username { + id, _ := uuid.NewV4() + return &Username{ + ID: id, + UserId: userId, + Username: username, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } +} + +func (username *Username) Validate(tx *pop.Connection) (*validate.Errors, error) { + return validate.Validate( + &validators.StringIsPresent{Name: "Username", Field: username.Username}, + &validators.UUIDIsPresent{Name: "UserId", Field: username.UserId}, + ), nil +} diff --git a/backend/persistence/models/webauthn_session_data.go b/backend/persistence/models/webauthn_session_data.go index bff348ebb..a521e9589 100644 --- a/backend/persistence/models/webauthn_session_data.go +++ b/backend/persistence/models/webauthn_session_data.go @@ -1,6 +1,10 @@ package models import ( + "encoding/base64" + "fmt" + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" "github.com/gobuffalo/nulls" "github.com/gobuffalo/pop/v6" "github.com/gobuffalo/validate/v3" @@ -29,6 +33,86 @@ type WebauthnSessionData struct { ExpiresAt nulls.Time `db:"expires_at"` } +func (sd *WebauthnSessionData) decodeAllowedCredentials() [][]byte { + var allowedCredentials [][]byte + + for _, credential := range sd.AllowedCredentials { + credentialId, err := base64.RawURLEncoding.DecodeString(credential.CredentialId) + if err != nil { + continue + } + + allowedCredentials = append(allowedCredentials, credentialId) + } + + return allowedCredentials +} + +func NewWebauthnSessionDataFrom(sessionData *webauthn.SessionData, operation Operation) (*WebauthnSessionData, error) { + now := time.Now().UTC() + + sessionDataID, err := uuid.NewV4() + if err != nil { + return nil, fmt.Errorf("failed to generate a new uuid for session data: %w", err) + } + + userID, _ := uuid.FromBytes(sessionData.UserID) + + allowedCredentials := make([]WebauthnSessionDataAllowedCredential, len(sessionData.AllowedCredentialIDs)) + + for index, credentialID := range sessionData.AllowedCredentialIDs { + allowedCredentialID, err := uuid.NewV4() + if err != nil { + return nil, fmt.Errorf("failed to generate a uuid for the allowed credential: %w", err) + } + + allowedCredential := WebauthnSessionDataAllowedCredential{ + ID: allowedCredentialID, + CredentialId: base64.RawURLEncoding.EncodeToString(credentialID), + WebauthnSessionDataID: sessionDataID, + CreatedAt: now, + UpdatedAt: now, + } + + allowedCredentials[index] = allowedCredential + } + + sessionDataModel := &WebauthnSessionData{ + ID: sessionDataID, + Challenge: sessionData.Challenge, + UserId: userID, + UserVerification: string(sessionData.UserVerification), + CreatedAt: now, + UpdatedAt: now, + Operation: operation, + AllowedCredentials: allowedCredentials, + ExpiresAt: nulls.NewTime(sessionData.Expires), + } + + return sessionDataModel, nil +} + +func (sd *WebauthnSessionData) ToSessionData() *webauthn.SessionData { + allowedCredentials := sd.decodeAllowedCredentials() + + // TODO: do we need the following lines and is the user optional? + var userId []byte = nil + + if !sd.UserId.IsNil() { + userId = sd.UserId.Bytes() + } + + sessionData := &webauthn.SessionData{ + Challenge: sd.Challenge, + UserID: userId, + AllowedCredentialIDs: allowedCredentials, + UserVerification: protocol.UserVerificationRequirement(sd.UserVerification), + Expires: sd.ExpiresAt.Time, + } + + return sessionData +} + // Validate gets run every time you call a "pop.Validate*" (pop.ValidateAndSave, pop.ValidateAndCreate, pop.ValidateAndUpdate) method. func (sd *WebauthnSessionData) Validate(tx *pop.Connection) (*validate.Errors, error) { return validate.Validate( diff --git a/backend/persistence/password_credential_persister.go b/backend/persistence/password_credential_persister.go index d70ac6d05..59729401a 100644 --- a/backend/persistence/password_credential_persister.go +++ b/backend/persistence/password_credential_persister.go @@ -13,6 +13,7 @@ type PasswordCredentialPersister interface { Create(password models.PasswordCredential) error GetByUserID(userId uuid.UUID) (*models.PasswordCredential, error) Update(password models.PasswordCredential) error + Delete(password models.PasswordCredential) error } type passwordCredentialPersister struct { @@ -61,3 +62,12 @@ func (p *passwordCredentialPersister) Update(password models.PasswordCredential) return nil } + +func (p *passwordCredentialPersister) Delete(password models.PasswordCredential) error { + err := p.db.Destroy(&password) + if err != nil { + return fmt.Errorf("failed to delete user: %w", err) + } + + return nil +} diff --git a/backend/persistence/persister.go b/backend/persistence/persister.go index 47e2e7f83..9d04a072a 100644 --- a/backend/persistence/persister.go +++ b/backend/persistence/persister.go @@ -44,6 +44,8 @@ type Persister interface { GetSamlCertificatePersister() SamlCertificatePersister GetSamlCertificatePersisterWithConnection(tx *pop.Connection) SamlCertificatePersister GetWebhookPersister(tx *pop.Connection) WebhookPersister + GetUsernamePersister() UsernamePersister + GetUsernamePersisterWithConnection(tx *pop.Connection) UsernamePersister } type Migrator interface { @@ -150,6 +152,14 @@ func (p *persister) GetPasswordCredentialPersisterWithConnection(tx *pop.Connect return NewPasswordCredentialPersister(tx) } +func (p *persister) GetUsernamePersister() UsernamePersister { + return NewUsernamePersister(p.DB) +} + +func (p *persister) GetUsernamePersisterWithConnection(tx *pop.Connection) UsernamePersister { + return NewUsernamePersister(tx) +} + func (p *persister) GetWebauthnCredentialPersister() WebauthnCredentialPersister { return NewWebauthnCredentialPersister(p.DB) } diff --git a/backend/persistence/primary_email_persister.go b/backend/persistence/primary_email_persister.go index 766b7819a..c7fa59feb 100644 --- a/backend/persistence/primary_email_persister.go +++ b/backend/persistence/primary_email_persister.go @@ -9,6 +9,7 @@ import ( type PrimaryEmailPersister interface { Create(models.PrimaryEmail) error Update(models.PrimaryEmail) error + Delete(models.PrimaryEmail) error } type primaryEmailPersister struct { @@ -44,3 +45,12 @@ func (p *primaryEmailPersister) Update(primaryEmail models.PrimaryEmail) error { return nil } + +func (e *primaryEmailPersister) Delete(primaryEmail models.PrimaryEmail) error { + err := e.db.Destroy(&primaryEmail) + if err != nil { + return fmt.Errorf("failed to delete email: %w", err) + } + + return nil +} diff --git a/backend/persistence/user_persister.go b/backend/persistence/user_persister.go index 99f0a6c97..9388fb1fd 100644 --- a/backend/persistence/user_persister.go +++ b/backend/persistence/user_persister.go @@ -19,6 +19,7 @@ type UserPersister interface { List(page int, perPage int, userId uuid.UUID, email string, sortDirection string) ([]models.User, error) All() ([]models.User, error) Count(userId uuid.UUID, email string) (int, error) + GetByUsername(username string) (*models.User, error) } type userPersister struct { @@ -31,7 +32,17 @@ func NewUserPersister(db *pop.Connection) UserPersister { func (p *userPersister) Get(id uuid.UUID) (*models.User, error) { user := models.User{} - err := p.db.EagerPreload("Emails", "Emails.PrimaryEmail", "Emails.Identities", "WebauthnCredentials").Find(&user, id) + + eagerPreloadFields := []string{ + "Emails", + "Emails.PrimaryEmail", + "Emails.Identities", + "WebauthnCredentials", + "WebauthnCredentials.Transports", + "Username", + "PasswordCredential"} + + err := p.db.EagerPreload(eagerPreloadFields...).Find(&user, id) if err != nil && errors.Is(err, sql.ErrNoRows) { return nil, nil } @@ -61,6 +72,28 @@ func (p *userPersister) GetByEmailAddress(emailAddress string) (*models.User, er return p.Get(*email.UserID) } +func (p *userPersister) GetByUsername(username string) (*models.User, error) { + user := models.User{} + err := p.db.EagerPreload( + "Emails", + "Emails.PrimaryEmail", + "Emails.Identities", + "WebauthnCredentials", + "PasswordCredential", + "Username"). + LeftJoin("usernames", "usernames.user_id = users.id"). + Where("usernames.username = (?)", username). + First(&user) + if err != nil && errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to get user: %w", err) + } + + return &user, nil +} + func (p *userPersister) Create(user models.User) error { vErr, err := p.db.ValidateAndCreate(&user) if err != nil { @@ -102,7 +135,8 @@ func (p *userPersister) List(page int, perPage int, userId uuid.UUID, email stri query := p.db. Q(). EagerPreload("Emails", "Emails.PrimaryEmail", "WebauthnCredentials"). - LeftJoin("emails", "emails.user_id = users.id") + LeftJoin("emails", "emails.user_id = users.id"). + LeftJoin("usernames", "usernames.user_id = users.id") query = p.addQueryParamsToSqlQuery(query, userId, email) err := query.GroupBy("users.id"). Having("count(emails.id) > 0"). @@ -122,7 +156,7 @@ func (p *userPersister) List(page int, perPage int, userId uuid.UUID, email stri func (p *userPersister) All() ([]models.User, error) { users := []models.User{} - err := p.db.EagerPreload("Emails", "Emails.PrimaryEmail", "Emails.Identities", "WebauthnCredentials").All(&users) + err := p.db.EagerPreload("Emails", "Emails.PrimaryEmail", "Emails.Identities", "WebauthnCredentials", "Usernames").All(&users) if err != nil && errors.Is(err, sql.ErrNoRows) { return users, nil } diff --git a/backend/persistence/username_persister.go b/backend/persistence/username_persister.go new file mode 100644 index 000000000..852a79232 --- /dev/null +++ b/backend/persistence/username_persister.go @@ -0,0 +1,72 @@ +package persistence + +import ( + "database/sql" + "errors" + "fmt" + "github.com/gobuffalo/pop/v6" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +type UsernamePersister interface { + Create(username models.Username) error + GetByName(name string) (*models.Username, error) + Update(username *models.Username) error + Delete(username *models.Username) error +} + +type usernamePersister struct { + db *pop.Connection +} + +func NewUsernamePersister(db *pop.Connection) UsernamePersister { + return &usernamePersister{db: db} +} + +func (p *usernamePersister) Create(username models.Username) error { + vErr, err := p.db.ValidateAndCreate(&username) + if err != nil { + return fmt.Errorf("failed to store username credential: %w", err) + } + + if vErr != nil && vErr.HasAny() { + return fmt.Errorf("username object validation failed: %w", vErr) + } + + return nil +} + +func (p *usernamePersister) GetByName(username string) (*models.Username, error) { + pw := models.Username{} + query := p.db.Where("username = (?)", username) + err := query.First(&pw) + if err != nil && errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to get username: %w", err) + } + return &pw, nil +} + +func (p *usernamePersister) Update(username *models.Username) error { + vErr, err := p.db.ValidateAndUpdate(username) + if err != nil { + return fmt.Errorf("failed to update username: %w", err) + } + + if vErr != nil && vErr.HasAny() { + return fmt.Errorf("username object validation failed: %w", vErr) + } + + return nil +} + +func (p *usernamePersister) Delete(username *models.Username) error { + err := p.db.Destroy(username) + if err != nil { + return fmt.Errorf("failed to delete user: %w", err) + } + + return nil +} diff --git a/backend/rate_limiter/rate_limiter.go b/backend/rate_limiter/rate_limiter.go index bdef62034..308dee827 100644 --- a/backend/rate_limiter/rate_limiter.go +++ b/backend/rate_limiter/rate_limiter.go @@ -2,6 +2,7 @@ package rate_limiter import ( "context" + "fmt" "github.com/gofrs/uuid" "github.com/gomodule/redigo/redis" "github.com/labstack/echo/v4" @@ -66,3 +67,27 @@ func Limit(store limiter.Store, userId uuid.UUID, c echo.Context) error { } return nil } + +func Limit2(store limiter.Store, key string) (int, bool, error) { + // Take from the store. + _, _, newTokensAvailableAt, ok, err := store.Take(context.Background(), key) + if err != nil { + return -1, false, fmt.Errorf("failed to take a token from %s", key) + } + + retryAfterSeconds := int(math.Floor(time.Unix(0, int64(newTokensAvailableAt)).UTC().Sub(time.Now().UTC()).Seconds())) + + return retryAfterSeconds, ok, nil +} + +func CreateRateLimitPasscodeKey(realIP, email string) string { + return fmt.Sprintf("passcode/%s/%s", realIP, email) +} + +func CreateRateLimitPasswordKey(realIP, userId string) string { + return fmt.Sprintf("password/%s/%s", realIP, userId) +} + +func CreateRateLimitTokenExchangeKey(realIP string) string { + return fmt.Sprintf("token_exchange/%s", realIP) +} diff --git a/backend/test/audit_logger.go b/backend/test/audit_logger.go index f63ccdbda..775f6b2ad 100644 --- a/backend/test/audit_logger.go +++ b/backend/test/audit_logger.go @@ -14,10 +14,10 @@ func NewAuditLogger() auditlog.Logger { type auditLogger struct { } -func (a *auditLogger) Create(context echo.Context, logType models.AuditLogType, user *models.User, err error) error { +func (a *auditLogger) Create(context echo.Context, logType models.AuditLogType, user *models.User, err error, opts ...auditlog.DetailOption) error { return nil } -func (a *auditLogger) CreateWithConnection(tx *pop.Connection, context echo.Context, logType models.AuditLogType, user *models.User, err error) error { +func (a *auditLogger) CreateWithConnection(tx *pop.Connection, context echo.Context, logType models.AuditLogType, user *models.User, err error, opts ...auditlog.DetailOption) error { return nil } diff --git a/backend/test/config.go b/backend/test/config.go index a5fbfe154..b3b3f752c 100644 --- a/backend/test/config.go +++ b/backend/test/config.go @@ -16,15 +16,21 @@ var DefaultConfig = config.Config{ Secrets: config.Secrets{ Keys: []string{"abcdefghijklmnop"}, }, - Smtp: config.SMTP{ - Host: "localhost", - Port: "2500", + Email: config.Email{ + Enabled: true, + UseForAuthentication: true, }, EmailDelivery: config.EmailDelivery{ Enabled: true, + SMTP: config.SMTP{ + Host: "localhost", + Port: "2500", + }, + FromAddress: "test@hanko.io", + FromName: "Hanko Test", }, Passcode: config.Passcode{ - Email: config.Email{ + Email: config.PasscodeEmail{ FromAddress: "test@hanko.io", FromName: "Hanko Test", }, @@ -43,4 +49,8 @@ var DefaultConfig = config.Config{ AllowSignup: true, AllowDeletion: false, }, + Passkey: config.Passkey{ + Enabled: true, + UserVerification: "preferred", + }, } diff --git a/backend/test/fixtures/actions/get_wa_creation_options/flows.yaml b/backend/test/fixtures/actions/get_wa_creation_options/flows.yaml new file mode 100644 index 000000000..8ad36ee3b --- /dev/null +++ b/backend/test/fixtures/actions/get_wa_creation_options/flows.yaml @@ -0,0 +1,28 @@ +- id: 0b41f4dd-8e46-4a7c-bb4d-d60843113431 + current_state: onboarding_create_passkey + stash_data: "{\"webauthn_available\":\"true\",\"email\":\"example@example.com\",\"username\":\"john.doe\"}" + version: 0 + expires_at: 2099-12-31 23:59:59 + created_at: 2023-01-01 00:00:00 + updated_at: 2023-01-01 00:00:00 +- id: de87cfc6-a6e2-434d-bbe8-5e5004c9deda + current_state: onboarding_create_passkey + stash_data: "{\"webauthn_available\":\"true\",\"username\":\"john.doe\"}" + version: 0 + expires_at: 2099-12-31 23:59:59 + created_at: 2023-01-01 00:00:00 + updated_at: 2023-01-01 00:00:00 +- id: a77e23b2-7ca5-4c76-a20b-c17b7dbcb117 + current_state: onboarding_create_passkey + stash_data: "{\"webauthn_available\":\"true\",\"email\":\"example@example.com\"}" + version: 0 + expires_at: 2099-12-31 23:59:59 + created_at: 2023-01-01 00:00:00 + updated_at: 2023-01-01 00:00:00 +- id: be57518c-6bd5-4b3e-a91a-6c082e212a58 + current_state: onboarding_create_passkey + stash_data: "{\"webauthn_available\":\"false\",\"email\":\"example@example.com\"}" + version: 0 + expires_at: 2099-12-31 23:59:59 + created_at: 2023-01-01 00:00:00 + updated_at: 2023-01-01 00:00:00 diff --git a/backend/test/fixtures/actions/send_capabilities/flows.yaml b/backend/test/fixtures/actions/send_capabilities/flows.yaml new file mode 100644 index 000000000..e0618323e --- /dev/null +++ b/backend/test/fixtures/actions/send_capabilities/flows.yaml @@ -0,0 +1,8 @@ +- id: 0b41f4dd-8e46-4a7c-bb4d-d60843113431 + current_state: registration_preflight + stash_data: {} + version: 0 + expires_at: 2099-12-31 23:59:59 + created_at: 2023-01-01 00:00:00 + updated_at: 2023-01-01 00:00:00 + diff --git a/backend/test/fixtures/actions/send_wa_attestation_response/flows.yaml b/backend/test/fixtures/actions/send_wa_attestation_response/flows.yaml new file mode 100644 index 000000000..77fbd8b1c --- /dev/null +++ b/backend/test/fixtures/actions/send_wa_attestation_response/flows.yaml @@ -0,0 +1,21 @@ +- id: 0b41f4dd-8e46-4a7c-bb4d-d60843113431 + current_state: onboarding_verify_passkey_attestation + stash_data: "{\"webauthn_available\":\"true\",\"webauthn_session_data_id\":\"adce0002-35bc-c60a-648b-0b25f1f05503\",\"user_id\":\"ec4ef049-5b88-4321-a173-21b0eff06a04\",\"email\":\"john.doe@example.com\",\"_\":{\"scheduled_states\":[\"success\"]}}" + version: 0 + expires_at: 2099-12-31 23:59:59 + created_at: 2023-01-01 00:00:00 + updated_at: 2023-01-01 00:00:00 +- id: 53d35f35-c87d-4533-b966-2b48686b9be9 + current_state: onboarding_verify_passkey_attestation + stash_data: "{\"webauthn_available\":\"true\",\"webauthn_session_data_id\":\"65f13ce2-d118-44f0-a38b-8e3ee918c6f3\",\"user_id\":\"ec4ef049-5b88-4321-a173-21b0eff06a04\",\"email\":\"john.doe@example.com\",\"_\":{\"scheduled_states\":[\"success\"]}}" + version: 0 + expires_at: 2099-12-31 23:59:59 + created_at: 2023-01-01 00:00:00 + updated_at: 2023-01-01 00:00:00 +- id: 4447f8df-713c-43c2-ae8d-ce2bc8e29cc7 + current_state: onboarding_verify_passkey_attestation + stash_data: "{\"webauthn_available\":\"false\"}" + version: 0 + expires_at: 2099-12-31 23:59:59 + created_at: 2023-01-01 00:00:00 + updated_at: 2023-01-01 00:00:00 diff --git a/backend/test/fixtures/actions/send_wa_attestation_response/webauthn_session_data.yaml b/backend/test/fixtures/actions/send_wa_attestation_response/webauthn_session_data.yaml new file mode 100644 index 000000000..e46054bac --- /dev/null +++ b/backend/test/fixtures/actions/send_wa_attestation_response/webauthn_session_data.yaml @@ -0,0 +1,17 @@ +- id: adce0002-35bc-c60a-648b-0b25f1f05503 + challenge: "tOrNDCD2xQf4zFjEjwxaP8fOErP3zz08rMoTlJGtnKU" + user_id: "ec4ef049-5b88-4321-a173-21b0eff06a04" + user_verification: "required" + operation: "registration" + created_at: 2020-12-31 23:59:59 + updated_at: 2020-12-31 23:59:59 + +# expired session data +- id: 65f13ce2-d118-44f0-a38b-8e3ee918c6f3 + challenge: "FeMc7sR9ElehwEU5TtEWFi7rPP3-kdZXgnwLtlb3ChY" + user_id: "ec4ef049-5b88-4321-a173-21b0eff06a04" + user_verification: "required" + operation: "registration" + created_at: 2020-12-31 23:59:59 + updated_at: 2020-12-31 23:59:59 + expires_at: 2020-12-31 23:59:59 diff --git a/backend/test/fixtures/actions/submit_new_password/flows.yaml b/backend/test/fixtures/actions/submit_new_password/flows.yaml new file mode 100644 index 000000000..3e10c6bd9 --- /dev/null +++ b/backend/test/fixtures/actions/submit_new_password/flows.yaml @@ -0,0 +1,14 @@ +- id: 0b41f4dd-8e46-4a7c-bb4d-d60843113431 + current_state: password_creation + stash_data: "{\"webauthn_available\":true}" + version: 0 + expires_at: 2099-12-31 23:59:59 + created_at: 2023-01-01 00:00:00 + updated_at: 2023-01-01 00:00:00 +- id: 8a2cf90d-dea5-4678-9dca-6707dab6af77 + current_state: password_creation + stash_data: "{\"webauthn_available\":false}" + version: 0 + expires_at: 2099-12-31 23:59:59 + created_at: 2023-01-01 00:00:00 + updated_at: 2023-01-01 00:00:00 diff --git a/backend/test/fixtures/actions/submit_passcode/flows.yaml b/backend/test/fixtures/actions/submit_passcode/flows.yaml new file mode 100644 index 000000000..20dfd1dd3 --- /dev/null +++ b/backend/test/fixtures/actions/submit_passcode/flows.yaml @@ -0,0 +1,42 @@ +- id: 0b41f4dd-8e46-4a7c-bb4d-d60843113431 + current_state: registration_email_verification + stash_data: "{\"passcode_id\":\"2b10fbae-0286-4719-81a6-b52262a02266\",\"webauthn_available\":true}" + version: 0 + expires_at: 2099-12-31 23:59:59 + created_at: 2023-01-01 00:00:00 + updated_at: 2023-01-01 00:00:00 +- id: 8a2cf90d-dea5-4678-9dca-6707dab6af77 + current_state: registration_email_verification + stash_data: "{\"passcode_id\":\"927b5be2-0add-4d98-b0bb-40c8462b3cca\"}" + version: 0 + expires_at: 2099-12-31 23:59:59 + created_at: 2023-01-01 00:00:00 + updated_at: 2023-01-01 00:00:00 +- id: 23524801-f445-4859-bc16-22cf1dd417ac + current_state: registration_email_verification + stash_data: "{}" + version: 0 + expires_at: 2099-12-31 23:59:59 + created_at: 2023-01-01 00:00:00 + updated_at: 2023-01-01 00:00:00 +- id: fc4dc7e4-bce7-4154-873b-cb3d766df279 + current_state: registration_email_verification + stash_data: "{\"passcode_id\":\"e086ac7e-19df-4e92-9d4a-9b0a02934507\"}" + version: 0 + expires_at: 2099-12-31 23:59:59 + created_at: 2023-01-01 00:00:00 + updated_at: 2023-01-01 00:00:00 +- id: 5a862a2d-0d10-4904-b297-cb32fc43c859 + current_state: registration_email_verification + stash_data: "{\"passcode_id\":\"9b09dcaf-1603-4708-b9e0-239725378576\"}" + version: 0 + expires_at: 2099-12-31 23:59:59 + created_at: 2023-01-01 00:00:00 + updated_at: 2023-01-01 00:00:00 +- id: bc3173e7-3204-4b9a-904b-9f812330b0de + current_state: registration_email_verification + stash_data: "{\"passcode_id\":\"2b10fbae-0286-4719-81a6-b52262a02266\",\"webauthn_available\":false}" + version: 0 + expires_at: 2099-12-31 23:59:59 + created_at: 2023-01-01 00:00:00 + updated_at: 2023-01-01 00:00:00 diff --git a/backend/test/fixtures/actions/submit_passcode/passcodes.yaml b/backend/test/fixtures/actions/submit_passcode/passcodes.yaml new file mode 100644 index 000000000..07fc39f13 --- /dev/null +++ b/backend/test/fixtures/actions/submit_passcode/passcodes.yaml @@ -0,0 +1,21 @@ +- id: 2b10fbae-0286-4719-81a6-b52262a02266 + ttl: 300 + code: $2a$12$pYlnZB/ornewcsD6EwuBPOj14bcc8SnZcgbz5nNuWebnVpMioaIVC + try_count: 0 + created_at: 2025-01-01 00:00:00 + updated_at: 2025-01-01 00:00:00 + flow_id: 0b41f4dd-8e46-4a7c-bb4d-d60843113431 +- id: 927b5be2-0add-4d98-b0bb-40c8462b3cca + ttl: 300 + code: $2a$12$pYlnZB/ornewcsD6EwuBPOj14bcc8SnZcgbz5nNuWebnVpMioaIVC + try_count: 3 + created_at: 2025-01-01 00:00:00 + updated_at: 2025-01-01 00:00:00 + flow_id: 8a2cf90d-dea5-4678-9dca-6707dab6af77 +- id: 9b09dcaf-1603-4708-b9e0-239725378576 + ttl: 300 + code: $2a$12$pYlnZB/ornewcsD6EwuBPOj14bcc8SnZcgbz5nNuWebnVpMioaIVC + try_count: 3 + created_at: 2023-01-01 00:00:00 + updated_at: 2023-01-01 00:00:00 + flow_id: 5a862a2d-0d10-4904-b297-cb32fc43c859 diff --git a/backend/test/fixtures/actions/submit_registration_identifier/emails.yaml b/backend/test/fixtures/actions/submit_registration_identifier/emails.yaml new file mode 100644 index 000000000..1e4b8023b --- /dev/null +++ b/backend/test/fixtures/actions/submit_registration_identifier/emails.yaml @@ -0,0 +1,12 @@ +- id: 7c8a09a9-5418-4e01-9918-968e58982a3a + user_id: b7fe9f28-bc2d-44fc-819e-bcb478656b94 + address: john.doe@example.com + verified: true + created_at: 2023-01-01 00:00:00 + updated_at: 2023-01-01 00:00:00 +- id: c23941fc-382f-46a3-bf0e-ff0f4258c58b + user_id: 0f813887-5479-42d8-b8d7-3e7a2f426516 + address: jane.doe@example.com + verified: true + created_at: 2023-01-01 00:00:00 + updated_at: 2023-01-01 00:00:00 diff --git a/backend/test/fixtures/actions/submit_registration_identifier/flows.yaml b/backend/test/fixtures/actions/submit_registration_identifier/flows.yaml new file mode 100644 index 000000000..cf8f33b19 --- /dev/null +++ b/backend/test/fixtures/actions/submit_registration_identifier/flows.yaml @@ -0,0 +1,7 @@ +- id: 0b41f4dd-8e46-4a7c-bb4d-d60843113431 + current_state: registration_init + stash_data: {} + version: 0 + expires_at: 2099-12-31 23:59:59 + created_at: 2023-01-01 00:00:00 + updated_at: 2023-01-01 00:00:00 diff --git a/backend/test/fixtures/actions/submit_registration_identifier/users.yaml b/backend/test/fixtures/actions/submit_registration_identifier/users.yaml new file mode 100644 index 000000000..070e03b33 --- /dev/null +++ b/backend/test/fixtures/actions/submit_registration_identifier/users.yaml @@ -0,0 +1,11 @@ +- id: b7fe9f28-bc2d-44fc-819e-bcb478656b94 + username: john.doe + created_at: 2023-01-01 00:00:00 + updated_at: 2023-01-01 00:00:00 +- id: 0f813887-5479-42d8-b8d7-3e7a2f426516 + created_at: 2023-01-01 00:00:00 + updated_at: 2023-01-01 00:00:00 +- id: 9ee61f69-952b-400a-adc9-2800aef967fa + username: max.mustermann + created_at: 2023-01-01 00:00:00 + updated_at: 2023-01-01 00:00:00 diff --git a/backend/test/fixtures/token/tokens.yaml b/backend/test/fixtures/token/tokens.yaml index 8b62241b9..3408324e4 100644 --- a/backend/test/fixtures/token/tokens.yaml +++ b/backend/test/fixtures/token/tokens.yaml @@ -5,3 +5,5 @@ updated_at: 2022-03-20T15:12:01.639187Z expires_at: 2022-03-20T15:18:10.168902Z value: "Trkauhl3q7XVxw5JcDH80lTe1KxzydIw0OcizH7umWk=" + user_created: false + is_flow: false diff --git a/backend/test/identity_persister.go b/backend/test/identity_persister.go index 0521d5719..459b162de 100644 --- a/backend/test/identity_persister.go +++ b/backend/test/identity_persister.go @@ -1,6 +1,7 @@ package test import ( + "github.com/gofrs/uuid" "github.com/teamhanko/hanko/backend/persistence" "github.com/teamhanko/hanko/backend/persistence/models" ) @@ -25,6 +26,15 @@ func (i identityPersister) Get(userProviderID string, providerName string) (*mod return nil, nil } +func (i identityPersister) GetByID(identityID uuid.UUID) (*models.Identity, error) { + for _, identity := range i.identities { + if identity.ID == identityID { + return &identity, nil + } + } + return nil, nil +} + func (i identityPersister) Create(identity models.Identity) error { i.identities = append(i.identities, identity) return nil diff --git a/backend/test/password_credential_persister.go b/backend/test/password_credential_persister.go index 388df60fe..36fb18b7a 100644 --- a/backend/test/password_credential_persister.go +++ b/backend/test/password_credential_persister.go @@ -38,3 +38,17 @@ func (p passwordCredentialPersister) Update(password models.PasswordCredential) } return nil } + +func (p *passwordCredentialPersister) Delete(password models.PasswordCredential) error { + index := -1 + for i, data := range p.passwords { + if data.ID == password.ID { + index = i + } + } + if index > -1 { + p.passwords = append(p.passwords[:index], p.passwords[index+1:]...) + } + + return nil +} diff --git a/backend/test/persister.go b/backend/test/persister.go index 603c59a01..43aede38d 100644 --- a/backend/test/persister.go +++ b/backend/test/persister.go @@ -33,6 +33,7 @@ func NewPersister( passwordCredentialPersister: NewPasswordCredentialPersister(passwords), auditLogPersister: NewAuditLogPersister(auditLogs), emailPersister: NewEmailPersister(emails), + usernamePersister: NewUsernamePersister(nil), primaryEmailPersister: NewPrimaryEmailPersister(primaryEmails), identityPersister: NewIdentityPersister(identities), tokenPersister: NewTokenPersister(tokens), @@ -51,6 +52,7 @@ type persister struct { passwordCredentialPersister persistence.PasswordCredentialPersister auditLogPersister persistence.AuditLogPersister emailPersister persistence.EmailPersister + usernamePersister persistence.UsernamePersister primaryEmailPersister persistence.PrimaryEmailPersister identityPersister persistence.IdentityPersister tokenPersister persistence.TokenPersister @@ -131,6 +133,14 @@ func (p *persister) GetEmailPersisterWithConnection(tx *pop.Connection) persiste return p.emailPersister } +func (p *persister) GetUsernamePersister() persistence.UsernamePersister { + return p.usernamePersister +} + +func (p *persister) GetUsernamePersisterWithConnection(tx *pop.Connection) persistence.UsernamePersister { + return p.usernamePersister +} + func (p *persister) GetPrimaryEmailPersister() persistence.PrimaryEmailPersister { return p.primaryEmailPersister } diff --git a/backend/test/primary_email_persister.go b/backend/test/primary_email_persister.go index 8c41ce822..615407598 100644 --- a/backend/test/primary_email_persister.go +++ b/backend/test/primary_email_persister.go @@ -26,3 +26,17 @@ func (p *primaryEmailPersister) Update(primaryEmail models.PrimaryEmail) error { } return nil } + +func (p *primaryEmailPersister) Delete(primaryEmail models.PrimaryEmail) error { + index := -1 + for i, data := range p.primaryEmails { + if data.ID == primaryEmail.ID { + index = i + } + } + if index > -1 { + p.primaryEmails = append(p.primaryEmails[:index], p.primaryEmails[index+1:]...) + } + + return nil +} diff --git a/backend/test/user_persister.go b/backend/test/user_persister.go index ab4cf47a8..1cb24ef6a 100644 --- a/backend/test/user_persister.go +++ b/backend/test/user_persister.go @@ -14,6 +14,17 @@ type userPersister struct { users []models.User } +func (p *userPersister) GetByUsername(username string) (*models.User, error) { + var found *models.User + for _, data := range p.users { + if data.GetUsername() == username { + d := data + found = &d + } + } + return found, nil +} + func (p *userPersister) Get(id uuid.UUID) (*models.User, error) { var found *models.User for _, data := range p.users { diff --git a/backend/test/username_persister.go b/backend/test/username_persister.go new file mode 100644 index 000000000..c120650f9 --- /dev/null +++ b/backend/test/username_persister.go @@ -0,0 +1,53 @@ +package test + +import ( + "github.com/teamhanko/hanko/backend/persistence" + "github.com/teamhanko/hanko/backend/persistence/models" +) + +func NewUsernamePersister(init []models.Username) persistence.UsernamePersister { + return &usernamePersister{append([]models.Username{}, init...)} +} + +type usernamePersister struct { + usernames []models.Username +} + +func (u *usernamePersister) Create(username models.Username) error { + u.usernames = append(u.usernames, username) + return nil +} + +func (u *usernamePersister) GetByName(name string) (*models.Username, error) { + var found *models.Username + for _, data := range u.usernames { + if data.Username == name { + d := data + found = &d + } + } + return found, nil +} + +func (u *usernamePersister) Update(username *models.Username) error { + for i, data := range u.usernames { + if data.ID == username.ID { + u.usernames[i] = *username + } + } + return nil +} + +func (u *usernamePersister) Delete(username *models.Username) error { + index := -1 + for i, data := range u.usernames { + if data.ID == username.ID { + index = i + } + } + if index > -1 { + u.usernames = append(u.usernames[:index], u.usernames[index+1:]...) + } + + return nil +} diff --git a/backend/thirdparty/linking.go b/backend/thirdparty/linking.go index 5b28a9188..00984aebb 100644 --- a/backend/thirdparty/linking.go +++ b/backend/thirdparty/linking.go @@ -13,15 +13,18 @@ type AccountLinkingResult struct { Type models.AuditLogType User *models.User WebhookEvent *events.Event + UserCreated bool } const ( getIdentityFailure = "could not get identity" ) -func LinkAccount(tx *pop.Connection, cfg *config.Config, p persistence.Persister, userData *UserData, providerName string, isSaml bool) (*AccountLinkingResult, error) { - if cfg.Emails.RequireVerification && !userData.Metadata.EmailVerified { - return nil, ErrorUnverifiedProviderEmail("third party provider email must be verified") +func LinkAccount(tx *pop.Connection, cfg *config.Config, p persistence.Persister, userData *UserData, providerName string, isSaml bool, isFlow bool) (*AccountLinkingResult, error) { + if !isFlow { + if cfg.Email.RequireVerification && !userData.Metadata.EmailVerified { + return nil, ErrorUnverifiedProviderEmail("third party provider email must be verified") + } } identity, err := p.GetIdentityPersister().Get(userData.Metadata.Subject, providerName) @@ -61,10 +64,16 @@ func link(tx *pop.Connection, cfg *config.Config, p persistence.Persister, userD return nil, ErrorServer(getIdentityFailure).WithCause(err) } + u, terr := p.GetUserPersisterWithConnection(tx).Get(*email.UserID) + if terr != nil { + return nil, ErrorServer("could not get user").WithCause(terr) + } + return &AccountLinkingResult{ Type: models.AuditLogThirdPartyLinkingSucceeded, - User: user, + User: u, WebhookEvent: nil, + UserCreated: false, }, nil } @@ -116,7 +125,7 @@ func signIn(tx *pop.Connection, cfg *config.Config, p persistence.Persister, use return nil, ErrorServer("failed to count user emails").WithCause(err) } - if emailCount >= cfg.Emails.MaxNumOfAddresses { + if emailCount >= cfg.Email.Limit { return nil, ErrorMaxNumberOfAddresses("max number of email addresses reached") } @@ -147,6 +156,7 @@ func signIn(tx *pop.Connection, cfg *config.Config, p persistence.Persister, use Type: models.AuditLogThirdPartySignInSucceeded, User: user, WebhookEvent: &webhookEvent, + UserCreated: false, } return linkingResult, nil @@ -221,6 +231,7 @@ func signUp(tx *pop.Connection, cfg *config.Config, p persistence.Persister, use Type: models.AuditLogThirdPartySignUpSucceeded, User: u, WebhookEvent: &evt, + UserCreated: true, } return linkingResult, nil diff --git a/backend/thirdparty/state.go b/backend/thirdparty/state.go index 0afba9e49..55d769f33 100644 --- a/backend/thirdparty/state.go +++ b/backend/thirdparty/state.go @@ -10,7 +10,13 @@ import ( "time" ) -func GenerateState(config *config.Config, provider string, redirectTo string) ([]byte, error) { +func GenerateStateForFlowAPI(isFlow bool) func(*State) { + return func(state *State) { + state.IsFlow = isFlow + } +} + +func GenerateState(config *config.Config, provider string, redirectTo string, options ...func(*State)) ([]byte, error) { if provider == "" { return nil, errors.New("provider must be present") } @@ -33,6 +39,10 @@ func GenerateState(config *config.Config, provider string, redirectTo string) ([ Nonce: nonce, } + for _, option := range options { + option(&state) + } + stateJson, err := json.Marshal(state) aes, err := aes_gcm.NewAESGCM(config.Secrets.Keys) @@ -54,6 +64,7 @@ type State struct { IssuedAt time.Time `json:"issued_at"` ExpiresAt time.Time `json:"expires_at"` Nonce string `json:"nonce"` + IsFlow bool `json:"is_flow"` } func VerifyState(config *config.Config, state string, expectedState string) (*State, error) { diff --git a/backend/utils/mask.go b/backend/utils/mask.go new file mode 100644 index 000000000..663cd8861 --- /dev/null +++ b/backend/utils/mask.go @@ -0,0 +1,64 @@ +package utils + +import "strings" + +var mask = "*" + +func MaskEmail(email string) string { + if len(email) == 0 { + return "" + } + + tmp := strings.Split(email, "@") + + name := tmp[0] + domain := tmp[1] + + nameRunes := []rune(name) + nameRunesLen := len(nameRunes) + + if nameRunesLen == 0 { + return strings.Repeat(mask, 6) + "@" + domain + } + + var maskStart, padLength int + if nameRunesLen <= 6 { + maskStart = 1 + padLength = 6 - nameRunesLen + } else { + maskStart = 3 + + } + + maskedAddress := "" + maskedAddress += string(nameRunes[:maskStart]) + maskedAddress += strings.Repeat(mask, len(nameRunes[maskStart:])+padLength) + maskedAddress += "@" + domain + + return maskedAddress +} + +func MaskUsername(username string) string { + usernameRunes := []rune(username) + usernameRunesLen := len(usernameRunes) + + if usernameRunesLen == 0 { + return "" + } + + if usernameRunesLen == 1 { + return mask + } + + padLength := 0 + if usernameRunesLen <= 3 { + padLength = 6 - usernameRunesLen + } + + maskedUsername := "" + maskedUsername += string(usernameRunes[0]) + maskedUsername += strings.Repeat(mask, usernameRunesLen-2+padLength) + maskedUsername += string(usernameRunes[usernameRunesLen-1]) + + return maskedUsername +} diff --git a/backend/utils/mask_test.go b/backend/utils/mask_test.go new file mode 100644 index 000000000..b1256ab38 --- /dev/null +++ b/backend/utils/mask_test.go @@ -0,0 +1,80 @@ +package utils + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestMaskEmail(t *testing.T) { + type args struct { + email string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "return empty string on empty email address", + args: args{""}, + want: "", + }, + { + name: "empty name part", + args: args{"@domain.com"}, + want: "******@domain.com", + }, + { + name: "mask start reduced and padding applied when name length < 6", + args: args{"123@domain.com"}, + want: "1*****@domain.com", + }, + { + name: "start mask at index 4 and mask everything until '@' rune", + args: args{"really_long_test_email_help_when_does_it_stop@domain.com"}, + want: "rea******************************************@domain.com", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, MaskEmail(tt.args.email), "MaskEmail(%v)", tt.args.email) + }) + } +} + +func TestMaskUsername(t *testing.T) { + type args struct { + username string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "return empty string on empty username", + args: args{username: ""}, + want: "", + }, + { + name: "mask everything if username length == 1", + args: args{username: "X"}, + want: "*", + }, + { + name: "mask and pad when username length 2 or 3", + args: args{username: "xx"}, + want: "x****x", + }, + { + name: "mask everything but first and last rune when username size > 3", + args: args{username: "test_username"}, + want: "t***********e", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, MaskUsername(tt.args.username), "MaskUsername(%v)", tt.args.username) + }) + } +} diff --git a/deploy/docker-compose/config.yaml b/deploy/docker-compose/config.yaml index 4515d139c..0c313592d 100644 --- a/deploy/docker-compose/config.yaml +++ b/deploy/docker-compose/config.yaml @@ -4,12 +4,11 @@ database: host: postgresd port: 5432 dialect: postgres -smtp: - host: "mailslurper" - port: "2500" -passcode: - email: - from_address: no-reply@hanko.io +email_delivery: + smtp: + host: "mailslurper" + port: "2500" + from_address: noreply@hanko.io secrets: keys: - abcedfghijklmnopqrstuvwxyz diff --git a/docs/static/jsdoc/hanko-frontend-sdk/AccountConfig.html b/docs/static/jsdoc/hanko-frontend-sdk/AccountConfig.html index 68dc2bd0c..d3a52b8c0 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/AccountConfig.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/AccountConfig.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/AuthFlowCompletedDetail.html b/docs/static/jsdoc/hanko-frontend-sdk/AuthFlowCompletedDetail.html deleted file mode 100644 index c84d7c32f..000000000 --- a/docs/static/jsdoc/hanko-frontend-sdk/AuthFlowCompletedDetail.html +++ /dev/null @@ -1,249 +0,0 @@ - - - - - - - - AuthFlowCompletedDetail - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - -
-
-
- -
-
-
-

Interface

-

AuthFlowCompletedDetail

-
- - - - - -
- -
- -

AuthFlowCompletedDetail

- - -
- -
-
- - -
The data passed in the `hanko-auth-flow-completed` event.
- - - - - -
Properties:
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
userID - - -string - - - - The user associated with the removed session.
-
- - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/events/CustomEvents.ts, line 65 - -

- -
- - - - -
- - - - - - - - - - - - - - - - - - - - -
- -
- - - - -
- - - -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/static/jsdoc/hanko-frontend-sdk/Client.html b/docs/static/jsdoc/hanko-frontend-sdk/Client.html index a94ac628e..8f8d13e30 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/Client.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/Client.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/Config.html b/docs/static/jsdoc/hanko-frontend-sdk/Config.html index d2b81c635..0ae4ff749 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/Config.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/Config.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/ConfigClient.html b/docs/static/jsdoc/hanko-frontend-sdk/ConfigClient.html deleted file mode 100644 index 477c9e09b..000000000 --- a/docs/static/jsdoc/hanko-frontend-sdk/ConfigClient.html +++ /dev/null @@ -1,523 +0,0 @@ - - - - - - - - ConfigClient - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - -
-
-
- -
-
-
-

Class

-

ConfigClient

-
- - - - - -
- -
- -

ConfigClient()

- -
A class for retrieving configurations from the API.
- - -
- -
-
- - -
-
-
-
- Constructor -
- - - - -

- # - - - - new ConfigClient() - - -

- - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/client/ConfigClient.ts, line 11 - -

- -
- - - - - - - - - - - - - - - - - - - - - - -
-
-
- - -
- - -

Extends

- - - - - - - - - - - - - - - - - - - - -
-

Members

-
- -
- - - - -HttpClient - - - - -

- # - - - client - - -

- - - - - - - - -
- - - - - - -
Inherited From:
-
- - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/client/Client.ts, line 20 - -

- -
- - - - - -
- -
-
- - - -
-

Methods

-
- -
- - - -

- # - - - async - - - - - get() → {Promise.<Config>} - - -

- - - - -
- Retrieves the frontend configuration. -
- - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
See:
-
- -
- - - - - -

- View Source - - lib/client/ConfigClient.ts, line 42 - -

- -
- - - - - - - - - - - - - - - - -
-
-
- - -
- - -
- -RequestTimeoutError - - -
- - -
- - - -
- - -
- -TechnicalError - - -
- - -
- - -
-
- - - -
-
-
- - - -
- - -
- - -Promise.<Config> - - -
- -
- - -
-
- - - - -
- -
-
- - - - - -
- -
- - - - -
- - - -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/static/jsdoc/hanko-frontend-sdk/ConflictError.html b/docs/static/jsdoc/hanko-frontend-sdk/ConflictError.html index 290110a4a..b145c3d9a 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/ConflictError.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/ConflictError.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/Cookie.html b/docs/static/jsdoc/hanko-frontend-sdk/Cookie.html index 0a83c5347..c55a8bc7d 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/Cookie.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/Cookie.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/CookieOptions.html b/docs/static/jsdoc/hanko-frontend-sdk/CookieOptions.html index 8ebeb664d..4ac2273fe 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/CookieOptions.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/CookieOptions.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/Credential.html b/docs/static/jsdoc/hanko-frontend-sdk/Credential.html index 2bb4c712f..c9ca25b98 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/Credential.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/Credential.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/Dispatcher.html b/docs/static/jsdoc/hanko-frontend-sdk/Dispatcher.html index 9ef32ab74..0c9c477fc 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/Dispatcher.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/Dispatcher.html @@ -66,7 +66,7 @@ @@ -282,154 +282,6 @@

Methods

-

- # - - - - dispatchAuthFlowCompletedEvent(detail) - - -

- - - - -
- Dispatches a "hanko-auth-flow-completed" event to the document with the specified detail. -
- - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
detail - - -AuthFlowCompletedDetail - - - - The event detail.
-
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/events/Dispatcher.ts, line 119 - -

- -
- - - - - - - - - - - - - - - - - - - - - - - -
- - -

# @@ -546,7 +398,7 @@

Parameters:

View Source - lib/events/Dispatcher.ts, line 93 + lib/events/Dispatcher.ts, line 84

@@ -643,7 +495,7 @@

View Source - lib/events/Dispatcher.ts, line 99 + lib/events/Dispatcher.ts, line 90

@@ -740,7 +592,7 @@

View Source - lib/events/Dispatcher.ts, line 111 + lib/events/Dispatcher.ts, line 102

@@ -837,7 +689,7 @@

View Source - lib/events/Dispatcher.ts, line 105 + lib/events/Dispatcher.ts, line 96

diff --git a/docs/static/jsdoc/hanko-frontend-sdk/DispatcherOptions.html b/docs/static/jsdoc/hanko-frontend-sdk/DispatcherOptions.html index a056169ca..79c7de0a4 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/DispatcherOptions.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/DispatcherOptions.html @@ -66,7 +66,7 @@

@@ -187,7 +187,7 @@
Properties:

View Source - lib/events/Dispatcher.ts, line 67 + lib/events/Dispatcher.ts, line 58

diff --git a/docs/static/jsdoc/hanko-frontend-sdk/Email.html b/docs/static/jsdoc/hanko-frontend-sdk/Email.html index a3987c45e..2ff4aa242 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/Email.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/Email.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/EmailAddressAlreadyExistsError.html b/docs/static/jsdoc/hanko-frontend-sdk/EmailAddressAlreadyExistsError.html index 6c6367c7b..c76169706 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/EmailAddressAlreadyExistsError.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/EmailAddressAlreadyExistsError.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/EmailClient.html b/docs/static/jsdoc/hanko-frontend-sdk/EmailClient.html index c27a87185..c2dbe5745 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/EmailClient.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/EmailClient.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/EmailConfig.html b/docs/static/jsdoc/hanko-frontend-sdk/EmailConfig.html index 71b6089f1..25e63bfce 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/EmailConfig.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/EmailConfig.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/Emails.html b/docs/static/jsdoc/hanko-frontend-sdk/Emails.html index 3169e6d23..f21503e67 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/Emails.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/Emails.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/EnterpriseClient.html b/docs/static/jsdoc/hanko-frontend-sdk/EnterpriseClient.html index d72860934..cfab0e9b8 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/EnterpriseClient.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/EnterpriseClient.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/ForbiddenError.html b/docs/static/jsdoc/hanko-frontend-sdk/ForbiddenError.html index 25ba3d80b..590eae5ac 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/ForbiddenError.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/ForbiddenError.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/Hanko.html b/docs/static/jsdoc/hanko-frontend-sdk/Hanko.html index f3a337115..6acb13568 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/Hanko.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/Hanko.html @@ -66,7 +66,7 @@ @@ -263,7 +263,7 @@
Parameters:

View Source - Hanko.ts, line 21 + Hanko.ts, line 18

@@ -330,79 +330,6 @@

Members

-ConfigClient - - - - -

- # - - - config - - -

- - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - Hanko.ts, line 62 - -

- -
- - - - - - - -
- - - - EmailClient @@ -459,7 +386,7 @@

View Source - Hanko.ts, line 87 + Hanko.ts, line 61

@@ -532,80 +459,7 @@

View Source - Hanko.ts, line 97 - -

- - - - - - - -

- -
- - - - -PasscodeClient - - - - -

- # - - - passcode - - -

- - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - Hanko.ts, line 82 + Hanko.ts, line 71

@@ -622,16 +476,16 @@

-PasswordClient +Flow -

- # +

+ # - password + flow

@@ -678,7 +532,7 @@

View Source - Hanko.ts, line 77 + Hanko.ts, line 91

@@ -751,7 +605,7 @@

View Source - Hanko.ts, line 107 + Hanko.ts, line 81

@@ -824,7 +678,7 @@

View Source - Hanko.ts, line 112 + Hanko.ts, line 86

@@ -897,7 +751,7 @@

View Source - Hanko.ts, line 92 + Hanko.ts, line 66

@@ -970,7 +824,7 @@

View Source - Hanko.ts, line 102 + Hanko.ts, line 76

@@ -1043,80 +897,7 @@

View Source - Hanko.ts, line 67 - -

- -

- - - - - -
- -
- - - - -WebauthnClient - - - - -

- # - - - webauthn - - -

- - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - Hanko.ts, line 72 + Hanko.ts, line 56

@@ -1141,229 +922,6 @@

Methods

-

- # - - - - onAuthFlowCompleted(callback, onceopt) → {CleanupFunc} - - -

- - - - -
- Adds an event listener for hanko-auth-flow-completed events. Will be triggered after the login or registration flow has been completed. -
- - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeAttributesDescription
callback - - -CallbackFunc.<AuthFlowCompletedDetail> - - - - - - - - - - The function to be called when the event is triggered.
once - - -boolean - - - - - - <optional>
- - - - - -
Whether the event listener should be removed after being called once.
-
- - - - - -
- - - - - - - - -
Overrides:
-
- - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/events/Listener.ts, line 131 - -

- -
- - - - - - - - - - - - - - - - - - -
-
-
- - - -
- -
This function can be called to remove the event listener.
- - -
- - -CleanupFunc - - -
- -
- - -
-
- - - - -
- -
- - -

# diff --git a/docs/static/jsdoc/hanko-frontend-sdk/Hanko.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/Hanko.ts.html index 76b874259..a42fd2e67 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/Hanko.ts.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/Hanko.ts.html @@ -68,7 +68,7 @@

@@ -85,12 +85,8 @@

Hanko.ts

-
import { ConfigClient } from "./lib/client/ConfigClient";
-import { EnterpriseClient } from "./lib/client/EnterpriseClient";
-import { PasscodeClient } from "./lib/client/PasscodeClient";
-import { PasswordClient } from "./lib/client/PasswordClient";
+            
import { EnterpriseClient } from "./lib/client/EnterpriseClient";
 import { UserClient } from "./lib/client/UserClient";
-import { WebauthnClient } from "./lib/client/WebauthnClient";
 import { EmailClient } from "./lib/client/EmailClient";
 import { ThirdPartyClient } from "./lib/client/ThirdPartyClient";
 import { TokenClient } from "./lib/client/TokenClient";
@@ -98,6 +94,7 @@ 

Hanko.ts

import { Relay } from "./lib/events/Relay"; import { Session } from "./lib/Session"; import { CookieSameSite } from "./lib/Cookie"; +import { Flow } from "./lib/flow-api/Flow"; /** * The options for the Hanko class @@ -126,17 +123,14 @@

Hanko.ts

*/ class Hanko extends Listener { api: string; - config: ConfigClient; user: UserClient; - webauthn: WebauthnClient; - password: PasswordClient; - passcode: PasscodeClient; email: EmailClient; thirdParty: ThirdPartyClient; enterprise: EnterpriseClient; token: TokenClient; relay: Relay; session: Session; + flow: Flow; // eslint-disable-next-line require-jsdoc constructor(api: string, options?: HankoOptions) { @@ -163,31 +157,11 @@

Hanko.ts

} this.api = api; - /** - * @public - * @type {ConfigClient} - */ - this.config = new ConfigClient(api, opts); /** * @public * @type {UserClient} */ this.user = new UserClient(api, opts); - /** - * @public - * @type {WebauthnClient} - */ - this.webauthn = new WebauthnClient(api, opts); - /** - * @public - * @type {PasswordClient} - */ - this.password = new PasswordClient(api, opts); - /** - * @public - * @type {PasscodeClient} - */ - this.passcode = new PasscodeClient(api, opts); /** * @public * @type {EmailClient} @@ -218,6 +192,11 @@

Hanko.ts

* @type {Session} */ this.session = new Session({ ...opts }); + /** + * @public + * @type {Flow} + */ + this.flow = new Flow(api, opts); } } diff --git a/docs/static/jsdoc/hanko-frontend-sdk/HankoError.html b/docs/static/jsdoc/hanko-frontend-sdk/HankoError.html index cd5e991b7..cb286ff11 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/HankoError.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/HankoError.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/HankoOptions.html b/docs/static/jsdoc/hanko-frontend-sdk/HankoOptions.html index 8b3839ab9..0cf08b67a 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/HankoOptions.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/HankoOptions.html @@ -66,7 +66,7 @@ @@ -321,7 +321,7 @@
Properties:

View Source - Hanko.ts, line 130 + Hanko.ts, line 106

diff --git a/docs/static/jsdoc/hanko-frontend-sdk/Headers.html b/docs/static/jsdoc/hanko-frontend-sdk/Headers.html index 3fb4b5912..e0ccd1ddb 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/Headers.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/Headers.html @@ -66,7 +66,7 @@ @@ -225,7 +225,7 @@
Parameters:

View Source - lib/client/HttpClient.ts, line 15 + lib/client/HttpClient.ts, line 14

@@ -398,7 +398,7 @@
Parameters:

View Source - lib/client/HttpClient.ts, line 287 + lib/client/HttpClient.ts, line 275

diff --git a/docs/static/jsdoc/hanko-frontend-sdk/HttpClient.html b/docs/static/jsdoc/hanko-frontend-sdk/HttpClient.html index 89e9d6e6d..a91e55a5a 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/HttpClient.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/HttpClient.html @@ -66,7 +66,7 @@ @@ -249,7 +249,7 @@
Parameters:

View Source - lib/client/HttpClient.ts, line 117 + lib/client/HttpClient.ts, line 116

@@ -422,7 +422,7 @@
Parameters:

View Source - lib/client/HttpClient.ts, line 378 + lib/client/HttpClient.ts, line 364

@@ -630,7 +630,7 @@
Parameters:

View Source - lib/client/HttpClient.ts, line 335 + lib/client/HttpClient.ts, line 321

@@ -883,7 +883,7 @@
Parameters:

View Source - lib/client/HttpClient.ts, line 368 + lib/client/HttpClient.ts, line 354

@@ -1136,7 +1136,7 @@
Parameters:

View Source - lib/client/HttpClient.ts, line 346 + lib/client/HttpClient.ts, line 332

@@ -1228,12 +1228,12 @@
Parameters:
-

- # +

+ # - processResponseHeadersOnLogin(userID, response) + processHeaders(xhr)

@@ -1242,8 +1242,7 @@

- Processes the response headers on login and extracts the JWT and expiration time. Also, the passcode state will be -removed, the session state updated und a `hanko-session-created` event will be dispatched. + Processes the response headers on login and extracts the JWT and expiration time.
@@ -1281,38 +1280,13 @@

Parameters:
- userID + xhr -string - - - - - - - - - - The user ID. - - - - - - - - - response - - - - - -Response +XMLHttpRequest @@ -1322,7 +1296,7 @@
Parameters:
- The HTTP response object. + The xhr object. @@ -1370,7 +1344,7 @@
Parameters:

View Source - lib/client/HttpClient.ts, line 325 + lib/client/HttpClient.ts, line 311

@@ -1563,7 +1537,7 @@
Parameters:

View Source - lib/client/HttpClient.ts, line 357 + lib/client/HttpClient.ts, line 343

diff --git a/docs/static/jsdoc/hanko-frontend-sdk/HttpClientOptions.html b/docs/static/jsdoc/hanko-frontend-sdk/HttpClientOptions.html index 8c2ab2c22..eb321a416 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/HttpClientOptions.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/HttpClientOptions.html @@ -66,7 +66,7 @@ @@ -284,7 +284,7 @@
Properties:

View Source - lib/client/HttpClient.ts, line 305 + lib/client/HttpClient.ts, line 293

diff --git a/docs/static/jsdoc/hanko-frontend-sdk/Identity.html b/docs/static/jsdoc/hanko-frontend-sdk/Identity.html index 19efd56fc..8658dcb47 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/Identity.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/Identity.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/InvalidPasscodeError.html b/docs/static/jsdoc/hanko-frontend-sdk/InvalidPasscodeError.html index d72e7c899..ca43adbb8 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/InvalidPasscodeError.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/InvalidPasscodeError.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/InvalidPasswordError.html b/docs/static/jsdoc/hanko-frontend-sdk/InvalidPasswordError.html index 51b22d374..6527a8fd0 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/InvalidPasswordError.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/InvalidPasswordError.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/InvalidWebauthnCredentialError.html b/docs/static/jsdoc/hanko-frontend-sdk/InvalidWebauthnCredentialError.html index 35f5d336b..a15ceffdf 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/InvalidWebauthnCredentialError.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/InvalidWebauthnCredentialError.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/Listener.html b/docs/static/jsdoc/hanko-frontend-sdk/Listener.html index 5b916451e..8528aa0f6 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/Listener.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/Listener.html @@ -66,7 +66,7 @@ @@ -224,12 +224,12 @@

Methods

-

- # +

+ # - onAuthFlowCompleted(callback, onceopt) → {CleanupFunc} + onSessionCreated(callback, onceopt) → {CleanupFunc}

@@ -238,7 +238,8 @@

- Adds an event listener for hanko-auth-flow-completed events. Will be triggered after the login or registration flow has been completed. + Adds an event listener for "hanko-session-created" events. Will be triggered across all browser windows, when the user +logs in, or when the page has been loaded or refreshed and there is a valid session.
@@ -284,7 +285,7 @@

Parameters:
-CallbackFunc.<AuthFlowCompletedDetail> +CallbackFunc.<SessionDetail> @@ -385,7 +386,7 @@
Parameters:

View Source - lib/events/Listener.ts, line 280 + lib/events/Listener.ts, line 227

@@ -442,12 +443,12 @@
Parameters:
-

- # +

+ # - onSessionCreated(callback, onceopt) → {CleanupFunc} + onSessionExpired(callback, onceopt) → {CleanupFunc}

@@ -456,8 +457,9 @@

- Adds an event listener for "hanko-session-created" events. Will be triggered across all browser windows, when the user -logs in, or when the page has been loaded or refreshed and there is a valid session. + Adds an event listener for "hanko-session-expired" events. The event will be triggered across all browser windows +as soon as the current JWT expires or the user logs out. It also triggers, when the user deletes the account in +another window.
@@ -503,7 +505,7 @@

Parameters:
-CallbackFunc.<SessionDetail> +CallbackFunc.<null> @@ -604,7 +606,7 @@
Parameters:

View Source - lib/events/Listener.ts, line 237 + lib/events/Listener.ts, line 239

@@ -661,12 +663,12 @@
Parameters:
-

- # +

+ # - onSessionExpired(callback, onceopt) → {CleanupFunc} + onUserDeleted(callback, onceopt) → {CleanupFunc}

@@ -675,9 +677,7 @@

- Adds an event listener for "hanko-session-expired" events. The event will be triggered across all browser windows -as soon as the current JWT expires or the user logs out. It also triggers, when the user deletes the account in -another window. + Adds an event listener for hanko-user-deleted events. The event triggers, when the user has deleted the account.
@@ -824,7 +824,7 @@

Parameters:

View Source - lib/events/Listener.ts, line 249 + lib/events/Listener.ts, line 260

@@ -881,12 +881,12 @@
Parameters:
-

- # +

+ # - onUserDeleted(callback, onceopt) → {CleanupFunc} + onUserLoggedOut(callback, onceopt) → {CleanupFunc}

@@ -895,7 +895,8 @@

- Adds an event listener for hanko-user-deleted events. The event triggers, when the user has deleted the account. + Adds an event listener for hanko-user-deleted events. The event triggers, when the user has deleted the account in +the browser window where the deletion happened.
@@ -1042,7 +1043,7 @@

Parameters:

View Source - lib/events/Listener.ts, line 270 + lib/events/Listener.ts, line 250

@@ -1095,16 +1096,25 @@
Parameters:
+ + + + + +
+

Type Definitions

+
+
-

- # +

+ # - onUserLoggedOut(callback, onceopt) → {CleanupFunc} + CallbackFunc(detail)

@@ -1113,8 +1123,7 @@

- Adds an event listener for hanko-user-deleted events. The event triggers, when the user has deleted the account in -the browser window where the deletion happened. + A callback function to be executed when an event is triggered.
@@ -1139,8 +1148,6 @@

Parameters:
Type - Attributes - @@ -1154,66 +1161,23 @@
Parameters:
- callback - - - - - -CallbackFunc.<null> - - - - - - - - - - - - - - - - - - The function to be called when the event is triggered. - - - - - - - - - once + detail -boolean +T - - - <optional>
- - - - - - - - Whether the event listener should be removed after being called once. + @@ -1261,7 +1225,7 @@
Parameters:

View Source - lib/events/Listener.ts, line 260 + lib/events/Listener.ts, line 128

@@ -1284,55 +1248,21 @@
Parameters:
-
-
-
- - -
- -
This function can be called to remove the event listener.
- - -
- - -CleanupFunc -
-
- - -
-
- - - - -
- -
-
- - - -
-

Type Definitions

-
-

- # +

+ # - CallbackFunc(detail) + CleanupFunc()

@@ -1341,7 +1271,8 @@

- A callback function to be executed when an event is triggered. + A function returned when adding an event listener. The function can be called to remove the corresponding event +listener.
@@ -1353,57 +1284,6 @@

-

Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
detail - - -T - - - -
-
- @@ -1443,7 +1323,7 @@
Parameters:

View Source - lib/events/Listener.ts, line 138 + lib/events/Listener.ts, line 145

@@ -1473,34 +1353,29 @@
Parameters:
- - -

- # + + - - - CleanupFunc() - - -

- - - - -
- A function returned when adding an event listener. The function can be called to remove the corresponding event -listener. -
- - +string + +

+ # + + + sessionCreatedType + + +

+
+ The type of the `hanko-session-created` event. +
@@ -1539,9 +1414,9 @@

- View Source + View Source - lib/events/Listener.ts, line 155 + lib/events/CustomEvents.ts, line 2

@@ -1550,22 +1425,6 @@

- - - - - - - - - - - - - - - -

@@ -1579,11 +1438,11 @@

-

- # +

+ # - authFlowCompletedType + sessionExpiredType

@@ -1592,7 +1451,7 @@

- The type of the `hanko-auth-flow-completed` event. + The type of the `hanko-session-expired` event.
@@ -1634,7 +1493,7 @@

View Source - lib/events/CustomEvents.ts, line 26 + lib/events/CustomEvents.ts, line 8

@@ -1656,11 +1515,11 @@

-

- # +

+ # - sessionCreatedType + userCreatedType

@@ -1669,7 +1528,7 @@

- The type of the `hanko-session-created` event. + The type of the `hanko-user-created` event.
@@ -1711,7 +1570,7 @@

View Source - lib/events/CustomEvents.ts, line 2 + lib/events/CustomEvents.ts, line 32

@@ -1733,11 +1592,11 @@

-

- # +

+ # - sessionExpiredType + userDeletedType

@@ -1746,7 +1605,7 @@

- The type of the `hanko-session-expired` event. + The type of the `hanko-user-deleted` event.
@@ -1788,7 +1647,7 @@

View Source - lib/events/CustomEvents.ts, line 8 + lib/events/CustomEvents.ts, line 20

@@ -1810,11 +1669,11 @@

-

- # +

+ # - userDeletedType + userLoggedInType

@@ -1823,7 +1682,7 @@

- The type of the `hanko-user-deleted` event. + The type of the `hanko-user-logged-in` event.
@@ -1865,7 +1724,7 @@

View Source - lib/events/CustomEvents.ts, line 20 + lib/events/CustomEvents.ts, line 26

diff --git a/docs/static/jsdoc/hanko-frontend-sdk/LocalStorage.html b/docs/static/jsdoc/hanko-frontend-sdk/LocalStorage.html index 254b8cb6d..f77821849 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/LocalStorage.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/LocalStorage.html @@ -66,7 +66,7 @@

@@ -132,7 +132,7 @@
Properties:
-LocalStorageUsers +LocalStorageUsers @@ -195,7 +195,7 @@
Properties:

View Source - lib/state/State.ts, line 80 + lib/state/State.ts, line 79

diff --git a/docs/static/jsdoc/hanko-frontend-sdk/LocalStoragePasscode.html b/docs/static/jsdoc/hanko-frontend-sdk/LocalStoragePasscode.html deleted file mode 100644 index e8adb8311..000000000 --- a/docs/static/jsdoc/hanko-frontend-sdk/LocalStoragePasscode.html +++ /dev/null @@ -1,350 +0,0 @@ - - - - - - - - LocalStoragePasscode - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - -
-
-
- -
-
-
-

Interface

-

LocalStoragePasscode

-
- - - - - -
- -
- -

LocalStoragePasscode

- - -
- -
-
- - - - - - -
Properties:
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeAttributesDescription
id - - -string - - - - - - <optional>
- - - -
The UUID of the active passcode.
ttl - - -number - - - - - - <optional>
- - - -
Timestamp until when the passcode is valid in seconds (since January 1, 1970 00:00:00 UTC).
resendAfter - - -number - - - - - - <optional>
- - - -
Seconds until a passcode can be resent.
emailID - - -emailID - - - - - - <optional>
- - - -
The email address ID.
-
- - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/users/PasscodeState.ts, line 131 - -

- -
- - - - -
- - - - - - - - - - - - - - - - - - - - -
- -
- - - - -
- - - -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/static/jsdoc/hanko-frontend-sdk/LocalStoragePassword.html b/docs/static/jsdoc/hanko-frontend-sdk/LocalStoragePassword.html deleted file mode 100644 index 57827f977..000000000 --- a/docs/static/jsdoc/hanko-frontend-sdk/LocalStoragePassword.html +++ /dev/null @@ -1,257 +0,0 @@ - - - - - - - - LocalStoragePassword - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - -
-
-
- -
-
-
-

Interface

-

LocalStoragePassword

-
- - - - - -
- -
- -

LocalStoragePassword

- - -
- -
-
- - - - - - -
Properties:
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeAttributesDescription
retryAfter - - -number - - - - - - <optional>
- - - -
Timestamp (in seconds since January 1, 1970 00:00:00 UTC) indicating when the next password login can be attempted.
-
- - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/users/PasswordState.ts, line 57 - -

- -
- - - - -
- - - - - - - - - - - - - - - - - - - - -
- -
- - - - -
- - - -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/static/jsdoc/hanko-frontend-sdk/LocalStorageSession.html b/docs/static/jsdoc/hanko-frontend-sdk/LocalStorageSession.html index cb02b1d2e..cf2b90b78 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/LocalStorageSession.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/LocalStorageSession.html @@ -66,7 +66,7 @@
@@ -181,7 +181,7 @@
Properties:

View Source - lib/state/session/SessionState.ts, line 108 + lib/state/session/SessionState.ts, line 91

diff --git a/docs/static/jsdoc/hanko-frontend-sdk/LocalStorageUser.html b/docs/static/jsdoc/hanko-frontend-sdk/LocalStorageUser.html deleted file mode 100644 index f81e974be..000000000 --- a/docs/static/jsdoc/hanko-frontend-sdk/LocalStorageUser.html +++ /dev/null @@ -1,319 +0,0 @@ - - - - - - - - LocalStorageUser - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - -
-
-
- -
-
-
-

Interface

-

LocalStorageUser

-
- - - - - -
- -
- -

LocalStorageUser

- - -
- -
-
- - - - - - -
Properties:
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeAttributesDescription
webauthn - - -LocalStorageWebauthn - - - - - - <optional>
- - - -
Information about WebAuthn credentials.
passcode - - -LocalStoragePasscode - - - - - - <optional>
- - - -
Information about the active passcode.
password - - -LocalStoragePassword - - - - - - <optional>
- - - -
Information about the password login attempts.
-
- - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/users/UserState.ts, line 39 - -

- -
- - - - -
- - - - - - - - - - - - - - - - - - - - -
- -
- - - - -
- - - -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/static/jsdoc/hanko-frontend-sdk/LocalStorageUsers.html b/docs/static/jsdoc/hanko-frontend-sdk/LocalStorageUsers.html deleted file mode 100644 index e05866716..000000000 --- a/docs/static/jsdoc/hanko-frontend-sdk/LocalStorageUsers.html +++ /dev/null @@ -1,243 +0,0 @@ - - - - - - - - LocalStorageUsers - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - -
-
-
- -
-
-
-

Interface

-

LocalStorageUsers

-
- - - - - -
- -
- -

LocalStorageUsers

- - -
- -
-
- - - - - - -
Properties:
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
TypeDescription
- - -Object.<string, LocalStorageUser> - - - - A dictionary for mapping users to their states.
-
- - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/users/UserState.ts, line 48 - -

- -
- - - - -
- - - - - - - - - - - - - - - - - - - - -
- -
- - - - -
- - - -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/static/jsdoc/hanko-frontend-sdk/LocalStorageWebauthn.html b/docs/static/jsdoc/hanko-frontend-sdk/LocalStorageWebauthn.html deleted file mode 100644 index 261c738cf..000000000 --- a/docs/static/jsdoc/hanko-frontend-sdk/LocalStorageWebauthn.html +++ /dev/null @@ -1,257 +0,0 @@ - - - - - - - - LocalStorageWebauthn - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - -
-
-
- -
-
-
-

Interface

-

LocalStorageWebauthn

-
- - - - - -
- -
- -

LocalStorageWebauthn

- - -
- -
-
- - - - - - -
Properties:
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeAttributesDescription
credentials - - -Array.<string> - - - - - - - - <nullable>
- -
A list of known credential IDs on the current browser.
-
- - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/users/WebauthnState.ts, line 69 - -

- -
- - - - -
- - - - - - - - - - - - - - - - - - - - -
- -
- - - - -
- - - -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/static/jsdoc/hanko-frontend-sdk/MaxNumOfEmailAddressesReachedError.html b/docs/static/jsdoc/hanko-frontend-sdk/MaxNumOfEmailAddressesReachedError.html index 88d94759c..2bd64798a 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/MaxNumOfEmailAddressesReachedError.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/MaxNumOfEmailAddressesReachedError.html @@ -66,7 +66,7 @@
diff --git a/docs/static/jsdoc/hanko-frontend-sdk/MaxNumOfPasscodeAttemptsReachedError.html b/docs/static/jsdoc/hanko-frontend-sdk/MaxNumOfPasscodeAttemptsReachedError.html index c41d28d72..8339a5abe 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/MaxNumOfPasscodeAttemptsReachedError.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/MaxNumOfPasscodeAttemptsReachedError.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/NotFoundError.html b/docs/static/jsdoc/hanko-frontend-sdk/NotFoundError.html index ed6970d58..850f51cf7 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/NotFoundError.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/NotFoundError.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/Options.html b/docs/static/jsdoc/hanko-frontend-sdk/Options.html deleted file mode 100644 index e5177e1c7..000000000 --- a/docs/static/jsdoc/hanko-frontend-sdk/Options.html +++ /dev/null @@ -1,1289 +0,0 @@ - - - - - - - - Options - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - -
-
-
- -
-
-
-

Interface

-

Options

-
- - - - - -
- -
- -

Options

- - -
- -
-
- - -
The options for the Hanko class
- - - - - -
Properties:
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeAttributesDescription
timeout - - -number - - - - - - <optional>
- - - -
The http request timeout in milliseconds. Defaults to 13000ms
cookieName - - -string - - - - - - <optional>
- - - -
The name of the session cookie set from the SDK. Defaults to "hanko"
storageKey - - -string - - - - - - <optional>
- - - -
The prefix / name of the local storage keys. Defaults to "hanko"
-
- - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - Hanko.ts, line 115 - -

- -
- - - - -
- - - - - - - - - - - - - - - - - - - - -
- -
- - - - - - - -
- -
- -

Options

- - -
- -
-
- - -
Options for Cookie
- - - - - -
Properties:
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
cookieName - - -string - - - - The name of the session cookie set from the SDK.
-
- - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/Cookie.ts, line 41 - -

- -
- - - - -
- - - - - - - - - - - - - - - - - - - - -
- -
- - - - - - - -
- -
- -

Options

- - -
- -
-
- - -
Options for Session
- - - - - -
Properties:
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
cookieName - - -string - - - - The name of the session cookie set from the SDK.
storageKey - - -string - - - - The prefix / name of the local storage keys.
-
- - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/Session.ts, line 77 - -

- -
- - - - -
- - - - - - - - - - - - - - - - - - - - -
- -
- - - - - - - -
- -
- -

Options

- - -
- -
-
- - -
Options for the HttpClient
- - - - - -
Properties:
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
timeout - - -number - - - - The http request timeout in milliseconds.
cookieName - - -string - - - - The name of the session cookie set from the SDK.
storageKey - - -string - - - - The prefix / name of the local storage keys.
-
- - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/client/HttpClient.ts, line 303 - -

- -
- - - - -
- - - - - - - - - - - - - - - - - - - - -
- -
- - - - - - - -
- -
- -

Options

- - -
- -
-
- - -
Options for Dispatcher
- - - - - -
Properties:
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
storageKey - - -string - - - - The prefix / name of the local storage keys.
-
- - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/events/Dispatcher.ts, line 66 - -

- -
- - - - -
- - - - - - - - - - - - - - - - - - - - -
- -
- - - - - - - -
- -
- -

Options

- - -
- -
-
- - -
Options for Relay
- - - - - -
Properties:
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
cookieName - - -string - - - - The name of the session cookie set from the SDK.
storageKey - - -string - - - - The prefix / name of the local storage keys.
-
- - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/events/Relay.ts, line 90 - -

- -
- - - - -
- - - - - - - - - - - - - - - - - - - - -
- -
- - - - - - - -
- -
- -

Options

- - -
- -
-
- - -
Options for SessionState
- - - - - -
Properties:
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
storageKey - - -string - - - - The prefix / name of the local storage keys.
-
- - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/session/SessionState.ts, line 100 - -

- -
- - - - -
- - - - - - - - - - - - - - - - - - - - -
- -
- - - - -
- - - -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/static/jsdoc/hanko-frontend-sdk/Passcode.html b/docs/static/jsdoc/hanko-frontend-sdk/Passcode.html index 89f6565c1..e07207e85 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/Passcode.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/Passcode.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/PasscodeClient.html b/docs/static/jsdoc/hanko-frontend-sdk/PasscodeClient.html deleted file mode 100644 index 19c737053..000000000 --- a/docs/static/jsdoc/hanko-frontend-sdk/PasscodeClient.html +++ /dev/null @@ -1,1358 +0,0 @@ - - - - - - - - PasscodeClient - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - -
-
-
- -
-
-
-

Class

-

PasscodeClient

-
- - - - - -
- -
- -

PasscodeClient()

- -
A class to handle passcodes.
- - -
- -
-
- - -
-
-
-
- Constructor -
- - - - -

- # - - - - new PasscodeClient() - - -

- - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/client/PasscodeClient.ts, line 13 - -

- -
- - - - - - - - - - - - - - - - - - - - - - -
-
-
- - -
- - -

Extends

- - - - - - - - - - - - - - - - - - - - -
-

Members

-
- -
- - - - -HttpClient - - - - -

- # - - - client - - -

- - - - - - - - -
- - - - - - - - -
Overrides:
-
- - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/client/Client.ts, line 20 - -

- -
- - - - - -
- -
- - - - -PasscodeState - - - - -

- # - - - state - - -

- - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/client/PasscodeClient.ts, line 22 - -

- -
- - - - - -
- -
-
- - - -
-

Methods

-
- -
- - - -

- # - - - async - - - - - finalize(userID, code) → {Promise.<void>} - - -

- - - - -
- Validates the passcode obtained from the email. -
- - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
userID - - -string - - - - The UUID of the user.
code - - -string - - - - The passcode digests.
-
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
See:
-
- -
- - - - - -

- View Source - - lib/client/PasscodeClient.ts, line 163 - -

- -
- - - - - - - - - - - - - - - - -
-
-
- - -
- - -
- -InvalidPasscodeError - - -
- - -
- - - -
- - - - - -
- - - -
- - -
- -RequestTimeoutError - - -
- - -
- - - -
- - -
- -TechnicalError - - -
- - -
- - -
-
- - - -
-
-
- - - -
- - -
- - -Promise.<void> - - -
- -
- - -
-
- - - - -
- -
- - - -

- # - - - - getResendAfter(userID) → {number} - - -

- - - - -
- Returns the number of seconds the rate limiting is active for. -
- - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
userID - - -string - - - - The UUID of the user.
-
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/client/PasscodeClient.ts, line 179 - -

- -
- - - - - - - - - - - - - - - - - - -
-
-
- - - -
- - -
- - -number - - -
- -
- - -
-
- - - - -
- -
- - - -

- # - - - - getTTL(userID) → {number} - - -

- - - - -
- Returns the number of seconds the current passcode is active for. -
- - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
userID - - -string - - - - The UUID of the user.
-
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/client/PasscodeClient.ts, line 171 - -

- -
- - - - - - - - - - - - - - - - - - -
-
-
- - - -
- - -
- - -number - - -
- -
- - -
-
- - - - -
- -
- - - -

- # - - - async - - - - - initialize(userID, emailIDopt, forceopt) → {Promise.<Passcode>} - - -

- - - - -
- Causes the API to send a new passcode to the user's email address. -
- - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeAttributesDescription
userID - - -string - - - - - - - - - - The UUID of the user.
emailID - - -string - - - - - - <optional>
- - - - - -
The UUID of the email address. If unspecified, the email will be sent to the primary email address.
force - - -boolean - - - - - - <optional>
- - - - - -
Indicates the passcode should be sent, even if there is another active passcode.
-
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
See:
-
- -
- - - - - -

- View Source - - lib/client/PasscodeClient.ts, line 148 - -

- -
- - - - - - - - - - - - - - - - -
-
-
- - -
- - -
- -RequestTimeoutError - - -
- - -
- - - -
- - -
- -TechnicalError - - -
- - -
- - - -
- - -
- -TooManyRequestsError - - -
- - -
- - -
-
- - - -
-
-
- - - -
- - -
- - -Promise.<Passcode> - - -
- -
- - -
-
- - - - -
- -
-
- - - - - -
- -
- - - - -
- - - -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/static/jsdoc/hanko-frontend-sdk/PasscodeExpiredError.html b/docs/static/jsdoc/hanko-frontend-sdk/PasscodeExpiredError.html index 7f2412fed..edff42743 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/PasscodeExpiredError.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/PasscodeExpiredError.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/PasscodeState.html b/docs/static/jsdoc/hanko-frontend-sdk/PasscodeState.html deleted file mode 100644 index 8a451b21f..000000000 --- a/docs/static/jsdoc/hanko-frontend-sdk/PasscodeState.html +++ /dev/null @@ -1,2420 +0,0 @@ - - - - - - - - PasscodeState - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - -
-
-
- -
-
-
-

Class

-

PasscodeState

-
- - - - - -
- -
- -

PasscodeState()

- -
A class that manages passcodes via local storage.
- - -
- -
-
- - -
-
-
-
- Constructor -
- - - - -

- # - - - - new PasscodeState() - - -

- - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/users/PasscodeState.ts, line 11 - -

- -
- - - - - - - - - - - - - - - - - - - - - - -
-
-
- - -
- - -

Extends

- - - - - - - - - - - - - - - - - - - - -
-

Members

-
- -
- - - - -LocalStorage - - - - -

- # - - - ls - - -

- - - - - - - - -
- - - - - - -
Inherited From:
-
- - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/State.ts, line 23 - -

- -
- - - - - -
- -
-
- - - -
-

Methods

-
- -
- - - -

- # - - - - getActiveID(userID) → {string} - - -

- - - - -
- Gets the UUID of the active passcode. -
- - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
userID - - -string - - - - The UUID of the user.
-
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/users/PasscodeState.ts, line 167 - -

- -
- - - - - - - - - - - - - - - - - - -
-
-
- - - -
- - -
- - -string - - -
- -
- - -
-
- - - - -
- -
- - - -

- # - - - - getEmailID(userID) → {string} - - -

- - - - -
- Gets the UUID of the email address. -
- - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
userID - - -string - - - - The UUID of the user.
-
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/users/PasscodeState.ts, line 184 - -

- -
- - - - - - - - - - - - - - - - - - -
-
-
- - - -
- - -
- - -string - - -
- -
- - -
-
- - - - -
- -
- - - -

- # - - - - getResendAfter(userID) → {number} - - -

- - - - -
- Gets the number of seconds until when the next passcode can be sent. -
- - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
userID - - -string - - - - The UUID of the user.
-
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/users/PasscodeState.ts, line 226 - -

- -
- - - - - - - - - - - - - - - - - - -
-
-
- - - -
- - -
- - -number - - -
- -
- - -
-
- - - - -
- -
- - - -

- # - - - - getTTL(userID) → {number} - - -

- - - - -
- Gets the TTL in seconds. When the seconds expire, the code is invalid. -
- - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
userID - - -string - - - - The UUID of the user.
-
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/users/PasscodeState.ts, line 209 - -

- -
- - - - - - - - - - - - - - - - - - -
-
-
- - - -
- - -
- - -number - - -
- -
- - -
-
- - - - -
- -
- - - -

- # - - - - getUserState(userID) → {LocalStorageUser} - - -

- - - - -
- Gets the state of the specified user. -
- - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
userID - - -string - - - - The UUID of the user.
-
- - - - - -
- - - - - - - - -
Overrides:
-
- - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/users/UserState.ts, line 25 - -

- -
- - - - - - - - - - - - - - - - - - -
-
-
- - - -
- - -
- - -LocalStorageUser - - -
- -
- - -
-
- - - - -
- -
- - - -

- # - - - - read() → {PasscodeState} - - -

- - - - -
- Reads the current state. -
- - - - - - - - - - - - - - -
- - - - - - - - -
Overrides:
-
- - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/users/PasscodeState.ts, line 159 - -

- -
- - - - - - - - - - - - - - - - - - -
-
-
- - - -
- - -
- - -PasscodeState - - -
- -
- - -
-
- - - - -
- -
- - - -

- # - - - - reset(userID) → {PasscodeState} - - -

- - - - -
- Removes the active passcode. -
- - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
userID - - -string - - - - The UUID of the user.
-
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/users/PasscodeState.ts, line 201 - -

- -
- - - - - - - - - - - - - - - - - - -
-
-
- - - -
- - -
- - -PasscodeState - - -
- -
- - -
-
- - - - -
- -
- - - -

- # - - - - setActiveID(userID, passcodeID) → {PasscodeState} - - -

- - - - -
- Sets the UUID of the active passcode. -
- - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
userID - - -string - - - - The UUID of the user.
passcodeID - - -string - - - - The UUID of the passcode to be set as active.
-
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/users/PasscodeState.ts, line 176 - -

- -
- - - - - - - - - - - - - - - - - - -
-
-
- - - -
- - -
- - -PasscodeState - - -
- -
- - -
-
- - - - -
- -
- - - -

- # - - - - setEmailID(userID, emailID) → {PasscodeState} - - -

- - - - -
- Sets the UUID of the email address. -
- - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
userID - - -string - - - - The UUID of the user.
emailID - - -string - - - - The UUID of the email address.
-
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/users/PasscodeState.ts, line 193 - -

- -
- - - - - - - - - - - - - - - - - - -
-
-
- - - -
- - -
- - -PasscodeState - - -
- -
- - -
-
- - - - -
- -
- - - -

- # - - - - setResendAfter(userID, seconds) → {PasscodeState} - - -

- - - - -
- Sets the number of seconds until a new passcode can be sent. -
- - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
userID - - -string - - - - The UUID of the user.
seconds - - -number - - - - Number of seconds the passcode is valid for.
-
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/users/PasscodeState.ts, line 235 - -

- -
- - - - - - - - - - - - - - - - - - -
-
-
- - - -
- - -
- - -PasscodeState - - -
- -
- - -
-
- - - - -
- -
- - - -

- # - - - - setTTL(userID, seconds) → {PasscodeState} - - -

- - - - -
- Sets the passcode's TTL and stores it to the local storage. -
- - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
userID - - -string - - - - The UUID of the user.
seconds - - -string - - - - Number of seconds the passcode is valid for.
-
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/users/PasscodeState.ts, line 218 - -

- -
- - - - - - - - - - - - - - - - - - -
-
-
- - - -
- - -
- - -PasscodeState - - -
- -
- - -
-
- - - - -
- -
- - - -

- # - - - - write() → {State} - - -

- - - - -
- Encodes and writes the data to the local storage. -
- - - - - - - - - - - - - - -
- - - - - - -
Inherited From:
-
- - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/State.ts, line 49 - -

- -
- - - - - - - - - - - - - - - - - - -
-
-
- - - -
- - -
- - -State - - -
- -
- - -
-
- - - - -
- -
-
- - - - - -
- -
- - - - -
- - - -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/static/jsdoc/hanko-frontend-sdk/PasswordClient.html b/docs/static/jsdoc/hanko-frontend-sdk/PasswordClient.html deleted file mode 100644 index 7449c2b90..000000000 --- a/docs/static/jsdoc/hanko-frontend-sdk/PasswordClient.html +++ /dev/null @@ -1,1205 +0,0 @@ - - - - - - - - PasswordClient - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - -
-
-
- -
-
-
-

Class

-

PasswordClient

-
- - - - - -
- -
- -

PasswordClient()

- -
A class to handle passwords.
- - -
- -
-
- - -
-
-
-
- Constructor -
- - - - -

- # - - - - new PasswordClient() - - -

- - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/client/PasswordClient.ts, line 14 - -

- -
- - - - - - - - - - - - - - - - - - - - - - -
-
-
- - -
- - -

Extends

- - - - - - - - - - - - - - - - - - - - -
-

Members

-
- -
- - - - -HttpClient - - - - -

- # - - - client - - -

- - - - - - - - -
- - - - - - - - -
Overrides:
-
- - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/client/Client.ts, line 20 - -

- -
- - - - - -
- -
- - - - -PasscodeState - - - - -

- # - - - passcodeState - - -

- - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/client/PasswordClient.ts, line 29 - -

- -
- - - - - -
- -
- - - - -PasswordState - - - - -

- # - - - passwordState - - -

- - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/client/PasswordClient.ts, line 24 - -

- -
- - - - - -
- -
-
- - - -
-

Methods

-
- -
- - - -

- # - - - - getRetryAfter(userID) → {number} - - -

- - - - -
- Returns the number of seconds the rate limiting is active for. -
- - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
userID - - -string - - - - The UUID of the user.
-
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/client/PasswordClient.ts, line 141 - -

- -
- - - - - - - - - - - - - - - - - - -
-
-
- - - -
- - -
- - -number - - -
- -
- - -
-
- - - - -
- -
- - - -

- # - - - async - - - - - login(userID, password) → {Promise.<void>} - - -

- - - - -
- Logs in a user with a password. -
- - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
userID - - -string - - - - The UUID of the user.
password - - -string - - - - The password.
-
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
See:
-
- -
- - - - - -

- View Source - - lib/client/PasswordClient.ts, line 119 - -

- -
- - - - - - - - - - - - - - - - -
-
-
- - -
- - -
- -InvalidPasswordError - - -
- - -
- - - -
- - -
- -RequestTimeoutError - - -
- - -
- - - -
- - -
- -TechnicalError - - -
- - -
- - - -
- - -
- -TooManyRequestsError - - -
- - -
- - -
-
- - - -
-
-
- - - -
- - -
- - -Promise.<void> - - -
- -
- - -
-
- - - - -
- -
- - - -

- # - - - async - - - - - update(userID, password) → {Promise.<void>} - - -

- - - - -
- Updates a password. -
- - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
userID - - -string - - - - The UUID of the user.
password - - -string - - - - The new password.
-
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
See:
-
- -
- - - - - -

- View Source - - lib/client/PasswordClient.ts, line 133 - -

- -
- - - - - - - - - - - - - - - - -
-
-
- - -
- - -
- -RequestTimeoutError - - -
- - -
- - - -
- - -
- -UnauthorizedError - - -
- - -
- - - -
- - -
- -TechnicalError - - -
- - -
- - -
-
- - - -
-
-
- - - -
- - -
- - -Promise.<void> - - -
- -
- - -
-
- - - - -
- -
-
- - - - - -
- -
- - - - -
- - - -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/static/jsdoc/hanko-frontend-sdk/PasswordConfig.html b/docs/static/jsdoc/hanko-frontend-sdk/PasswordConfig.html index 8cf85148c..937e38e60 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/PasswordConfig.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/PasswordConfig.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/PasswordState.html b/docs/static/jsdoc/hanko-frontend-sdk/PasswordState.html deleted file mode 100644 index 0dee2f22d..000000000 --- a/docs/static/jsdoc/hanko-frontend-sdk/PasswordState.html +++ /dev/null @@ -1,1148 +0,0 @@ - - - - - - - - PasswordState - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - -
-
-
- -
-
-
-

Class

-

PasswordState

-
- - - - - -
- -
- -

PasswordState()

- -
A class that manages the password login state.
- - -
- -
-
- - -
-
-
-
- Constructor -
- - - - -

- # - - - - new PasswordState() - - -

- - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/users/PasswordState.ts, line 11 - -

- -
- - - - - - - - - - - - - - - - - - - - - - -
-
-
- - -
- - -

Extends

- - - - - - - - - - - - - - - - - - - - -
-

Members

-
- -
- - - - -LocalStorage - - - - -

- # - - - ls - - -

- - - - - - - - -
- - - - - - -
Inherited From:
-
- - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/State.ts, line 23 - -

- -
- - - - - -
- -
-
- - - -
-

Methods

-
- -
- - - -

- # - - - - getRetryAfter(userID) → {number} - - -

- - - - -
- Gets the number of seconds until when a new password login can be attempted. -
- - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
userID - - -string - - - - The UUID of the user.
-
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/users/PasswordState.ts, line 90 - -

- -
- - - - - - - - - - - - - - - - - - -
-
-
- - - -
- - -
- - -number - - -
- -
- - -
-
- - - - -
- -
- - - -

- # - - - - getUserState(userID) → {LocalStorageUser} - - -

- - - - -
- Gets the state of the specified user. -
- - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
userID - - -string - - - - The UUID of the user.
-
- - - - - -
- - - - - - - - -
Overrides:
-
- - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/users/UserState.ts, line 25 - -

- -
- - - - - - - - - - - - - - - - - - -
-
-
- - - -
- - -
- - -LocalStorageUser - - -
- -
- - -
-
- - - - -
- -
- - - -

- # - - - - read() → {PasswordState} - - -

- - - - -
- Reads the current state. -
- - - - - - - - - - - - - - -
- - - - - - - - -
Overrides:
-
- - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/users/PasswordState.ts, line 82 - -

- -
- - - - - - - - - - - - - - - - - - -
-
-
- - - -
- - -
- - -PasswordState - - -
- -
- - -
-
- - - - -
- -
- - - -

- # - - - - setRetryAfter(userID, seconds) → {PasswordState} - - -

- - - - -
- Sets the number of seconds until a new password login can be attempted. -
- - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
userID - - -string - - - - The UUID of the user.
seconds - - -string - - - - Number of seconds the passcode is valid for.
-
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/users/PasswordState.ts, line 99 - -

- -
- - - - - - - - - - - - - - - - - - -
-
-
- - - -
- - -
- - -PasswordState - - -
- -
- - -
-
- - - - -
- -
- - - -

- # - - - - write() → {State} - - -

- - - - -
- Encodes and writes the data to the local storage. -
- - - - - - - - - - - - - - -
- - - - - - -
Inherited From:
-
- - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/State.ts, line 49 - -

- -
- - - - - - - - - - - - - - - - - - -
-
-
- - - -
- - -
- - -State - - -
- -
- - -
-
- - - - -
- -
-
- - - - - -
- -
- - - - -
- - - -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/static/jsdoc/hanko-frontend-sdk/Relay.html b/docs/static/jsdoc/hanko-frontend-sdk/Relay.html index 0c7a0f8e1..5c98c6816 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/Relay.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/Relay.html @@ -66,7 +66,7 @@ @@ -293,159 +293,6 @@

Methods

-

- # - - - - dispatchAuthFlowCompletedEvent(detail) - - -

- - - - -
- Dispatches a "hanko-auth-flow-completed" event to the document with the specified detail. -
- - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
detail - - -AuthFlowCompletedDetail - - - - The event detail.
-
- - - - - -
- - - - - - - - -
Overrides:
-
- - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/events/Dispatcher.ts, line 59 - -

- -
- - - - - - - - - - - - - - - - - - - - - - - -
- - -

# diff --git a/docs/static/jsdoc/hanko-frontend-sdk/RelayOptions.html b/docs/static/jsdoc/hanko-frontend-sdk/RelayOptions.html index 20c035807..51df4381e 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/RelayOptions.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/RelayOptions.html @@ -66,7 +66,7 @@

@@ -210,7 +210,7 @@
Properties:

View Source - lib/events/Relay.ts, line 91 + lib/events/Relay.ts, line 87

diff --git a/docs/static/jsdoc/hanko-frontend-sdk/RequestTimeoutError.html b/docs/static/jsdoc/hanko-frontend-sdk/RequestTimeoutError.html index 2db3eb6a3..c042f3682 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/RequestTimeoutError.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/RequestTimeoutError.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/Response.html b/docs/static/jsdoc/hanko-frontend-sdk/Response.html index 971caf8fb..8e2edae25 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/Response.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/Response.html @@ -66,7 +66,7 @@ @@ -225,7 +225,7 @@
Parameters:

View Source - lib/client/HttpClient.ts, line 39 + lib/client/HttpClient.ts, line 38

@@ -337,7 +337,7 @@

View Source - lib/client/HttpClient.ts, line 53 + lib/client/HttpClient.ts, line 52

@@ -410,7 +410,7 @@

View Source - lib/client/HttpClient.ts, line 58 + lib/client/HttpClient.ts, line 57

@@ -483,7 +483,7 @@

View Source - lib/client/HttpClient.ts, line 63 + lib/client/HttpClient.ts, line 62

@@ -556,7 +556,7 @@

View Source - lib/client/HttpClient.ts, line 68 + lib/client/HttpClient.ts, line 67

@@ -629,7 +629,7 @@

View Source - lib/client/HttpClient.ts, line 73 + lib/client/HttpClient.ts, line 72

@@ -719,7 +719,7 @@

View Source - lib/client/HttpClient.ts, line 295 + lib/client/HttpClient.ts, line 283

@@ -891,7 +891,7 @@

Parameters:

View Source - lib/client/HttpClient.ts, line 304 + lib/client/HttpClient.ts, line 292

diff --git a/docs/static/jsdoc/hanko-frontend-sdk/Scheduler.html b/docs/static/jsdoc/hanko-frontend-sdk/Scheduler.html index 3969b8363..30e8639b1 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/Scheduler.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/Scheduler.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/Session.html b/docs/static/jsdoc/hanko-frontend-sdk/Session.html index 6d44b46d2..933a75786 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/Session.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/Session.html @@ -66,7 +66,7 @@ @@ -347,7 +347,7 @@

View Source - lib/Session.ts, line 95 + lib/Session.ts, line 93

@@ -469,7 +469,7 @@

View Source - lib/Session.ts, line 120 + lib/Session.ts, line 118

@@ -591,7 +591,7 @@

View Source - lib/Session.ts, line 103 + lib/Session.ts, line 101

diff --git a/docs/static/jsdoc/hanko-frontend-sdk/SessionDetail.html b/docs/static/jsdoc/hanko-frontend-sdk/SessionDetail.html index 1a0c90b20..b75c3cd77 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/SessionDetail.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/SessionDetail.html @@ -66,7 +66,7 @@ @@ -185,35 +185,6 @@

Properties:
- - - - userID - - - - - -string - - - - - - - - - - - - - - - - The user associated with the session. - - - @@ -255,7 +226,7 @@
Properties:

View Source - lib/events/CustomEvents.ts, line 54 + lib/events/CustomEvents.ts, line 61

diff --git a/docs/static/jsdoc/hanko-frontend-sdk/SessionOptions.html b/docs/static/jsdoc/hanko-frontend-sdk/SessionOptions.html index fae4cb94a..293e35280 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/SessionOptions.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/SessionOptions.html @@ -66,7 +66,7 @@ @@ -210,7 +210,7 @@
Properties:

View Source - lib/Session.ts, line 78 + lib/Session.ts, line 76

diff --git a/docs/static/jsdoc/hanko-frontend-sdk/SessionState.html b/docs/static/jsdoc/hanko-frontend-sdk/SessionState.html index 51dfc09ae..f2d893285 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/SessionState.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/SessionState.html @@ -66,7 +66,7 @@ @@ -436,7 +436,7 @@

View Source - lib/state/session/SessionState.ts, line 165 + lib/state/session/SessionState.ts, line 134

@@ -556,7 +556,7 @@

View Source - lib/state/session/SessionState.ts, line 137 + lib/state/session/SessionState.ts, line 120

@@ -676,7 +676,7 @@

View Source - lib/state/session/SessionState.ts, line 130 + lib/state/session/SessionState.ts, line 113

@@ -725,126 +725,6 @@

- - -
- - - -

- # - - - - getUserID() → {string} - - -

- - - - -
- Gets the user id. -
- - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/session/SessionState.ts, line 151 - -

- -
- - - - - - - - - - - - - - - - - - -
-
-
- - - -
- - -
- - -string - - -
- -
- - -
-
- - - -
@@ -921,7 +801,7 @@

View Source - lib/state/session/SessionState.ts, line 123 + lib/state/session/SessionState.ts, line 106

@@ -1041,7 +921,7 @@

View Source - lib/state/session/SessionState.ts, line 180 + lib/state/session/SessionState.ts, line 149

@@ -1212,7 +1092,7 @@

Parameters:

View Source - lib/state/session/SessionState.ts, line 173 + lib/state/session/SessionState.ts, line 142

@@ -1383,178 +1263,7 @@
Parameters:

View Source - lib/state/session/SessionState.ts, line 145 - -

- - - - - - - - - - - - - - - - - - - - -
-
-
- - - -
- - -
- - -SessionState - - -
- -
- - -
-
- - - - -
- -
- - - -

- # - - - - setUserID(userID) → {SessionState} - - -

- - - - -
- Sets the user id. -
- - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
userID - - -string - - - - The user id
-
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/session/SessionState.ts, line 159 + lib/state/session/SessionState.ts, line 128

diff --git a/docs/static/jsdoc/hanko-frontend-sdk/SessionStateOptions.html b/docs/static/jsdoc/hanko-frontend-sdk/SessionStateOptions.html index 6f8bdaaee..3f30d5556 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/SessionStateOptions.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/SessionStateOptions.html @@ -66,7 +66,7 @@
@@ -187,7 +187,7 @@

Properties:

View Source - lib/state/session/SessionState.ts, line 100 + lib/state/session/SessionState.ts, line 83

diff --git a/docs/static/jsdoc/hanko-frontend-sdk/SetAuthCookieOptions.html b/docs/static/jsdoc/hanko-frontend-sdk/SetAuthCookieOptions.html deleted file mode 100644 index 6e93239ae..000000000 --- a/docs/static/jsdoc/hanko-frontend-sdk/SetAuthCookieOptions.html +++ /dev/null @@ -1,278 +0,0 @@ - - - - - - - - SetAuthCookieOptions - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - -
-
-
- -
-
-
-

Interface

-

SetAuthCookieOptions

-
- - - - - -
- -
- -

SetAuthCookieOptions

- - -
- -
-
- - -
Options for setting the auth cookie.
- - - - - -
Properties:
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
secure - - -boolean - - - - Indicates if the Secure attribute of the cookie should be set.
expires - - -number -| - -Date -| - -undefined - - - - The expiration of the cookie.
-
- - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/Cookie.ts, line 50 - -

- -
- - - - -
- - - - - - - - - - - - - - - - - - - - -
- -
- - - - -
- - - -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/static/jsdoc/hanko-frontend-sdk/State.html b/docs/static/jsdoc/hanko-frontend-sdk/State.html index 2c813751c..56016866c 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/State.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/State.html @@ -66,7 +66,7 @@ @@ -479,7 +479,7 @@
Parameters:

View Source - lib/state/State.ts, line 119 + lib/state/State.ts, line 118

@@ -654,7 +654,7 @@
Parameters:

View Source - lib/state/State.ts, line 110 + lib/state/State.ts, line 109

@@ -774,7 +774,7 @@

View Source - lib/state/State.ts, line 94 + lib/state/State.ts, line 93

@@ -894,7 +894,7 @@

View Source - lib/state/State.ts, line 101 + lib/state/State.ts, line 100

diff --git a/docs/static/jsdoc/hanko-frontend-sdk/TechnicalError.html b/docs/static/jsdoc/hanko-frontend-sdk/TechnicalError.html index 5dc6c3a15..41ab1b64e 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/TechnicalError.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/TechnicalError.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/ThirdPartyClient.html b/docs/static/jsdoc/hanko-frontend-sdk/ThirdPartyClient.html index 22723e284..dc4fab339 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/ThirdPartyClient.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/ThirdPartyClient.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/ThirdPartyError.html b/docs/static/jsdoc/hanko-frontend-sdk/ThirdPartyError.html index 5b7114d0f..4295ab47b 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/ThirdPartyError.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/ThirdPartyError.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/Throttle.html b/docs/static/jsdoc/hanko-frontend-sdk/Throttle.html index 581cdd79b..4d91547ba 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/Throttle.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/Throttle.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/ThrottleOptions.html b/docs/static/jsdoc/hanko-frontend-sdk/ThrottleOptions.html index d92114346..090e77c7b 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/ThrottleOptions.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/ThrottleOptions.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/TokenClient.html b/docs/static/jsdoc/hanko-frontend-sdk/TokenClient.html index 4eab75efb..99b8624e6 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/TokenClient.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/TokenClient.html @@ -66,7 +66,7 @@ @@ -391,7 +391,7 @@

View Source - lib/client/TokenClient.ts, line 52 + lib/client/TokenClient.ts, line 50

diff --git a/docs/static/jsdoc/hanko-frontend-sdk/TokenFinalized.html b/docs/static/jsdoc/hanko-frontend-sdk/TokenFinalized.html index 0023b7cb3..a724ef088 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/TokenFinalized.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/TokenFinalized.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/TooManyRequestsError.html b/docs/static/jsdoc/hanko-frontend-sdk/TooManyRequestsError.html index 92d10fe1d..90384e67a 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/TooManyRequestsError.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/TooManyRequestsError.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/UnauthorizedError.html b/docs/static/jsdoc/hanko-frontend-sdk/UnauthorizedError.html index 837430c5f..318c910d2 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/UnauthorizedError.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/UnauthorizedError.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/User.html b/docs/static/jsdoc/hanko-frontend-sdk/User.html index f221fec4e..38c1ae9cc 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/User.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/User.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/UserClient.html b/docs/static/jsdoc/hanko-frontend-sdk/UserClient.html index 0a7801f16..093ac26c5 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/UserClient.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/UserClient.html @@ -66,7 +66,7 @@ @@ -448,7 +448,7 @@

Parameters:

View Source - lib/client/UserClient.ts, line 172 + lib/client/UserClient.ts, line 168

@@ -624,7 +624,7 @@

View Source - lib/client/UserClient.ts, line 196 + lib/client/UserClient.ts, line 192

@@ -809,7 +809,7 @@

View Source - lib/client/UserClient.ts, line 185 + lib/client/UserClient.ts, line 181

@@ -1045,7 +1045,7 @@

Parameters:

View Source - lib/client/UserClient.ts, line 158 + lib/client/UserClient.ts, line 154

@@ -1221,7 +1221,7 @@

View Source - lib/client/UserClient.ts, line 206 + lib/client/UserClient.ts, line 202

diff --git a/docs/static/jsdoc/hanko-frontend-sdk/UserCreated.html b/docs/static/jsdoc/hanko-frontend-sdk/UserCreated.html index 88ca452fa..cc76b975c 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/UserCreated.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/UserCreated.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/UserInfo.html b/docs/static/jsdoc/hanko-frontend-sdk/UserInfo.html index 043b86de2..10b6169b0 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/UserInfo.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/UserInfo.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/UserState.html b/docs/static/jsdoc/hanko-frontend-sdk/UserState.html deleted file mode 100644 index 2c20b6a35..000000000 --- a/docs/static/jsdoc/hanko-frontend-sdk/UserState.html +++ /dev/null @@ -1,831 +0,0 @@ - - - - - - - - UserState - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - -
-
-
- -
-
-
-

Class

-

UserState

-
- - - - - -
- -
- -

(abstract) UserState(key)

- -
A class to read and write local storage contents.
- - -
- -
-
- - -
-
-
-
- Constructor -
- - - - -

- # - - - abstract - - - - - new UserState(key) - - -

- - - - - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
key - - -string - - - - The local storage key.
-
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/users/UserState.ts, line 18 - -

- -
- - - - - - - - - - - - - - - - - - - - - - -
-
-
- - -
- - -

Extends

- - - - - - - - - - - - - - - - - - - - -
-

Members

-
- -
- - - - -LocalStorage - - - - -

- # - - - ls - - -

- - - - - - - - -
- - - - - - -
Inherited From:
-
- - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/State.ts, line 23 - -

- -
- - - - - -
- -
-
- - - -
-

Methods

-
- -
- - - -

- # - - - - getUserState(userID) → {LocalStorageUser} - - -

- - - - -
- Gets the state of the specified user. -
- - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
userID - - -string - - - - The UUID of the user.
-
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/users/UserState.ts, line 63 - -

- -
- - - - - - - - - - - - - - - - - - -
-
-
- - - -
- - -
- - -LocalStorageUser - - -
- -
- - -
-
- - - - -
- -
- - - -

- # - - - - read() → {State} - - -

- - - - -
- Reads and decodes the locally stored data. -
- - - - - - - - - - - - - - -
- - - - - - - - -
Overrides:
-
- - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/State.ts, line 30 - -

- -
- - - - - - - - - - - - - - - - - - -
-
-
- - - -
- - -
- - -State - - -
- -
- - -
-
- - - - -
- -
- - - -

- # - - - - write() → {State} - - -

- - - - -
- Encodes and writes the data to the local storage. -
- - - - - - - - - - - - - - -
- - - - - - - - -
Overrides:
-
- - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/State.ts, line 49 - -

- -
- - - - - - - - - - - - - - - - - - -
-
-
- - - -
- - -
- - -State - - -
- -
- - -
-
- - - - -
- -
-
- - - - - -
- -
- - - - -
- - - -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/static/jsdoc/hanko-frontend-sdk/UserVerificationError.html b/docs/static/jsdoc/hanko-frontend-sdk/UserVerificationError.html index 7197e1732..d11d79069 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/UserVerificationError.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/UserVerificationError.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/WebauthnClient.html b/docs/static/jsdoc/hanko-frontend-sdk/WebauthnClient.html deleted file mode 100644 index 03458bbe9..000000000 --- a/docs/static/jsdoc/hanko-frontend-sdk/WebauthnClient.html +++ /dev/null @@ -1,1920 +0,0 @@ - - - - - - - - WebauthnClient - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - -
-
-
- -
-
-
-

Class

-

WebauthnClient

-
- - - - - -
- -
- -

WebauthnClient()

- -
A class that handles WebAuthn authentication and registration.
- - -
- -
-
- - -
-
-
-
- Constructor -
- - - - -

- # - - - - new WebauthnClient() - - -

- - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/client/WebauthnClient.ts, line 16 - -

- -
- - - - - - - - - - - - - - - - - - - - - - -
-
-
- - -
- - -

Extends

- - - - - - - - - - - - - - - - - - - - -
-

Members

-
- -
- - - - -HttpClient - - - - -

- # - - - client - - -

- - - - - - - - -
- - - - - - - - -
Overrides:
-
- - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/client/Client.ts, line 20 - -

- -
- - - - - -
- -
- - - - -PasscodeState - - - - -

- # - - - passcodeState - - -

- - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/client/WebauthnClient.ts, line 34 - -

- -
- - - - - -
- -
- - - - -WebauthnState - - - - -

- # - - - webauthnState - - -

- - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/client/WebauthnClient.ts, line 29 - -

- -
- - - - - -
- -
-
- - - -
-

Methods

-
- -
- - - -

- # - - - async - - - - - deleteCredential(credentialIDopt) → {Promise.<void>} - - -

- - - - -
- Deletes the WebAuthn credential. -
- - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeAttributesDescription
credentialID - - -string - - - - - - <optional>
- - - - - -
The credential's UUID.
-
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
See:
-
- -
- - - - - -

- View Source - - lib/client/WebauthnClient.ts, line 313 - -

- -
- - - - - - - - - - - - - - - - -
-
-
- - -
- - -
- -UnauthorizedError - - -
- - -
- - - -
- - -
- -RequestTimeoutError - - -
- - -
- - - -
- - -
- -TechnicalError - - -
- - -
- - -
-
- - - -
-
-
- - - -
- - -
- - -Promise.<void> - - -
- -
- - -
-
- - - - -
- -
- - - -

- # - - - async - - - - - listCredentials() → {Promise.<WebauthnCredentials>} - - -

- - - - -
- Returns a list of all WebAuthn credentials assigned to the current user. -
- - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
See:
-
- -
- - - - - -

- View Source - - lib/client/WebauthnClient.ts, line 286 - -

- -
- - - - - - - - - - - - - - - - -
-
-
- - -
- - -
- -UnauthorizedError - - -
- - -
- - - -
- - -
- -RequestTimeoutError - - -
- - -
- - - -
- - -
- -TechnicalError - - -
- - -
- - -
-
- - - -
-
-
- - - -
- - -
- - -Promise.<WebauthnCredentials> - - -
- -
- - -
-
- - - - -
- -
- - - -

- # - - - async - - - - - login(userIDopt, useConditionalMediationopt) → {Promise.<void>|WebauthnFinalized} - - -

- - - - -
- Performs a WebAuthn authentication ceremony. When 'userID' is specified, the API provides a list of -allowed credentials and the browser is able to present a list of suitable credentials to the user. -
- - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeAttributesDescription
userID - - -string - - - - - - <optional>
- - - - - -
The user's UUID.
useConditionalMediation - - -boolean - - - - - - <optional>
- - - - - -
Enables autofill assisted login.
-
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
See:
-
- -
- - - - - -

- View Source - - lib/client/WebauthnClient.ts, line 258 - -

- -
- - - - - - - - - - - - - - - - -
-
-
- - -
- - - - - -
- - - -
- - - - - -
- - - -
- - -
- -RequestTimeoutError - - -
- - -
- - - -
- - -
- -TechnicalError - - -
- - -
- - -
-
- - - -
-
-
- - - -
- - -
- - -Promise.<void> - - -
- -
- - - - -
- - -
- - -WebauthnFinalized - - -
- -
- - -
-
- - - - -
- -
- - - -

- # - - - async - - - - - register() → {Promise.<void>} - - -

- - - - -
- Performs a WebAuthn registration ceremony. -
- - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
See:
-
- -
- - - - - -

- View Source - - lib/client/WebauthnClient.ts, line 274 - -

- -
- - - - - - - - - - - - - - - - -
-
-
- - -
- - - - - -
- - - -
- - -
- -RequestTimeoutError - - -
- - -
- - - -
- - -
- -UnauthorizedError - - -
- - -
- - - -
- - -
- -TechnicalError - - -
- - -
- - - -
- - -
- -UserVerificationError - - -
- - -
- - -
-
- - - -
-
-
- - - -
- - -
- - -Promise.<void> - - -
- -
- - -
-
- - - - -
- -
- - - -

- # - - - async - - - - - shouldRegister(user) → {Promise.<boolean>} - - -

- - - - -
- Determines whether a credential registration ceremony should be performed. Returns 'true' when WebAuthn -is supported and the user's credentials do not intersect with the credentials already known on the -current browser/device. -
- - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
user - - -User - - - - The user object.
-
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/client/WebauthnClient.ts, line 324 - -

- -
- - - - - - - - - - - - - - - - - - -
-
-
- - - -
- - -
- - -Promise.<boolean> - - -
- -
- - -
-
- - - - -
- -
- - - -

- # - - - async - - - - - updateCredential(credentialIDopt, name) → {Promise.<void>} - - -

- - - - -
- Updates the WebAuthn credential. -
- - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeAttributesDescription
credentialID - - -string - - - - - - <optional>
- - - - - -
The credential's UUID.
name - - -string - - - - - - - - - - The new credential name.
-
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
See:
-
- -
- - - - - -

- View Source - - lib/client/WebauthnClient.ts, line 300 - -

- -
- - - - - - - - - - - - - - - - -
-
-
- - -
- - -
- -UnauthorizedError - - -
- - -
- - - -
- - -
- -RequestTimeoutError - - -
- - -
- - - -
- - -
- -TechnicalError - - -
- - -
- - -
-
- - - -
-
-
- - - -
- - -
- - -Promise.<void> - - -
- -
- - -
-
- - - - -
- -
-
- - - - - -
- -
- - - - -
- - - -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/static/jsdoc/hanko-frontend-sdk/WebauthnCredential.html b/docs/static/jsdoc/hanko-frontend-sdk/WebauthnCredential.html index 19c94561d..54ce65c06 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/WebauthnCredential.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/WebauthnCredential.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/WebauthnCredentials.html b/docs/static/jsdoc/hanko-frontend-sdk/WebauthnCredentials.html index e366a3280..5c7d6917e 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/WebauthnCredentials.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/WebauthnCredentials.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/WebauthnFinalized.html b/docs/static/jsdoc/hanko-frontend-sdk/WebauthnFinalized.html index dbe1ac4d7..b5bb8973f 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/WebauthnFinalized.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/WebauthnFinalized.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/WebauthnRequestCancelledError.html b/docs/static/jsdoc/hanko-frontend-sdk/WebauthnRequestCancelledError.html index fabdfca4c..874bb2985 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/WebauthnRequestCancelledError.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/WebauthnRequestCancelledError.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/WebauthnState.html b/docs/static/jsdoc/hanko-frontend-sdk/WebauthnState.html deleted file mode 100644 index dabf3c9a7..000000000 --- a/docs/static/jsdoc/hanko-frontend-sdk/WebauthnState.html +++ /dev/null @@ -1,1345 +0,0 @@ - - - - - - - - WebauthnState - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - -
-
-
- -
-
-
-

Class

-

WebauthnState

-
- - - - - -
- -
- -

WebauthnState()

- -
A class that manages WebAuthn credentials via local storage.
- - -
- -
-
- - -
-
-
-
- Constructor -
- - - - -

- # - - - - new WebauthnState() - - -

- - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/users/WebauthnState.ts, line 10 - -

- -
- - - - - - - - - - - - - - - - - - - - - - -
-
-
- - -
- - -

Extends

- - - - - - - - - - - - - - - - - - - - -
-

Members

-
- -
- - - - -LocalStorage - - - - -

- # - - - ls - - -

- - - - - - - - -
- - - - - - -
Inherited From:
-
- - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/State.ts, line 23 - -

- -
- - - - - -
- -
-
- - - -
-

Methods

-
- -
- - - -

- # - - - - addCredential(userID, credentialID) → {WebauthnState} - - -

- - - - -
- Adds the credential to the list of known credentials. -
- - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
userID - - -string - - - - The UUID of the user.
credentialID - - -string - - - - The WebAuthn credential ID.
-
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/users/WebauthnState.ts, line 111 - -

- -
- - - - - - - - - - - - - - - - - - -
-
-
- - - -
- - -
- - -WebauthnState - - -
- -
- - -
-
- - - - -
- -
- - - -

- # - - - - getCredentials(userID) → {Array.<string>} - - -

- - - - -
- Gets the list of known credentials on the current browser. -
- - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
userID - - -string - - - - The UUID of the user.
-
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/users/WebauthnState.ts, line 102 - -

- -
- - - - - - - - - - - - - - - - - - -
-
-
- - - -
- - -
- - -Array.<string> - - -
- -
- - -
-
- - - - -
- -
- - - -

- # - - - - getUserState(userID) → {LocalStorageUser} - - -

- - - - -
- Gets the state of the specified user. -
- - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
userID - - -string - - - - The UUID of the user.
-
- - - - - -
- - - - - - - - -
Overrides:
-
- - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/users/UserState.ts, line 25 - -

- -
- - - - - - - - - - - - - - - - - - -
-
-
- - - -
- - -
- - -LocalStorageUser - - -
- -
- - -
-
- - - - -
- -
- - - -

- # - - - - matchCredentials(userID, match) → {Array.<Credential>} - - -

- - - - -
- Returns the intersection between the specified list of credentials and the known credentials stored in -the local storage. -
- - - - - - - - - - -
Parameters:
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
userID - - -string - - - - The UUID of the user.
match - - -Array.<Credential> - - - - A list of credential IDs to be matched against the local storage.
-
- - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/users/WebauthnState.ts, line 121 - -

- -
- - - - - - - - - - - - - - - - - - -
-
-
- - - -
- - -
- - -Array.<Credential> - - -
- -
- - -
-
- - - - -
- -
- - - -

- # - - - - read() → {WebauthnState} - - -

- - - - -
- Reads the current state. -
- - - - - - - - - - - - - - -
- - - - - - - - -
Overrides:
-
- - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/users/WebauthnState.ts, line 94 - -

- -
- - - - - - - - - - - - - - - - - - -
-
-
- - - -
- - -
- - -WebauthnState - - -
- -
- - -
-
- - - - -
- -
- - - -

- # - - - - write() → {State} - - -

- - - - -
- Encodes and writes the data to the local storage. -
- - - - - - - - - - - - - - -
- - - - - - -
Inherited From:
-
- - - - - - - - - - - - - - - - - - - - - - - - - - -

- View Source - - lib/state/State.ts, line 49 - -

- -
- - - - - - - - - - - - - - - - - - -
-
-
- - - -
- - -
- - -State - - -
- -
- - -
-
- - - - -
- -
-
- - - - - -
- -
- - - - -
- - - -
-
-
-
- - - - - - - \ No newline at end of file diff --git a/docs/static/jsdoc/hanko-frontend-sdk/WebauthnSupport.html b/docs/static/jsdoc/hanko-frontend-sdk/WebauthnSupport.html index 5b69fed72..3f980dc31 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/WebauthnSupport.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/WebauthnSupport.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/WebauthnTransports.html b/docs/static/jsdoc/hanko-frontend-sdk/WebauthnTransports.html index 9e42f6bd7..8bff5c799 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/WebauthnTransports.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/WebauthnTransports.html @@ -66,7 +66,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/index.html b/docs/static/jsdoc/hanko-frontend-sdk/index.html index 76c55ecc7..c49cc4cb0 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/index.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/index.html @@ -66,7 +66,7 @@ @@ -145,11 +145,7 @@

SDK

Client Classes

    -
  • ConfigClient - A class to fetch configurations.
  • UserClient - A class to manage users.
  • -
  • WebauthnClient - A class to handle WebAuthn-related functionalities.
  • -
  • PasswordClient - A class to manage passwords and password logins.
  • -
  • PasscodeClient - A class to handle passcode logins.
  • ThirdPartyClient - A class to handle social logins.
  • TokenClient - A class that handles the exchange of one time tokens for session JWTs.
@@ -182,12 +178,10 @@

DTO Interfaces

Event Interfaces

  • SessionDetail
  • -
  • AuthFlowCompletedDetail

Event Types

  • CustomEventWithDetail
  • -
  • authFlowCompletedType
  • sessionCreatedType
  • sessionExpiredType
  • userLoggedOutType
  • @@ -230,32 +224,6 @@

    Get the current user / Validate the JWT against the Hanko API

    } }
-

Register a WebAuthn credential

-

There are a number of situations where you may want the user to register a WebAuthn credential. For example, after user -creation, when a user logs in to a new browser/device, or to take advantage of the "caBLE" support and pair a smartphone -with a desktop computer:

-
import { Hanko, UnauthorizedError, WebauthnRequestCancelledError } from "@teamhanko/hanko-frontend-sdk"
-
-const hanko = new Hanko("https://[HANKO_API_URL]")
-
-// By passing the user object (see example above) to `hanko.webauthn.shouldRegister(user)` you get an indication of
-// whether a WebAuthn credential registration should be performed on the current browser. This is useful if the user has
-// logged in using a method other than WebAuthn, and you then want to display a UI that allows the user to register a
-// credential when possibly none exists.
-
-try {
-    // Will cause the browser to present a dialog with various options depending on the WebAuthn implemention.
-    await hanko.webauthn.register()
-
-    // Credential has been registered.
-} catch(e) {
-    if (e instanceof WebauthnRequestCancelledError) {
-        // The WebAuthn API failed. Usually in this case the user cancelled the WebAuthn dialog.
-    } else if (e instanceof UnauthorizedError) {
-        // The user needs to login to perform this action.
-    }
-}
-

Custom Events

You can bind callback functions to different custom events. The callback function will be called when the event happens and an object will be passed in, containing event details. The event binding works as follows:

@@ -268,18 +236,9 @@

Custom Events

The following events are available:

    -
  • "hanko-auth-flow-completed": Will be triggered after a session has been created and the user has completed possible -additional steps (e.g. passkey registration or password recovery) via the <hanko-auth> element.
  • -
-
hanko.onAuthFlowCompleted((authFlowCompletedDetail) => {
-  // Login, registration or recovery has been completed successfully. You can now take control and redirect the
-  // user to protected pages.
-  console.info(`User successfully completed the registration or authorization process (user-id: "${authFlowCompletedDetail.userID}")`);
-})
-
-
    -
  • "hanko-session-created": Will be triggered before the "hanko-auth-flow-completed" happens, as soon as the user is technically logged in. -It will also be triggered when the user logs in via another browser window. The event can be used to obtain the JWT. Please note, that the +
  • "hanko-session-created": Will be triggered after a session has been created and the user has completed possible +additional steps (e.g. passkey registration or password recovery). It will also be triggered when the user logs in via +another browser window. The event can be used to obtain the JWT. Please note, that the JWT is only available, when the Hanko API configuration allows to obtain the JWT. When using Hanko-Cloud the JWT is always present, for self-hosted Hanko-APIs you can restrict the cookie to be readable by the backend only, as long as your backend runs under the same domain as your frontend. To do so, make sure the config parameter "session.enable_auth_token_header" diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_Cookie.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_Cookie.ts.html index 64a27cd51..5e4cb269d 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/lib_Cookie.ts.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_Cookie.ts.html @@ -68,7 +68,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_Dto.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_Dto.ts.html index 5850581cf..8533adb1d 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/lib_Dto.ts.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_Dto.ts.html @@ -68,7 +68,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_Errors.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_Errors.ts.html index 3c41c61ef..1a3c1983c 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/lib_Errors.ts.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_Errors.ts.html @@ -68,7 +68,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_Session.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_Session.ts.html index c29946656..7c32a70d6 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/lib_Session.ts.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_Session.ts.html @@ -68,7 +68,7 @@ @@ -148,12 +148,10 @@

    lib/Session.ts

    public _get(): SessionDetail { this._sessionState.read(); - const userID = this._sessionState.getUserID(); const expirationSeconds = this._sessionState.getExpirationSeconds(); const jwt = this._cookie.getAuthCookie(); return { - userID, expirationSeconds, jwt, }; @@ -174,10 +172,10 @@

    lib/Session.ts

    @private @param {SessionDetail} detail - The session details to validate. - @returns {boolean} true if the session details are valid, false otherwise. + @returns {boolean} true if the session is valid, false otherwise. */ private static validate(detail: SessionDetail): boolean { - return !!(detail.expirationSeconds > 0 && detail.userID?.length); + return detail.expirationSeconds > 0; } } diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_Throttle.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_Throttle.ts.html index a93130286..c3cb63e3b 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/lib_Throttle.ts.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_Throttle.ts.html @@ -68,7 +68,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_WebauthnSupport.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_WebauthnSupport.ts.html index ed305924c..c216c09b5 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/lib_WebauthnSupport.ts.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_WebauthnSupport.ts.html @@ -68,7 +68,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_Client.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_Client.ts.html index a07251b18..bb4e91f77 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_Client.ts.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_Client.ts.html @@ -68,7 +68,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_ConfigClient.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_ConfigClient.ts.html deleted file mode 100644 index af96b2e07..000000000 --- a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_ConfigClient.ts.html +++ /dev/null @@ -1,148 +0,0 @@ - - - - - - - - - - lib/client/ConfigClient.ts - - - - - - - - - - - - - - - - - - - - - - - -
    -
    - - - -
    -
    -
    - -
    -
    -
    -

    Source

    -

    lib/client/ConfigClient.ts

    -
    - - - - - -
    -
    -
    import { Config } from "../Dto";
    -import { TechnicalError } from "../Errors";
    -import { Client } from "./Client";
    -
    -/**
    - * A class for retrieving configurations from the API.
    - *
    - * @category SDK
    - * @subcategory Clients
    - * @extends {Client}
    - */
    -class ConfigClient extends Client {
    -  /**
    -   * Retrieves the frontend configuration.
    -   * @return {Promise<Config>}
    -   * @throws {RequestTimeoutError}
    -   * @throws {TechnicalError}
    -   * @see https://docs.hanko.io/api/public#tag/.well-known/operation/getConfig
    -   */
    -  async get(): Promise<Config> {
    -    const response = await this.client.get("/.well-known/config");
    -
    -    if (!response.ok) {
    -      throw new TechnicalError();
    -    }
    -
    -    return response.json();
    -  }
    -}
    -
    -export { ConfigClient };
    -
    -
    -
    - - - - -
    - - - -
    -
    -
    -
    - - - - - - - diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_EmailClient.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_EmailClient.ts.html index a8d0a32ba..951537620 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_EmailClient.ts.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_EmailClient.ts.html @@ -68,7 +68,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_EnterpriseClient.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_EnterpriseClient.ts.html index c62556ef6..c92b236db 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_EnterpriseClient.ts.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_EnterpriseClient.ts.html @@ -68,7 +68,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_HttpClient.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_HttpClient.ts.html index 5f5b325a3..5280571a4 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_HttpClient.ts.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_HttpClient.ts.html @@ -68,7 +68,7 @@ @@ -87,7 +87,6 @@

    lib/client/HttpClient.ts

    import { RequestTimeoutError, TechnicalError } from "../Errors";
     import { SessionState } from "../state/session/SessionState";
    -import { PasscodeState } from "../state/users/PasscodeState";
     import { Dispatcher } from "../events/Dispatcher";
     import { Cookie } from "../Cookie";
     
    @@ -229,7 +228,6 @@ 

    lib/client/HttpClient.ts

    timeout: number; api: string; sessionState: SessionState; - passcodeState: PasscodeState; dispatcher: Dispatcher; cookie: Cookie; @@ -238,13 +236,13 @@

    lib/client/HttpClient.ts

    this.api = api; this.timeout = options.timeout; this.sessionState = new SessionState({ ...options }); - this.passcodeState = new PasscodeState(options.cookieName); this.dispatcher = new Dispatcher({ ...options }); this.cookie = new Cookie({ ...options }); } // eslint-disable-next-line require-jsdoc _fetch(path: string, options: RequestInit, xhr = new XMLHttpRequest()) { + const self = this; const url = this.api + path; const timeout = this.timeout; const bearerToken = this.cookie.getAuthCookie(); @@ -261,8 +259,8 @@

    lib/client/HttpClient.ts

    xhr.timeout = timeout; xhr.withCredentials = true; xhr.onload = () => { - const response = new Response(xhr); - resolve(response); + self.processHeaders(xhr); + resolve(new Response(xhr)); }; xhr.onerror = () => { @@ -278,26 +276,24 @@

    lib/client/HttpClient.ts

    } /** - * Processes the response headers on login and extracts the JWT and expiration time. Also, the passcode state will be - * removed, the session state updated und a `hanko-session-created` event will be dispatched. + * Processes the response headers on login and extracts the JWT and expiration time. * - * @param {string} userID - The user ID. - * @param {Response} response - The HTTP response object. + * @param {XMLHttpRequest} xhr - The xhr object. */ - processResponseHeadersOnLogin(userID: string, response: Response) { + processHeaders(xhr: XMLHttpRequest) { let jwt = ""; let expirationSeconds = 0; - response.xhr + xhr .getAllResponseHeaders() .split("\r\n") .forEach((h) => { const header = h.toLowerCase(); if (header.startsWith("x-auth-token")) { - jwt = response.headers.getResponseHeader("X-Auth-Token"); + jwt = xhr.getResponseHeader("X-Auth-Token"); } else if (header.startsWith("x-session-lifetime")) { expirationSeconds = parseInt( - response.headers.getResponseHeader("X-Session-Lifetime"), + xhr.getResponseHeader("X-Session-Lifetime"), 10, ); } @@ -311,19 +307,11 @@

    lib/client/HttpClient.ts

    this.cookie.setAuthCookie(jwt, { secure, expires }); } - this.passcodeState.read().reset(userID).write(); - if (expirationSeconds > 0) { this.sessionState.read(); this.sessionState.setExpirationSeconds(expirationSeconds); - this.sessionState.setUserID(userID); this.sessionState.setAuthFlowCompleted(false); this.sessionState.write(); - this.dispatcher.dispatchSessionCreatedEvent({ - jwt, - userID, - expirationSeconds, - }); } } diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_PasscodeClient.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_PasscodeClient.ts.html deleted file mode 100644 index 4059a128b..000000000 --- a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_PasscodeClient.ts.html +++ /dev/null @@ -1,275 +0,0 @@ - - - - - - - - - - lib/client/PasscodeClient.ts - - - - - - - - - - - - - - - - - - - - - - - -
    -
    - - - -
    -
    -
    - -
    -
    -
    -

    Source

    -

    lib/client/PasscodeClient.ts

    -
    - - - - - -
    -
    -
    import { PasscodeState } from "../state/users/PasscodeState";
    -import { Passcode } from "../Dto";
    -import {
    -  InvalidPasscodeError,
    -  MaxNumOfPasscodeAttemptsReachedError,
    -  PasscodeExpiredError,
    -  TechnicalError,
    -  TooManyRequestsError,
    -} from "../Errors";
    -import { Client } from "./Client";
    -import { HttpClientOptions } from "./HttpClient";
    -
    -/**
    - * A class to handle passcodes.
    - *
    - * @constructor
    - * @category SDK
    - * @subcategory Clients
    - * @extends {Client}
    - */
    -class PasscodeClient extends Client {
    -  state: PasscodeState;
    -
    -  // eslint-disable-next-line require-jsdoc
    -  constructor(api: string, options: HttpClientOptions) {
    -    super(api, options);
    -    /**
    -     *  @public
    -     *  @type {PasscodeState}
    -     */
    -    this.state = new PasscodeState(options.cookieName);
    -  }
    -
    -  /**
    -   * Causes the API to send a new passcode to the user's email address.
    -   *
    -   * @param {string} userID - The UUID of the user.
    -   * @param {string=} emailID - The UUID of the email address. If unspecified, the email will be sent to the primary email address.
    -   * @param {boolean=} force - Indicates the passcode should be sent, even if there is another active passcode.
    -   * @return {Promise<Passcode>}
    -   * @throws {RequestTimeoutError}
    -   * @throws {TechnicalError}
    -   * @throws {TooManyRequestsError}
    -   * @see https://docs.hanko.io/api/public#tag/Passcode/operation/passcodeInit
    -   */
    -  async initialize(
    -    userID: string,
    -    emailID?: string,
    -    force?: boolean
    -  ): Promise<Passcode> {
    -    this.state.read();
    -
    -    const lastPasscodeTTL = this.state.getTTL(userID);
    -    const lastPasscodeID = this.state.getActiveID(userID);
    -    const lastEmailID = this.state.getEmailID(userID);
    -    let retryAfter = this.state.getResendAfter(userID);
    -
    -    if (retryAfter > 0) {
    -      throw new TooManyRequestsError(retryAfter);
    -    }
    -
    -    if (!force && lastPasscodeTTL > 0 && emailID === lastEmailID) {
    -      return {
    -        id: lastPasscodeID,
    -        ttl: lastPasscodeTTL,
    -      };
    -    }
    -
    -    const body: any = { user_id: userID };
    -
    -    if (emailID) {
    -      body.email_id = emailID;
    -    }
    -
    -    const response = await this.client.post(`/passcode/login/initialize`, body);
    -
    -    if (response.status === 429) {
    -      retryAfter = response.parseNumericHeader("Retry-After");
    -      this.state.setResendAfter(userID, retryAfter).write();
    -      throw new TooManyRequestsError(retryAfter);
    -    } else if (!response.ok) {
    -      throw new TechnicalError();
    -    }
    -
    -    const passcode: Passcode = response.json();
    -
    -    this.state.setActiveID(userID, passcode.id).setTTL(userID, passcode.ttl);
    -
    -    if (emailID) {
    -      this.state.setEmailID(userID, emailID);
    -    }
    -
    -    this.state.write();
    -
    -    return passcode;
    -  }
    -
    -  /**
    -   * Validates the passcode obtained from the email.
    -   *
    -   * @param {string} userID - The UUID of the user.
    -   * @param {string} code - The passcode digests.
    -   * @return {Promise<void>}
    -   * @throws {InvalidPasscodeError}
    -   * @throws {MaxNumOfPasscodeAttemptsReachedError}
    -   * @throws {RequestTimeoutError}
    -   * @throws {TechnicalError}
    -   * @see https://docs.hanko.io/api/public#tag/Passcode/operation/passcodeFinal
    -   */
    -  async finalize(userID: string, code: string): Promise<void> {
    -    const passcodeID = this.state.read().getActiveID(userID);
    -    const ttl = this.state.getTTL(userID);
    -
    -    if (ttl <= 0) {
    -      throw new PasscodeExpiredError();
    -    }
    -
    -    const response = await this.client.post("/passcode/login/finalize", {
    -      id: passcodeID,
    -      code,
    -    });
    -
    -    if (response.status === 401) {
    -      throw new InvalidPasscodeError();
    -    } else if (response.status === 410) {
    -      this.state.reset(userID).write();
    -      throw new MaxNumOfPasscodeAttemptsReachedError();
    -    } else if (!response.ok) {
    -      throw new TechnicalError();
    -    }
    -
    -    this.client.processResponseHeadersOnLogin(userID, response);
    -
    -    return;
    -  }
    -
    -  /**
    -   * Returns the number of seconds the current passcode is active for.
    -   *
    -   * @param {string} userID - The UUID of the user.
    -   * @return {number}
    -   */
    -  getTTL(userID: string) {
    -    return this.state.read().getTTL(userID);
    -  }
    -
    -  /**
    -   * Returns the number of seconds the rate limiting is active for.
    -   *
    -   * @param {string} userID - The UUID of the user.
    -   * @return {number}
    -   */
    -  getResendAfter(userID: string) {
    -    return this.state.read().getResendAfter(userID);
    -  }
    -}
    -
    -export { PasscodeClient };
    -
    -
    -
    - - - - -
    - - - -
    -
    -
    -
    - - - - - - - diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_PasswordClient.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_PasswordClient.ts.html deleted file mode 100644 index 00d491d09..000000000 --- a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_PasswordClient.ts.html +++ /dev/null @@ -1,226 +0,0 @@ - - - - - - - - - - lib/client/PasswordClient.ts - - - - - - - - - - - - - - - - - - - - - - - -
    -
    - - - -
    -
    -
    - -
    -
    -
    -

    Source

    -

    lib/client/PasswordClient.ts

    -
    - - - - - -
    -
    -
    import { PasswordState } from "../state/users/PasswordState";
    -import { PasscodeState } from "../state/users/PasscodeState";
    -import {
    -  InvalidPasswordError,
    -  TechnicalError,
    -  TooManyRequestsError,
    -  UnauthorizedError,
    -} from "../Errors";
    -import { Client } from "./Client";
    -import { HttpClientOptions } from "./HttpClient";
    -
    -/**
    - * A class to handle passwords.
    - *
    - * @constructor
    - * @category SDK
    - * @subcategory Clients
    - * @extends {Client}
    - */
    -class PasswordClient extends Client {
    -  passwordState: PasswordState;
    -  passcodeState: PasscodeState;
    -
    -  // eslint-disable-next-line require-jsdoc
    -  constructor(api: string, options: HttpClientOptions) {
    -    super(api, options);
    -    /**
    -     *  @public
    -     *  @type {PasswordState}
    -     */
    -    this.passwordState = new PasswordState(options.cookieName);
    -    /**
    -     *  @public
    -     *  @type {PasscodeState}
    -     */
    -    this.passcodeState = new PasscodeState(options.cookieName);
    -  }
    -
    -  /**
    -   * Logs in a user with a password.
    -   *
    -   * @param {string} userID - The UUID of the user.
    -   * @param {string} password - The password.
    -   * @return {Promise<void>}
    -   * @throws {InvalidPasswordError}
    -   * @throws {RequestTimeoutError}
    -   * @throws {TechnicalError}
    -   * @throws {TooManyRequestsError}
    -   * @see https://docs.hanko.io/api/public#tag/Password/operation/passwordLogin
    -   */
    -  async login(userID: string, password: string): Promise<void> {
    -    const response = await this.client.post("/password/login", {
    -      user_id: userID,
    -      password,
    -    });
    -
    -    if (response.status === 401) {
    -      throw new InvalidPasswordError();
    -    } else if (response.status === 429) {
    -      const retryAfter = response.parseNumericHeader("Retry-After");
    -      this.passwordState.read().setRetryAfter(userID, retryAfter).write();
    -      throw new TooManyRequestsError(retryAfter);
    -    } else if (!response.ok) {
    -      throw new TechnicalError();
    -    }
    -
    -    this.client.processResponseHeadersOnLogin(userID, response);
    -    return;
    -  }
    -
    -  /**
    -   * Updates a password.
    -   *
    -   * @param {string} userID - The UUID of the user.
    -   * @param {string} password - The new password.
    -   * @return {Promise<void>}
    -   * @throws {RequestTimeoutError}
    -   * @throws {UnauthorizedError}
    -   * @throws {TechnicalError}
    -   * @see https://docs.hanko.io/api/public#tag/Password/operation/password
    -   */
    -  async update(userID: string, password: string): Promise<void> {
    -    const response = await this.client.put("/password", {
    -      user_id: userID,
    -      password,
    -    });
    -
    -    if (response.status === 401) {
    -      this.client.dispatcher.dispatchSessionExpiredEvent();
    -      throw new UnauthorizedError();
    -    } else if (!response.ok) {
    -      throw new TechnicalError();
    -    }
    -
    -    return;
    -  }
    -
    -  /**
    -   * Returns the number of seconds the rate limiting is active for.
    -   *
    -   * @param {string} userID - The UUID of the user.
    -   * @return {number}
    -   */
    -  getRetryAfter(userID: string) {
    -    return this.passwordState.read().getRetryAfter(userID);
    -  }
    -}
    -
    -export { PasswordClient };
    -
    -
    -
    - - - - -
    - - - -
    -
    -
    -
    - - - - - - - diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_ThirdPartyClient.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_ThirdPartyClient.ts.html index 5002256a1..90ed34591 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_ThirdPartyClient.ts.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_ThirdPartyClient.ts.html @@ -68,7 +68,7 @@ @@ -113,14 +113,14 @@

    lib/client/ThirdPartyClient.ts

    if (!provider) { throw new ThirdPartyError( "somethingWentWrong", - new Error("provider missing from request") + new Error("provider missing from request"), ); } if (!redirectTo) { throw new ThirdPartyError( "somethingWentWrong", - new Error("redirectTo missing from request") + new Error("redirectTo missing from request"), ); } diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_TokenClient.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_TokenClient.ts.html index 5e897f6d5..c9c35c246 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_TokenClient.ts.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_TokenClient.ts.html @@ -68,7 +68,7 @@ @@ -87,7 +87,6 @@

    lib/client/TokenClient.ts

    import { Client } from "./Client";
     import { TechnicalError } from "../Errors";
    -import { TokenFinalized } from "../Dto";
     
     /**
      * Client responsible for exchanging one time tokens for session JWTs.
    @@ -120,8 +119,7 @@ 

    lib/client/TokenClient.ts

    throw new TechnicalError(); } - const tokenResponse: TokenFinalized = response.json(); - this.client.processResponseHeadersOnLogin(tokenResponse.user_id, response); + return response.json(); } }
    diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_UserClient.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_UserClient.ts.html index 4b9341ea1..013a0d798 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_UserClient.ts.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_UserClient.ts.html @@ -68,7 +68,7 @@ @@ -143,17 +143,14 @@

    lib/client/UserClient.ts

    if (response.status === 409) { throw new ConflictError(); - } if (response.status === 403) { + } + if (response.status === 403) { throw new ForbiddenError(); } else if (!response.ok) { throw new TechnicalError(); } - const createUser: UserCreated = response.json(); - if (createUser && createUser.user_id) { - this.client.processResponseHeadersOnLogin(createUser.user_id, response); - } - return createUser; + return response.json(); } /** diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_WebauthnClient.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_client_WebauthnClient.ts.html deleted file mode 100644 index ac811b41d..000000000 --- a/docs/static/jsdoc/hanko-frontend-sdk/lib_client_WebauthnClient.ts.html +++ /dev/null @@ -1,417 +0,0 @@ - - - - - - - - - - lib/client/WebauthnClient.ts - - - - - - - - - - - - - - - - - - - - - - - -
    -
    - - - -
    -
    -
    - -
    -
    -
    -

    Source

    -

    lib/client/WebauthnClient.ts

    -
    - - - - - -
    -
    -
    import {
    -  create as createWebauthnCredential,
    -  get as getWebauthnCredential,
    -} from "@github/webauthn-json";
    -
    -import { WebauthnSupport } from "../WebauthnSupport";
    -import { Client } from "./Client";
    -import { PasscodeState } from "../state/users/PasscodeState";
    -import { WebauthnState } from "../state/users/WebauthnState";
    -
    -import {
    -  InvalidWebauthnCredentialError,
    -  TechnicalError,
    -  UnauthorizedError,
    -  UserVerificationError,
    -  WebauthnRequestCancelledError,
    -} from "../Errors";
    -
    -import {
    -  Attestation,
    -  User,
    -  WebauthnCredentials,
    -  WebauthnFinalized,
    -} from "../Dto";
    -import { HttpClientOptions } from "./HttpClient";
    -
    -/**
    - * A class that handles WebAuthn authentication and registration.
    - *
    - * @constructor
    - * @category SDK
    - * @subcategory Clients
    - * @extends {Client}
    - */
    -class WebauthnClient extends Client {
    -  webauthnState: WebauthnState;
    -  passcodeState: PasscodeState;
    -  controller: AbortController;
    -  _getCredential = getWebauthnCredential;
    -  _createCredential = createWebauthnCredential;
    -
    -  // eslint-disable-next-line require-jsdoc
    -  constructor(api: string, options: HttpClientOptions) {
    -    super(api, options);
    -    /**
    -     *  @public
    -     *  @type {WebauthnState}
    -     */
    -    this.webauthnState = new WebauthnState(options.cookieName);
    -    /**
    -     *  @public
    -     *  @type {PasscodeState}
    -     */
    -    this.passcodeState = new PasscodeState(options.cookieName);
    -  }
    -
    -  /**
    -   * Performs a WebAuthn authentication ceremony. When 'userID' is specified, the API provides a list of
    -   * allowed credentials and the browser is able to present a list of suitable credentials to the user.
    -   *
    -   * @param {string=} userID - The user's UUID.
    -   * @param {boolean=} useConditionalMediation - Enables autofill assisted login.
    -   * @return {Promise<void>}
    -   * @throws {WebauthnRequestCancelledError}
    -   * @throws {InvalidWebauthnCredentialError}
    -   * @throws {RequestTimeoutError}
    -   * @throws {TechnicalError}
    -   * @see https://docs.hanko.io/api/public#tag/WebAuthn/operation/webauthnLoginInit
    -   * @see https://docs.hanko.io/api/public#tag/WebAuthn/operation/webauthnLoginFinal
    -   * @see https://www.w3.org/TR/webauthn-2/#authentication-ceremony
    -   * @return {WebauthnFinalized}
    -   */
    -  async login(
    -    userID?: string,
    -    useConditionalMediation?: boolean
    -  ): Promise<WebauthnFinalized> {
    -    const challengeResponse = await this.client.post(
    -      "/webauthn/login/initialize",
    -      { user_id: userID }
    -    );
    -
    -    if (!challengeResponse.ok) {
    -      throw new TechnicalError();
    -    }
    -
    -    const challenge = challengeResponse.json();
    -    challenge.signal = this._createAbortSignal();
    -
    -    if (useConditionalMediation) {
    -      // `CredentialMediationRequirement` doesn't support "conditional" in the current typescript version.
    -      challenge.mediation = "conditional" as CredentialMediationRequirement;
    -    }
    -
    -    let assertion;
    -    try {
    -      assertion = await this._getCredential(challenge);
    -    } catch (e) {
    -      throw new WebauthnRequestCancelledError(e);
    -    }
    -
    -    const assertionResponse = await this.client.post(
    -      "/webauthn/login/finalize",
    -      assertion
    -    );
    -
    -    if (assertionResponse.status === 400 || assertionResponse.status === 401) {
    -      throw new InvalidWebauthnCredentialError();
    -    } else if (!assertionResponse.ok) {
    -      throw new TechnicalError();
    -    }
    -
    -    const finalizeResponse: WebauthnFinalized = assertionResponse.json();
    -
    -    this.webauthnState
    -      .read()
    -      .addCredential(finalizeResponse.user_id, finalizeResponse.credential_id)
    -      .write();
    -
    -    this.client.processResponseHeadersOnLogin(
    -      finalizeResponse.user_id,
    -      assertionResponse
    -    );
    -
    -    return finalizeResponse;
    -  }
    -
    -  /**
    -   * Performs a WebAuthn registration ceremony.
    -   *
    -   * @return {Promise<void>}
    -   * @throws {WebauthnRequestCancelledError}
    -   * @throws {RequestTimeoutError}
    -   * @throws {UnauthorizedError}
    -   * @throws {TechnicalError}
    -   * @throws {UserVerificationError}
    -   * @see https://docs.hanko.io/api/public#tag/WebAuthn/operation/webauthnRegInit
    -   * @see https://docs.hanko.io/api/public#tag/WebAuthn/operation/webauthnRegFinal
    -   * @see https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential
    -   */
    -  async register(): Promise<void> {
    -    const challengeResponse = await this.client.post(
    -      "/webauthn/registration/initialize"
    -    );
    -
    -    if (challengeResponse.status === 401) {
    -      this.client.dispatcher.dispatchSessionExpiredEvent();
    -      throw new UnauthorizedError();
    -    } else if (!challengeResponse.ok) {
    -      throw new TechnicalError();
    -    }
    -
    -    const challenge = challengeResponse.json();
    -    challenge.signal = this._createAbortSignal();
    -
    -    let attestation;
    -    try {
    -      attestation = (await this._createCredential(challenge)) as Attestation;
    -    } catch (e) {
    -      throw new WebauthnRequestCancelledError(e);
    -    }
    -
    -    // The generated PublicKeyCredentialWithAttestationJSON object does not align with the API. The list of
    -    // supported transports must be available under a different path.
    -    attestation.transports = attestation.response.transports;
    -
    -    const attestationResponse = await this.client.post(
    -      "/webauthn/registration/finalize",
    -      attestation
    -    );
    -
    -    if (attestationResponse.status === 401) {
    -      this.client.dispatcher.dispatchSessionExpiredEvent();
    -      throw new UnauthorizedError();
    -    }
    -    if (attestationResponse.status === 422) {
    -      throw new UserVerificationError();
    -    }
    -    if (!attestationResponse.ok) {
    -      throw new TechnicalError();
    -    }
    -
    -    const finalizeResponse: WebauthnFinalized = attestationResponse.json();
    -    this.webauthnState
    -      .read()
    -      .addCredential(finalizeResponse.user_id, finalizeResponse.credential_id)
    -      .write();
    -
    -    return;
    -  }
    -
    -  /**
    -   * Returns a list of all WebAuthn credentials assigned to the current user.
    -   *
    -   * @return {Promise<WebauthnCredentials>}
    -   * @throws {UnauthorizedError}
    -   * @throws {RequestTimeoutError}
    -   * @throws {TechnicalError}
    -   * @see https://docs.hanko.io/api/public#tag/WebAuthn/operation/listCredentials
    -   */
    -  async listCredentials(): Promise<WebauthnCredentials> {
    -    const response = await this.client.get("/webauthn/credentials");
    -
    -    if (response.status === 401) {
    -      this.client.dispatcher.dispatchSessionExpiredEvent();
    -      throw new UnauthorizedError();
    -    } else if (!response.ok) {
    -      throw new TechnicalError();
    -    }
    -
    -    return response.json();
    -  }
    -
    -  /**
    -   * Updates the WebAuthn credential.
    -   *
    -   * @param {string=} credentialID - The credential's UUID.
    -   * @param {string} name - The new credential name.
    -   * @return {Promise<void>}
    -   * @throws {UnauthorizedError}
    -   * @throws {RequestTimeoutError}
    -   * @throws {TechnicalError}
    -   * @see https://docs.hanko.io/api/public#tag/WebAuthn/operation/updateCredential
    -   */
    -  async updateCredential(credentialID: string, name: string): Promise<void> {
    -    const response = await this.client.patch(
    -      `/webauthn/credentials/${credentialID}`,
    -      {
    -        name,
    -      }
    -    );
    -
    -    if (response.status === 401) {
    -      this.client.dispatcher.dispatchSessionExpiredEvent();
    -      throw new UnauthorizedError();
    -    } else if (!response.ok) {
    -      throw new TechnicalError();
    -    }
    -
    -    return;
    -  }
    -
    -  /**
    -   * Deletes the WebAuthn credential.
    -   *
    -   * @param {string=} credentialID - The credential's UUID.
    -   * @return {Promise<void>}
    -   * @throws {UnauthorizedError}
    -   * @throws {RequestTimeoutError}
    -   * @throws {TechnicalError}
    -   * @see https://docs.hanko.io/api/public#tag/WebAuthn/operation/deleteCredential
    -   */
    -  async deleteCredential(credentialID: string): Promise<void> {
    -    const response = await this.client.delete(
    -      `/webauthn/credentials/${credentialID}`
    -    );
    -
    -    if (response.status === 401) {
    -      this.client.dispatcher.dispatchSessionExpiredEvent();
    -      throw new UnauthorizedError();
    -    } else if (!response.ok) {
    -      throw new TechnicalError();
    -    }
    -
    -    return;
    -  }
    -
    -  /**
    -   * Determines whether a credential registration ceremony should be performed. Returns 'true' when WebAuthn
    -   * is supported and the user's credentials do not intersect with the credentials already known on the
    -   * current browser/device.
    -   *
    -   * @param {User} user - The user object.
    -   * @return {Promise<boolean>}
    -   */
    -  async shouldRegister(user: User): Promise<boolean> {
    -    const supported = WebauthnSupport.supported();
    -
    -    if (!user.webauthn_credentials || !user.webauthn_credentials.length) {
    -      return supported;
    -    }
    -
    -    const matches = this.webauthnState
    -      .read()
    -      .matchCredentials(user.id, user.webauthn_credentials);
    -
    -    return supported && !matches.length;
    -  }
    -
    -  // eslint-disable-next-line require-jsdoc
    -  _createAbortSignal() {
    -    if (this.controller) {
    -      this.controller.abort();
    -    }
    -
    -    this.controller = new AbortController();
    -    return this.controller.signal;
    -  }
    -}
    -
    -export { WebauthnClient };
    -
    -
    -
    - - - - -
    - - - -
    -
    -
    -
    - - - - - - - diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_events_CustomEvents.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_events_CustomEvents.ts.html index 2c35399f6..ea8622401 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/lib_events_CustomEvents.ts.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_events_CustomEvents.ts.html @@ -68,7 +68,7 @@ @@ -117,12 +117,18 @@

    lib/events/CustomEvents.ts

    export const userDeletedType: "hanko-user-deleted" = "hanko-user-deleted"; /** - * The type of the `hanko-auth-flow-completed` event. - * @typedef {string} authFlowCompletedType + * The type of the `hanko-user-logged-in` event. + * @typedef {string} userLoggedInType * @memberOf Listener */ -export const authFlowCompletedType: "hanko-auth-flow-completed" = - "hanko-auth-flow-completed"; +export const userLoggedInType: "hanko-user-logged-in" = "hanko-user-logged-in"; + +/** + * The type of the `hanko-user-created` event. + * @typedef {string} userCreatedType + * @memberOf Listener + */ +export const userCreatedType: "hanko-user-created" = "hanko-user-created"; /** * The data passed in the `hanko-session-created` or `hanko-session-resumed` event. @@ -132,24 +138,10 @@

    lib/events/CustomEvents.ts

    * @subcategory Events * @property {string=} jwt - The JSON web token associated with the session. Only present when the Hanko-API allows the JWT to be accessible client-side. * @property {number} expirationSeconds - The number of seconds until the JWT expires. - * @property {string} userID - The user associated with the session. */ export interface SessionDetail { jwt?: string; expirationSeconds: number; - userID: string; -} - -/** - * The data passed in the `hanko-auth-flow-completed` event. - * - * @interface - * @category SDK - * @subcategory Events - * @property {string} userID - The user associated with the removed session. - */ -export interface AuthFlowCompletedDetail { - userID: string; } /** diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_events_Dispatcher.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_events_Dispatcher.ts.html index 9e934bad8..11cdba8a1 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/lib_events_Dispatcher.ts.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_events_Dispatcher.ts.html @@ -68,7 +68,7 @@ @@ -88,11 +88,9 @@

    lib/events/Dispatcher.ts

    import {
       SessionDetail,
       CustomEventWithDetail,
    -  AuthFlowCompletedDetail,
       sessionCreatedType,
       sessionExpiredType,
       userDeletedType,
    -  authFlowCompletedType,
       userLoggedOutType,
     } from "./CustomEvents";
     import { SessionState } from "../state/session/SessionState";
    @@ -164,16 +162,6 @@ 

    lib/events/Dispatcher.ts

    public dispatchUserDeletedEvent() { this.dispatch(userDeletedType, null); } - - /** - * Dispatches a "hanko-auth-flow-completed" event to the document with the specified detail. - * - * @param {AuthFlowCompletedDetail} detail - The event detail. - */ - public dispatchAuthFlowCompletedEvent(detail: AuthFlowCompletedDetail) { - this._sessionState.read().setAuthFlowCompleted(true).write(); - this.dispatch(authFlowCompletedType, detail); - } }
    diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_events_Listener.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_events_Listener.ts.html index c7474343c..9af6d8e2c 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/lib_events_Listener.ts.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_events_Listener.ts.html @@ -68,7 +68,7 @@ @@ -89,11 +89,9 @@

    lib/events/Listener.ts

    import { CustomEventWithDetail, SessionDetail, - AuthFlowCompletedDetail, sessionCreatedType, sessionExpiredType, userDeletedType, - authFlowCompletedType, userLoggedOutType, } from "./CustomEvents"; @@ -171,7 +169,7 @@

    lib/events/Listener.ts

    */ private wrapCallback<T>( callback: CallbackFunc<T>, - throttle: boolean + throttle: boolean, ): WrappedCallback<T> { // The function that will be called when the event is triggered. const wrappedCallback = (event: CustomEventWithDetail<T>) => { @@ -221,7 +219,7 @@

    lib/events/Listener.ts

    private static mapAddEventListenerParams<T>( type: string, { once, callback }: EventListenerParams<T>, - throttle?: boolean + throttle?: boolean, ): EventListenerWithTypeParams<T> { return { type, @@ -243,10 +241,10 @@

    lib/events/Listener.ts

    private addEventListener<T>( type: string, params: EventListenerParams<T>, - throttle?: boolean + throttle?: boolean, ) { return this.addEventListenerWithType( - Listener.mapAddEventListenerParams(type, params, throttle) + Listener.mapAddEventListenerParams(type, params, throttle), ); } @@ -260,7 +258,7 @@

    lib/events/Listener.ts

    */ public onSessionCreated( callback: CallbackFunc<SessionDetail>, - once?: boolean + once?: boolean, ): CleanupFunc { return this.addEventListener(sessionCreatedType, { callback, once }, true); } @@ -276,7 +274,7 @@

    lib/events/Listener.ts

    */ public onSessionExpired( callback: CallbackFunc<null>, - once?: boolean + once?: boolean, ): CleanupFunc { return this.addEventListener(sessionExpiredType, { callback, once }, true); } @@ -291,7 +289,7 @@

    lib/events/Listener.ts

    */ public onUserLoggedOut( callback: CallbackFunc<null>, - once?: boolean + once?: boolean, ): CleanupFunc { return this.addEventListener(userLoggedOutType, { callback, once }); } @@ -305,24 +303,10 @@

    lib/events/Listener.ts

    */ public onUserDeleted( callback: CallbackFunc<null>, - once?: boolean + once?: boolean, ): CleanupFunc { return this.addEventListener(userDeletedType, { callback, once }); } - - /** - * Adds an event listener for hanko-auth-flow-completed events. Will be triggered after the login or registration flow has been completed. - * - * @param {CallbackFunc<AuthFlowCompletedDetail>} callback - The function to be called when the event is triggered. - * @param {boolean=} once - Whether the event listener should be removed after being called once. - * @returns {CleanupFunc} This function can be called to remove the event listener. - */ - public onAuthFlowCompleted( - callback: CallbackFunc<AuthFlowCompletedDetail>, - once?: boolean - ): CleanupFunc { - return this.addEventListener(authFlowCompletedType, { callback, once }); - } }
    diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_events_Relay.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_events_Relay.ts.html index 346cb8a0f..25685beb2 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/lib_events_Relay.ts.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_events_Relay.ts.html @@ -68,7 +68,7 @@ @@ -136,7 +136,7 @@

    lib/events/Relay.ts

    this._scheduler.scheduleTask( sessionExpiredType, () => this.dispatchSessionExpiredEvent(), - detail.expirationSeconds + detail.expirationSeconds, ); }; @@ -168,11 +168,6 @@

    lib/events/Relay.ts

    return; } - if (this._session.isAuthFlowCompleted()) { - this.dispatchAuthFlowCompletedEvent({ userID: sessionDetail.userID }); - return; - } - this.dispatchSessionCreatedEvent(sessionDetail); }; diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_events_Scheduler.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_events_Scheduler.ts.html index 1fefacbde..58228967b 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/lib_events_Scheduler.ts.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_events_Scheduler.ts.html @@ -68,7 +68,7 @@ diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_flow-api_Flow.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_flow-api_Flow.ts.html new file mode 100644 index 000000000..185088816 --- /dev/null +++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_flow-api_Flow.ts.html @@ -0,0 +1,241 @@ + + + + + + + + + + lib/flow-api/Flow.ts + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + +
    +
    +
    + +
    +
    +
    +

    Source

    +

    lib/flow-api/Flow.ts

    +
    + + + + + +
    +
    +
    import { Client } from "../client/Client";
    +import { State, isState } from "./State";
    +import { Action } from "./types/action";
    +import { FetchNextState, FlowPath, Handlers } from "./types/state-handling";
    +
    +type MaybePromise<T> = T | Promise<T>;
    +
    +type ExtendedHandlers = Handlers & { onError?: (e: unknown) => any };
    +type GetInitState = (flow: Flow) => MaybePromise<State<any> | null>;
    +
    +// eslint-disable-next-line require-jsdoc
    +class Flow extends Client {
    +  public async init(
    +    initPath: FlowPath,
    +    handlers: ExtendedHandlers,
    +    // getInitState: GetInitState = () => this.fetchNextState(initPath),
    +  ): Promise<void> {
    +    const fetchNextState: FetchNextState = async (href: string, body?: any) => {
    +      try {
    +        const response = await this.client.post(href, body);
    +        return new State(response.json(), fetchNextState);
    +      } catch (e) {
    +        handlers.onError?.(e);
    +      }
    +    };
    +
    +    const initState = await fetchNextState(initPath);
    +    await this.run(initState, handlers);
    +  }
    +
    +  public async fromString(init: string, handlers: ExtendedHandlers) {
    +    const fetchNextState: FetchNextState = async (href: string, body?: any) => {
    +      try {
    +        const response = await this.client.post(href, body);
    +        return new State(response.json(), fetchNextState);
    +      } catch (e) {
    +        handlers.onError?.(e);
    +      }
    +    };
    +
    +    const initState = new State(JSON.parse(init), fetchNextState);
    +    await this.run(initState, handlers);
    +  }
    +
    +  /**
    +   * Runs a handler for a given state.
    +   *
    +   * If the handler returns an action or a state, this method will run the next
    +   * appropriate handler for that state. (Recursively)
    +   *
    +   * If the handlers passed to `init` do not contain an `onError` handler,
    +   * this method will throw.
    +   *
    +   * @see InvalidStateError
    +   * @see HandlerNotFoundError
    +   *
    +   * @example
    +   * const handlerResult = await run("/login", {
    +   *   // all login handlers are in here, one of which will be called
    +   *   // based on what the /login endpoint returns
    +   * });
    +   */
    +  run = async (
    +    state: State<any>,
    +    handlers: ExtendedHandlers,
    +  ): Promise<unknown> => {
    +    try {
    +      if (!isState(state)) {
    +        throw new InvalidStateError(state);
    +      }
    +
    +      const handler = handlers[state.name];
    +      if (!handler) {
    +        throw new HandlerNotFoundError(state);
    +      }
    +
    +      let maybeNextState = await handler(state);
    +
    +      // handler can return an action, which we'll run (and turn into state)...
    +      if (isAction(maybeNextState)) {
    +        maybeNextState = await (maybeNextState as any).run();
    +      }
    +
    +      // ...or a state, to continue the "run loop"
    +      if (isState(maybeNextState)) {
    +        return this.run(maybeNextState, handlers);
    +      }
    +    } catch (e) {
    +      if (typeof handlers.onError === "function") {
    +        return handlers.onError(e);
    +      }
    +    }
    +  };
    +}
    +
    +export class HandlerNotFoundError extends Error {
    +  constructor(public state: State<any>) {
    +    super(
    +      `No handler found for state: ${
    +        typeof state.name === "string"
    +          ? `"${state.name}"`
    +          : `(${typeof state.name})`
    +      }`,
    +    );
    +  }
    +}
    +
    +export class InvalidStateError extends Error {
    +  constructor(public state: State<any>) {
    +    super(
    +      `Invalid state: ${
    +        typeof state.name === "string"
    +          ? `"${state.name}"`
    +          : `(${typeof state.name})`
    +      }`,
    +    );
    +  }
    +}
    +
    +export function isAction(x: any): x is Action<unknown> {
    +  return typeof x === "object" && x !== null && "href" in x && "inputs" in x;
    +}
    +
    +export { Flow };
    +
    +
    +
    + + + + +
    + + + +
    +
    +
    +
    + + + + + + + diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_flow-api_State.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_flow-api_State.ts.html new file mode 100644 index 000000000..b5a05146b --- /dev/null +++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_flow-api_State.ts.html @@ -0,0 +1,478 @@ + + + + + + + + + + lib/flow-api/State.ts + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + +
    +
    +
    + +
    +
    +
    +

    Source

    +

    lib/flow-api/State.ts

    +
    + + + + + +
    +
    +
    import {
    +  FetchNextState,
    +  StateName,
    +  Actions,
    +  Payloads,
    +} from "./types/state-handling";
    +import { Error } from "./types/error";
    +import { Action } from "./types/action";
    +import { Input } from "./types/input";
    +
    +type InputValues<TInput extends Record<string, Input<any>>> = {
    +  [K in keyof TInput]?: TInput[K]["value"];
    +};
    +
    +type CreateAction<TAction extends Action<any>> = (
    +  inputs: InputValues<TAction["inputs"]>
    +) => TAction & {
    +  run: () => Promise<State<any>>;
    +  validate: () => TAction;
    +  tryValidate: () => ValidationError | void;
    +};
    +
    +type ActionFunctions = {
    +  [TStateName in keyof Actions]: {
    +    [TActionName in keyof Actions[TStateName]]: Actions[TStateName][TActionName] extends Action<
    +      infer Inputs
    +    >
    +      ? CreateAction<Action<Inputs>>
    +      : never;
    +  };
    +};
    +
    +interface StateResponse<TStateName extends StateName> {
    +  name: StateName;
    +  status: number;
    +  payload?: Payloads[TStateName];
    +  actions?: Actions[TStateName];
    +  csrf_token: string;
    +  error: Error;
    +}
    +
    +// State class represents a state in the flow
    +// eslint-disable-next-line require-jsdoc
    +class State<TStateName extends StateName>
    +  implements Omit<StateResponse<TStateName>, "actions">
    +{
    +  readonly name: StateName;
    +  readonly payload?: Payloads[TStateName];
    +  readonly error: Error;
    +  readonly status: number;
    +  readonly csrf_token: string;
    +
    +  readonly #actionDefinitions: Actions[TStateName];
    +  readonly actions: ActionFunctions[TStateName];
    +
    +  private readonly fetchNextState: FetchNextState;
    +
    +  toJSON() {
    +    return {
    +      name: this.name,
    +      payload: this.payload,
    +      error: this.error,
    +      status: this.status,
    +      csrf_token: this.csrf_token,
    +      actions: this.#actionDefinitions,
    +    };
    +  }
    +
    +  // eslint-disable-next-line require-jsdoc
    +  constructor(
    +    { name, payload, error, status, actions, csrf_token }: StateResponse<TStateName>,
    +    fetchNextState: FetchNextState
    +  ) {
    +    this.name = name;
    +    this.payload = payload;
    +    this.error = error;
    +    this.status = status;
    +    this.csrf_token = csrf_token;
    +    this.#actionDefinitions = actions;
    +
    +    // We're doing something really hacky here, but hear me out
    +    //
    +    // `actions` is an object like this:
    +    //
    +    //     { login_password_recovery: { inputs: { new_password: { min_length: 8, value: "this still needs to be set" } } } }
    +    //
    +    // However, we don't want users to have to mutate the `actions` object manually.
    +    // They WOULD have to do this:
    +    //
    +    //     actions.login_password_recovery.inputs.new_password.value = "password";
    +    //
    +    // Instead, we're going to wrap the `actions` object in a Proxy.
    +    // This Proxy transforms the manual mutation you're seeing above into a function call.
    +    // The following is doing the same thing as the manual mutation above:
    +    //
    +    //     actions.login_password_recovery({ new_password: "password" });
    +    //
    +    // Okay, there's one difference, the function call creates a copy of the action, so it's not mutating the original object.
    +    // The newly created action is returned. It also has a `run` method, which sends the action to the server (fetchNextState)
    +    this.actions = this.#createActionsProxy(actions, csrf_token);
    +
    +    // Do not remove! `this.fetchNextState` has to be set for `this.#runAction` to work
    +    this.fetchNextState = fetchNextState;
    +  }
    +
    +  /**
    +   * We get the `actions` object from the server. That object is essentially a definition of actions that can be performed in the current state.
    +   *
    +   * For example:
    +   *
    +   *     actions = {
    +   *       login_password_recovery: {
    +   *         inputs: {
    +   *           email: { value: undefined, required: true, ... },
    +   *           password: { value: undefined, required: true, min_length: 8, ... }
    +   *         }
    +   *       },
    +   *       create_account: { inputs: ... },
    +   *       some_other_action: { inputs: ... },
    +   *     };
    +   *
    +   * The proxy returned by this method creates "action functions".
    +   *
    +   * Each action function copies the original definition (`{ inputs: ... }`) and modifies that copy with the inputs provided by the user.
    +   *
    +   * In practice, it looks like this:
    +   *
    +   *     actions.login_password_recovery({ new_password: "very-secure-123" });
    +   *     // => { inputs: { password: { value: "very-secure-123", min_length: 8, ... }}}
    +   *
    +   * Additionally, helper methods like `run` (to send the action to the server) and `validate` (to validate the inputs; the `inputs` object also contains validation rules)
    +   */
    +  #createActionsProxy(actions: Actions[TStateName], csrfToken: string) {
    +    const runAction = (action: Action<any>) => this.runAction(action, csrfToken);
    +    const validateAction = (action: Action<any>) => this.validateAction(action);
    +
    +    return new Proxy(actions, {
    +      get(target, prop): CreateAction<Action<unknown>> | undefined {
    +        if (typeof prop === "symbol") return (target as any)[prop];
    +
    +        type Original = Actions[TStateName][keyof Actions[TStateName]];
    +        type Prop = keyof typeof target;
    +
    +        /**
    +         * This is the action defintion.
    +         * Running the function returned by this getter creates a **deep copy**
    +         * with values set by the user.
    +         */
    +        const originalAction = target[
    +          prop as Prop
    +        ] satisfies Original as Action<unknown>;
    +
    +        if (originalAction == null) {
    +          return null;
    +        }
    +
    +        return (newInputs: any) => {
    +          const action = Object.assign(deepCopy(originalAction), {
    +            validate() {
    +              validateAction(action);
    +              return action;
    +            },
    +            tryValidate() {
    +              try {
    +                validateAction(action);
    +              } catch (e) {
    +                if (e instanceof ValidationError) return e;
    +
    +                // We still want to throw non-ValidationErrors since they're unexpected (and indicate a bug on our side)
    +                throw e;
    +              }
    +            },
    +            run() {
    +              return runAction(action);
    +            },
    +          });
    +
    +          // If `actions` is an object that has inputs,
    +          //
    +          // Transform this:
    +          // actions.login_password_recovery({ new_password: "password" });
    +          //                                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    +          // Into this:
    +          // action.inputs = { new_password: { min_length: 8, value: "password", ... }}
    +          if (
    +            action !== null &&
    +            typeof action === "object" &&
    +            "inputs" in action
    +          ) {
    +            for (const inputName in newInputs) {
    +              const actionInputs = action.inputs as Record<
    +                string,
    +                Input<unknown>
    +              >;
    +
    +              if (!actionInputs[inputName]) {
    +                actionInputs[inputName] = { name: inputName, type: "" };
    +              }
    +
    +              actionInputs[inputName].value = newInputs[inputName];
    +            }
    +          }
    +
    +          return action;
    +        };
    +      },
    +    }) satisfies Actions[TStateName] as any;
    +  }
    +
    +  runAction(action: Action<any>, csrfToken: string): Promise<State<any>> {
    +    const data: Record<string, any> = {};
    +
    +    // Deal with object-type inputs
    +    // i.e. actions.some_action({ ... })
    +    //                          ^^^^^^^
    +    // Other input types would look like this:
    +    //
    +    // actions.another_action(1234);
    +    // actions.yet_another_action("foo");
    +    //
    +    // Meaning
    +    if (
    +      "inputs" in action &&
    +      typeof action.inputs === "object" &&
    +      action.inputs !== null
    +    ) {
    +      // This looks horrible, but at this point we're sure that `action.inputs` is a Record<string, Input>
    +      // Because there are no object-type inputs that AREN'T a Record<string, Input>
    +      const inputs = action.inputs satisfies object as Record<
    +        string,
    +        Input<unknown>
    +      >;
    +
    +      for (const inputName in action.inputs) {
    +        const input = inputs[inputName];
    +
    +        if (input && "value" in input) {
    +          data[inputName] = input.value;
    +        }
    +      }
    +    }
    +
    +    // (Possibly add more input types here?)
    +
    +    // Use the fetch function to perform the action
    +    return this.fetchNextState(action.href, {
    +      input_data: data,
    +      csrf_token: csrfToken,
    +    });
    +  }
    +
    +  validateAction(action: Action<{ [key: string]: Input<unknown> }>) {
    +    if (!("inputs" in action)) return;
    +
    +    for (const inputName in action.inputs) {
    +      const input = action.inputs[inputName];
    +
    +      function reject<T>(
    +        reason: ValidationReason,
    +        message: string,
    +        wanted?: T,
    +        actual?: T
    +      ) {
    +        throw new ValidationError({
    +          reason,
    +          inputName,
    +          wanted,
    +          actual,
    +          message,
    +        });
    +      }
    +
    +      const value = input.value as any; // TS gets in the way here
    +
    +      // TODO is !input.value right here? this will also reject empty strings, `0`, ... and will never reject an empty array/object
    +      if (input.required && !value) {
    +        reject(ValidationReason.Required, "is required");
    +      }
    +
    +      const hasLengthRequirement =
    +        input.min_length != null || input.max_length != null;
    +
    +      if (hasLengthRequirement) {
    +        if (!("length" in value)) {
    +          reject(
    +            ValidationReason.InvalidInputDefinition,
    +            'has min/max length requirement, but is missing "length" property',
    +            "string",
    +            typeof value
    +          );
    +        }
    +
    +        if (input.min_length != null && value < input.min_length) {
    +          reject(
    +            ValidationReason.MinLength,
    +            `too short (min ${input.min_length})`,
    +            input.min_length,
    +            value.length
    +          );
    +        }
    +
    +        if (input.max_length != null && value > input.max_length) {
    +          reject(
    +            ValidationReason.MaxLength,
    +            `too long (max ${input.max_length})`,
    +            input.max_length,
    +            value.length
    +          );
    +        }
    +      }
    +    }
    +  }
    +}
    +
    +export enum ValidationReason {
    +  InvalidInputDefinition,
    +  MinLength,
    +  MaxLength,
    +  Required,
    +}
    +
    +export class ValidationError<TWanted = undefined> extends Error {
    +  reason: ValidationReason;
    +  inputName: string;
    +  wanted: TWanted;
    +  actual: TWanted;
    +
    +  constructor(opts: {
    +    reason: ValidationReason;
    +    inputName: string;
    +    wanted: TWanted;
    +    actual: TWanted;
    +    message: string;
    +  }) {
    +    super(`"${opts.inputName}" ${opts.message}`);
    +
    +    this.name = "ValidationError";
    +    this.reason = opts.reason;
    +    this.inputName = opts.inputName;
    +    this.wanted = opts.wanted;
    +    this.actual = opts.actual;
    +  }
    +}
    +
    +function deepCopy<T>(obj: T): T {
    +  return JSON.parse(JSON.stringify(obj));
    +}
    +
    +export function isState(x: any): x is State<any> {
    +  return (
    +    typeof x === "object" &&
    +    x !== null &&
    +    "status" in x &&
    +    "error" in x &&
    +    "name" in x &&
    +    Boolean(x.name) &&
    +    Boolean(x.status)
    +  );
    +}
    +
    +export { State };
    +
    +
    +
    + + + + +
    + + + +
    +
    +
    +
    + + + + + + + diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_state_State.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_state_State.ts.html index 4d4c558d1..bd1818439 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/lib_state_State.ts.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_state_State.ts.html @@ -68,7 +68,7 @@ @@ -85,8 +85,7 @@

    lib/state/State.ts

    -
    import { LocalStorageUsers } from "./users/UserState";
    -import { LocalStorageSession } from "./session/SessionState";
    +            
    import { LocalStorageSession } from "./session/SessionState";
     
     /**
      * @interface
    @@ -95,7 +94,6 @@ 

    lib/state/State.ts

    * @property {LocalStorageUsers=} users - The user states. */ interface LocalStorage { - users?: LocalStorageUsers; session?: LocalStorageSession; } diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_state_session_SessionState.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_state_session_SessionState.ts.html index 09c8fa889..7240da692 100644 --- a/docs/static/jsdoc/hanko-frontend-sdk/lib_state_session_SessionState.ts.html +++ b/docs/static/jsdoc/hanko-frontend-sdk/lib_state_session_SessionState.ts.html @@ -68,7 +68,7 @@ @@ -106,7 +106,6 @@

    lib/state/session/SessionState.ts

    */ export interface LocalStorageSession { expiry: number; - userID: string; authFlowCompleted: boolean; } @@ -142,7 +141,7 @@

    lib/state/session/SessionState.ts

    * @return {LocalStorageSession} */ getState(): LocalStorageSession { - this.ls.session ||= { expiry: 0, userID: "", authFlowCompleted: false }; + this.ls.session ||= { expiry: 0, authFlowCompleted: false }; return this.ls.session; } @@ -166,24 +165,6 @@

    lib/state/session/SessionState.ts

    return this; } - /** - * Gets the user id. - */ - getUserID(): string { - return this.getState().userID; - } - - /** - * Sets the user id. - * - * @param {string} userID - The user id - * @return {SessionState} - */ - setUserID(userID: string): SessionState { - this.getState().userID = userID; - return this; - } - /** * Gets the authFlowCompleted indicator. */ @@ -211,7 +192,6 @@

    lib/state/session/SessionState.ts

    const session = this.getState(); delete session.expiry; - delete session.userID; return this; } diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_state_users_PasscodeState.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_state_users_PasscodeState.ts.html deleted file mode 100644 index 71fd0f617..000000000 --- a/docs/static/jsdoc/hanko-frontend-sdk/lib_state_users_PasscodeState.ts.html +++ /dev/null @@ -1,278 +0,0 @@ - - - - - - - - - - lib/state/users/PasscodeState.ts - - - - - - - - - - - - - - - - - - - - - - - -
    -
    - - - -
    -
    -
    - -
    -
    -
    -

    Source

    -

    lib/state/users/PasscodeState.ts

    -
    - - - - - -
    -
    -
    import { State } from "../State";
    -import { UserState } from "./UserState";
    -
    -/**
    - * @interface
    - * @category SDK
    - * @subcategory Internal
    - * @property {string=} id - The UUID of the active passcode.
    - * @property {number=} ttl - Timestamp until when the passcode is valid in seconds (since January 1, 1970 00:00:00 UTC).
    - * @property {number=} resendAfter - Seconds until a passcode can be resent.
    - * @property {emailID=} emailID - The email address ID.
    - */
    -export interface LocalStoragePasscode {
    -  id?: string;
    -  ttl?: number;
    -  resendAfter?: number;
    -  emailID?: string;
    -}
    -
    -/**
    - * A class that manages passcodes via local storage.
    - *
    - * @extends UserState
    - * @category SDK
    - * @subcategory Internal
    - */
    -class PasscodeState extends UserState {
    -  /**
    -   * Get the passcode state.
    -   *
    -   * @private
    -   * @param {string} userID - The UUID of the user.
    -   * @return {LocalStoragePasscode}
    -   */
    -  private getState(userID: string): LocalStoragePasscode {
    -    return (super.getUserState(userID).passcode ||= {});
    -  }
    -
    -  /**
    -   * Reads the current state.
    -   *
    -   * @public
    -   * @return {PasscodeState}
    -   */
    -  read(): PasscodeState {
    -    super.read();
    -
    -    return this;
    -  }
    -
    -  /**
    -   * Gets the UUID of the active passcode.
    -   *
    -   * @param {string} userID - The UUID of the user.
    -   * @return {string}
    -   */
    -  getActiveID(userID: string): string {
    -    return this.getState(userID).id;
    -  }
    -
    -  /**
    -   * Sets the UUID of the active passcode.
    -   *
    -   * @param {string} userID - The UUID of the user.
    -   * @param {string} passcodeID - The UUID of the passcode to be set as active.
    -   * @return {PasscodeState}
    -   */
    -  setActiveID(userID: string, passcodeID: string): PasscodeState {
    -    this.getState(userID).id = passcodeID;
    -
    -    return this;
    -  }
    -
    -  /**
    -   * Gets the UUID of the email address.
    -   *
    -   * @param {string} userID - The UUID of the user.
    -   * @return {string}
    -   */
    -  getEmailID(userID: string): string {
    -    return this.getState(userID).emailID;
    -  }
    -
    -  /**
    -   * Sets the UUID of the email address.
    -   *
    -   * @param {string} userID - The UUID of the user.
    -   * @param {string} emailID - The UUID of the email address.
    -   * @return {PasscodeState}
    -   */
    -  setEmailID(userID: string, emailID: string): PasscodeState {
    -    this.getState(userID).emailID = emailID;
    -
    -    return this;
    -  }
    -
    -  /**
    -   * Removes the active passcode.
    -   *
    -   * @param {string} userID - The UUID of the user.
    -   * @return {PasscodeState}
    -   */
    -  reset(userID: string): PasscodeState {
    -    const passcode = this.getState(userID);
    -
    -    delete passcode.id;
    -    delete passcode.ttl;
    -    delete passcode.resendAfter;
    -    delete passcode.emailID;
    -
    -    return this;
    -  }
    -
    -  /**
    -   * Gets the TTL in seconds. When the seconds expire, the code is invalid.
    -   *
    -   * @param {string} userID - The UUID of the user.
    -   * @return {number}
    -   */
    -  getTTL(userID: string): number {
    -    return State.timeToRemainingSeconds(this.getState(userID).ttl);
    -  }
    -
    -  /**
    -   * Sets the passcode's TTL and stores it to the local storage.
    -   *
    -   * @param {string} userID - The UUID of the user.
    -   * @param {string} seconds - Number of seconds the passcode is valid for.
    -   * @return {PasscodeState}
    -   */
    -  setTTL(userID: string, seconds: number): PasscodeState {
    -    this.getState(userID).ttl = State.remainingSecondsToTime(seconds);
    -
    -    return this;
    -  }
    -
    -  /**
    -   * Gets the number of seconds until when the next passcode can be sent.
    -   *
    -   * @param {string} userID - The UUID of the user.
    -   * @return {number}
    -   */
    -  getResendAfter(userID: string): number {
    -    return State.timeToRemainingSeconds(this.getState(userID).resendAfter);
    -  }
    -
    -  /**
    -   * Sets the number of seconds until a new passcode can be sent.
    -   *
    -   * @param {string} userID - The UUID of the user.
    -   * @param {number} seconds - Number of seconds the passcode is valid for.
    -   * @return {PasscodeState}
    -   */
    -  setResendAfter(userID: string, seconds: number): PasscodeState {
    -    this.getState(userID).resendAfter = State.remainingSecondsToTime(seconds);
    -
    -    return this;
    -  }
    -}
    -
    -export { PasscodeState };
    -
    -
    -
    - - - - -
    - - - -
    -
    -
    -
    - - - - - - - diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_state_users_PasswordState.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_state_users_PasswordState.ts.html deleted file mode 100644 index 1eec7b93f..000000000 --- a/docs/static/jsdoc/hanko-frontend-sdk/lib_state_users_PasswordState.ts.html +++ /dev/null @@ -1,186 +0,0 @@ - - - - - - - - - - lib/state/users/PasswordState.ts - - - - - - - - - - - - - - - - - - - - - - - -
    -
    - - - -
    -
    -
    - -
    -
    -
    -

    Source

    -

    lib/state/users/PasswordState.ts

    -
    - - - - - -
    -
    -
    import { State } from "../State";
    -import { UserState } from "./UserState";
    -
    -/**
    - * @interface
    - * @category SDK
    - * @subcategory Internal
    - * @property {number=} retryAfter - Timestamp (in seconds since January 1, 1970 00:00:00 UTC) indicating when the next password login can be attempted.
    - */
    -export interface LocalStoragePassword {
    -  retryAfter?: number;
    -}
    -
    -/**
    - * A class that manages the password login state.
    - *
    - * @extends UserState
    - * @category SDK
    - * @subcategory Internal
    - */
    -class PasswordState extends UserState {
    -  /**
    -   * Get the password state.
    -   *
    -   * @private
    -   * @param {string} userID - The UUID of the user.
    -   * @return {LocalStoragePassword}
    -   */
    -  private getState(userID: string): LocalStoragePassword {
    -    return (super.getUserState(userID).password ||= {});
    -  }
    -
    -  /**
    -   * Reads the current state.
    -   *
    -   * @public
    -   * @return {PasswordState}
    -   */
    -  read(): PasswordState {
    -    super.read();
    -
    -    return this;
    -  }
    -
    -  /**
    -   * Gets the number of seconds until when a new password login can be attempted.
    -   *
    -   * @param {string} userID - The UUID of the user.
    -   * @return {number}
    -   */
    -  getRetryAfter(userID: string): number {
    -    return State.timeToRemainingSeconds(this.getState(userID).retryAfter);
    -  }
    -
    -  /**
    -   * Sets the number of seconds until a new password login can be attempted.
    -   *
    -   * @param {string} userID - The UUID of the user.
    -   * @param {string} seconds - Number of seconds the passcode is valid for.
    -   * @return {PasswordState}
    -   */
    -  setRetryAfter(userID: string, seconds: number): PasswordState {
    -    this.getState(userID).retryAfter = State.remainingSecondsToTime(seconds);
    -
    -    return this;
    -  }
    -}
    -
    -export { PasswordState };
    -
    -
    -
    - - - - -
    - - - -
    -
    -
    -
    - - - - - - - diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_state_users_UserState.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_state_users_UserState.ts.html deleted file mode 100644 index 58989c440..000000000 --- a/docs/static/jsdoc/hanko-frontend-sdk/lib_state_users_UserState.ts.html +++ /dev/null @@ -1,181 +0,0 @@ - - - - - - - - - - lib/state/users/UserState.ts - - - - - - - - - - - - - - - - - - - - - - - -
    -
    - - - -
    -
    -
    - -
    -
    -
    -

    Source

    -

    lib/state/users/UserState.ts

    -
    - - - - - -
    -
    -
    /**
    - * @interface
    - * @category SDK
    - * @subcategory Internal
    - * @property {Object.<string, LocalStorageUser>} - A dictionary for mapping users to their states.
    - */
    -import { State } from "../State";
    -
    -import { LocalStorageWebauthn } from "./WebauthnState";
    -import { LocalStoragePasscode } from "./PasscodeState";
    -import { LocalStoragePassword } from "./PasswordState";
    -
    -/**
    - * @interface
    - * @category SDK
    - * @subcategory Internal
    - * @property {LocalStorageWebauthn=} webauthn - Information about WebAuthn credentials.
    - * @property {LocalStoragePasscode=} passcode - Information about the active passcode.
    - * @property {LocalStoragePassword=} password - Information about the password login attempts.
    - */
    -interface LocalStorageUser {
    -  webauthn?: LocalStorageWebauthn;
    -  passcode?: LocalStoragePasscode;
    -  password?: LocalStoragePassword;
    -}
    -
    -/**
    - * @interface
    - * @category SDK
    - * @subcategory Internal
    - * @property {Object.<string, LocalStorageUser>} - A dictionary for mapping users to their states.
    - */
    -export interface LocalStorageUsers {
    -  [userID: string]: LocalStorageUser;
    -}
    -
    -/**
    - * A class to read and write local storage contents.
    - *
    - * @abstract
    - * @extends State
    - * @param {string} key - The local storage key.
    - * @category SDK
    - * @subcategory Internal
    - */
    -abstract class UserState extends State {
    -  /**
    -   * Gets the state of the specified user.
    -   *
    -   * @param {string} userID - The UUID of the user.
    -   * @return {LocalStorageUser}
    -   */
    -  getUserState(userID: string): LocalStorageUser {
    -    this.ls.users ||= {};
    -
    -    if (!Object.prototype.hasOwnProperty.call(this.ls.users, userID)) {
    -      this.ls.users[userID] = {};
    -    }
    -
    -    return this.ls.users[userID];
    -  }
    -}
    -
    -export { UserState };
    -
    -
    -
    - - - - -
    - - - -
    -
    -
    -
    - - - - - - - diff --git a/docs/static/jsdoc/hanko-frontend-sdk/lib_state_users_WebauthnState.ts.html b/docs/static/jsdoc/hanko-frontend-sdk/lib_state_users_WebauthnState.ts.html deleted file mode 100644 index f6ec12d7d..000000000 --- a/docs/static/jsdoc/hanko-frontend-sdk/lib_state_users_WebauthnState.ts.html +++ /dev/null @@ -1,200 +0,0 @@ - - - - - - - - - - lib/state/users/WebauthnState.ts - - - - - - - - - - - - - - - - - - - - - - - -
    -
    - - - -
    -
    -
    - -
    -
    -
    -

    Source

    -

    lib/state/users/WebauthnState.ts

    -
    - - - - - -
    -
    -
    import { UserState } from "./UserState";
    -import { Credential } from "../../Dto";
    -
    -/**
    - * @interface
    - * @category SDK
    - * @subcategory Internal
    - * @property {string[]?} credentials - A list of known credential IDs on the current browser.
    - */
    -export interface LocalStorageWebauthn {
    -  credentials?: string[];
    -}
    -
    -/**
    - * A class that manages WebAuthn credentials via local storage.
    - *
    - * @extends UserState
    - * @category SDK
    - * @subcategory Internal
    - */
    -class WebauthnState extends UserState {
    -  /**
    -   * Gets the WebAuthn state.
    -   *
    -   * @private
    -   * @param {string} userID - The UUID of the user.
    -   * @return {LocalStorageWebauthn}
    -   */
    -  private getState(userID: string): LocalStorageWebauthn {
    -    return (super.getUserState(userID).webauthn ||= {});
    -  }
    -
    -  /**
    -   * Reads the current state.
    -   *
    -   * @public
    -   * @return {WebauthnState}
    -   */
    -  read(): WebauthnState {
    -    super.read();
    -
    -    return this;
    -  }
    -
    -  /**
    -   * Gets the list of known credentials on the current browser.
    -   *
    -   * @param {string} userID - The UUID of the user.
    -   * @return {string[]}
    -   */
    -  getCredentials(userID: string): string[] {
    -    return (this.getState(userID).credentials ||= []);
    -  }
    -
    -  /**
    -   * Adds the credential to the list of known credentials.
    -   *
    -   * @param {string} userID - The UUID of the user.
    -   * @param {string} credentialID - The WebAuthn credential ID.
    -   * @return {WebauthnState}
    -   */
    -  addCredential(userID: string, credentialID: string): WebauthnState {
    -    this.getCredentials(userID).push(credentialID);
    -
    -    return this;
    -  }
    -
    -  /**
    -   * Returns the intersection between the specified list of credentials and the known credentials stored in
    -   * the local storage.
    -   *
    -   * @param {string} userID - The UUID of the user.
    -   * @param {Credential[]} match - A list of credential IDs to be matched against the local storage.
    -   * @return {Credential[]}
    -   */
    -  matchCredentials(userID: string, match: Credential[]): Credential[] {
    -    return this.getCredentials(userID)
    -      .filter((id) => match.find((c) => c.id === id))
    -      .map((id: string) => ({ id } as Credential));
    -  }
    -}
    -
    -export { WebauthnState };
    -
    -
    -
    - - - - -
    - - - -
    -
    -
    -
    - - - - - - - diff --git a/docs/static/spec/flow.yaml b/docs/static/spec/flow.yaml new file mode 100644 index 000000000..5462294e9 --- /dev/null +++ b/docs/static/spec/flow.yaml @@ -0,0 +1,145 @@ +openapi: 3.0.3 +info: + title: Flow API + description: Flow API + version: 1.0.0 +servers: + - url: 'http://localhost:8080' +paths: + /registration: + post: + description: Registration + parameters: + - in: query + name: flowpilot_action + schema: + type: string + example: register_client_capabilities@1c456375-4dde-48ba-bb03-5845aec350ce + responses: + 200: + $ref: "#/components/responses/FlowResponse" + /login: + post: + description: Start Login Flow + parameters: + - in: query + name: flowpilot_action + schema: + type: string + example: register_client_capabilities@1c456375-4dde-48ba-bb03-5845aec350ce + responses: + 200: + $ref: "#/components/responses/FlowResponse" + /profile: + post: + description: Profile + parameters: + - in: query + name: flowpilot_action + schema: + type: string + example: register_client_capabilities@1c456375-4dde-48ba-bb03-5845aec350ce + responses: + 200: + $ref: "#/components/responses/FlowResponse" + +components: + schemas: + Action: + description: Action + type: object + properties: + action: + type: string + href: + type: string + description: + type: string + inputs: + $ref: "#/components/schemas/Inputs" + Actions: + description: Actions + type: object + additionalProperties: + $ref: "#/components/schemas/Action" + Input: + description: Input + type: object + properties: + hidden: + type: boolean + name: + type: string + type: + type: string + required: + type: boolean + Inputs: + description: Inputs + type: object + additionalProperties: + $ref: "#/components/schemas/Input" + Payload: + description: Payload + type: object + additionalProperties: true + responses: + FlowResponse: + description: Flow Response + content: + application/json: + examples: + gut: + $ref: "#/components/examples/gute_flow_response" + schlecht: + $ref: "#/components/examples/schlechte_flow_response" + schema: + type: object + properties: + actions: + $ref: "#/components/schemas/Actions" + flow_path: + type: string + name: + type: string + payload: + $ref: "#/components/schemas/Payload" + status: + type: integer + examples: + schlechte_flow_response: + value: + actions: + gute_action: + action: schlechte_action + href: /schlechte_action + description: Eine schlechte Action + inputs: + guter_input: + hidden: true + name: schlechte_input + type: string + required: true + flow_path: /schlechter_flow + name: "Schlechter Flow" + payload: + pay: load + status: 200 + gute_flow_response: + value: + actions: + gute_action: + action: gute_action + href: /gute_action + description: Eine gute Action + inputs: + guter_input: + hidden: true + name: guter_input + type: string + required: true + flow_path: /guter_flow + name: "Guter Flow" + payload: + pay: load + status: 200 diff --git a/frontend/elements/.nvmrc b/frontend/elements/.nvmrc index 07c7cf304..80a9956e1 100644 --- a/frontend/elements/.nvmrc +++ b/frontend/elements/.nvmrc @@ -1 +1 @@ -v18.14.2 +v20.16.0 diff --git a/frontend/elements/README.md b/frontend/elements/README.md index 30bf74e94..526b35348 100644 --- a/frontend/elements/README.md +++ b/frontend/elements/README.md @@ -74,7 +74,7 @@ pnpm install @teamhanko/hanko-elements To integrate Hanko, you need to import and call the `register()` function from the `hanko-elements` module. Once this is done, you can use the web components in your HTML code. For a functioning page, at least the `` element -should be placed, so the users can sign in, and also, a handler for the "onAuthFlowCompleted" event should be added, to +should be placed, so the users can sign in, and also, a handler for the "onSessionCreated" event should be added, to customize the behaviour after the authentication flow has been completed (e.g. redirect to another page). These steps will be described in the following sections. @@ -149,7 +149,7 @@ of your HTML. A minimal example would look like this: await register("https://hanko.yourdomain.com"); const authComponent = document.getElementById("authComponent"); - authComponent.addEventListener("onAuthFlowCompleted", () => { + authComponent.addEventListener("onSessionCreated", () => { // redirect to a different page }); @@ -157,19 +157,37 @@ of your HTML. A minimal example would look like this: The individual web component are described in the following sections. -#### <hanko-auth> +#### <hanko-auth>, <hanko-login> and <hanko-registration> -A web component that handles user login and user registration. +These three web components offer a user-friendly interface for user login or registration. The difference between +the components is, that `` has the ability to switch between the login and +registration UI, whereas `` is dedicated to the login and `` +to the registration only. ##### Markup +Combined UI for login and registration: + ```html ``` +Dedicated UI for the login: + +```html + +``` + +Dedicated UI for the registration: + +```html + +``` + ##### Attributes - `prefilled-email` Used to prefill the email input field. +- `prefilled-username` Used to prefill the username input field. - `lang` Used to specify the language of the content within the element. See [Translations](#translations). - `experimental` A space-separated list of experimental features to be enabled. See [experimental features](#experimental-features). @@ -201,7 +219,7 @@ handler via the `frontend-sdk` (see next section). ``` @@ -225,25 +243,11 @@ const hanko = new Hanko("https://hanko.yourdomain.com"); It is possible to bind callbacks to different custom events in use of the SDKs event listener functions. The callback function will be called when the event happens and an object will be passed in, containing event details. -##### Auth Flow Completed - -Will be triggered after a session has been created and the user has completed possible -additional steps (e.g. passkey registration or password recovery) via the `` element. - -```js -hanko.onAuthFlowCompleted((authFlowCompletedDetail) => { - // Login, registration or recovery has been completed successfully. You can now take control and redirect the - // user to protected pages. - console.info( - `User successfully completed the registration or authorization process (user-id: "${authFlowCompletedDetail.userID}")` - ); -}); -``` - ##### Session Created -Will be triggered before the "hanko-auth-flow-completed" happens, as soon as the user is technically logged in. It will -also be triggered when the user logs in via another browser window. The event can be used to obtain the JWT. +Will be triggered after a session has been created and the user has completed possible additional steps (e.g. passkey +registration or password recovery). It will also be triggered when the user logs in via another browser window. The +event can be used to obtain the JWT. Please note, that the JWT is only available, when the Hanko-API configuration allows to obtain the JWT. When using Hanko-Cloud the JWT is always present, for self-hosted Hanko-APIs you can restrict the cookie to be readable by the @@ -258,7 +262,7 @@ frontend. hanko.onSessionCreated((sessionDetail) => { // A new JWT has been issued. console.info( - `Session created or updated (user-id: "${sessionDetail.userID}", jwt: ${sessionDetail.jwt})` + `Session created or updated (jwt: ${sessionDetail.jwt})` ); }); ``` @@ -444,7 +448,7 @@ The following parts are available: - `divider-line` - the line before and after the `divider-text` - `form-item` - the container of a form item, e.g. an input field or a button -#### Examples +#### Using shadow parts The following examples demonstrate how to apply styles to specific shadow parts: diff --git a/frontend/elements/src/Elements.tsx b/frontend/elements/src/Elements.tsx index af9cbb134..04ce8dafe 100644 --- a/frontend/elements/src/Elements.tsx +++ b/frontend/elements/src/Elements.tsx @@ -10,6 +10,7 @@ import { defaultTranslations, Translations } from "./i18n/translations"; export interface HankoAuthAdditionalProps { experimental?: string; prefilledEmail?: string; + prefilledUsername?: string; } export declare interface HankoAuthElementProps @@ -28,6 +29,8 @@ declare global { // eslint-disable-next-line no-unused-vars interface IntrinsicElements { "hanko-auth": HankoAuthElementProps; + "hanko-login": HankoAuthElementProps; + "hanko-registration": HankoAuthElementProps; "hanko-profile": HankoProfileElementProps; "hanko-events": HankoEventsElementProps; } @@ -66,6 +69,7 @@ const createHankoComponent = ( ); @@ -73,12 +77,29 @@ const createHankoComponent = ( const HankoAuth = (props: HankoAuthElementProps) => createHankoComponent("auth", props); +const HankoLogin = (props: HankoAuthElementProps) => + createHankoComponent("login", props); + +const HankoRegistration = (props: HankoProfileElementProps) => + createHankoComponent("registration", props); + const HankoProfile = (props: HankoProfileElementProps) => createHankoComponent("profile", props); const HankoEvents = (props: HankoEventsElementProps) => createHankoComponent("events", props); +let webauthnAbortController = new AbortController(); + +const createWebauthnAbortSignal = () => { + if (webauthnAbortController) { + webauthnAbortController.abort(); + } + + webauthnAbortController = new AbortController(); + return webauthnAbortController.signal; +}; + const _register = async ({ tagName, entryComponent, @@ -96,6 +117,14 @@ export const register = async ( api: string, options: RegisterOptions = {}, ): Promise => { + const observedAttributes = [ + "api", + "lang", + "experimental", + "prefilled-email", + "entry", + ]; + options = { shadow: true, injectStyles: true, @@ -119,19 +148,32 @@ export const register = async ( globalOptions.translations = options.translations || defaultTranslations; globalOptions.translationsLocation = options.translationsLocation; globalOptions.fallbackLanguage = options.fallbackLanguage; - await Promise.all([ _register({ ...options, tagName: "hanko-auth", entryComponent: HankoAuth, - observedAttributes: ["api", "lang", "experimental", "prefilled-email"], + observedAttributes, + }), + _register({ + ...options, + tagName: "hanko-login", + entryComponent: HankoLogin, + observedAttributes, + }), + _register({ + ...options, + tagName: "hanko-registration", + entryComponent: HankoRegistration, + observedAttributes, }), _register({ ...options, tagName: "hanko-profile", entryComponent: HankoProfile, - observedAttributes: ["api", "lang"], + observedAttributes: observedAttributes.filter((attribute) => + ["api", "lang"].includes(attribute), + ), }), _register({ ...options, diff --git a/frontend/elements/src/_preset.sass b/frontend/elements/src/_preset.sass index e0d7c89f4..b79bdbce2 100644 --- a/frontend/elements/src/_preset.sass +++ b/frontend/elements/src/_preset.sass @@ -32,7 +32,7 @@ $container-max-width: 410px // Headline Styles $headline1-font-size: 24px $headline1-font-weight: 600 -$headline1-margin: 0 0 1rem +$headline1-margin: 0 0 .5rem $headline2-font-size: 16px $headline2-font-weight: 600 diff --git a/frontend/elements/src/components/accordion/Accordion.tsx b/frontend/elements/src/components/accordion/Accordion.tsx index da47900f9..a1d4d2325 100644 --- a/frontend/elements/src/components/accordion/Accordion.tsx +++ b/frontend/elements/src/components/accordion/Accordion.tsx @@ -1,18 +1,16 @@ import { h } from "preact"; -import { StateUpdater } from "preact/compat"; +import { StateUpdater, useCallback } from "preact/compat"; +type Selector = (item: T, itemIndex?: number) => string | h.JSX.Element; import cx from "classnames"; - import styles from "./styles.sass"; -type Selector = (item: T, itemIndex?: number) => string | h.JSX.Element; - interface Props { name: string; columnSelector: Selector; contentSelector: Selector; - checkedItemIndex?: number; - setCheckedItemIndex: StateUpdater; + checkedItemID?: string; + setCheckedItemID: StateUpdater; data: Array; dropdown?: boolean; } @@ -22,14 +20,25 @@ const Accordion = function ({ columnSelector, contentSelector, data, - checkedItemIndex, - setCheckedItemIndex, + checkedItemID, + setCheckedItemID, dropdown = false, }: Props) { + const toID = useCallback( + (itemIndex: number) => `${name}-${itemIndex}`, + [name], + ); + + const checked = useCallback( + (itemIndex: number) => toID(itemIndex) === checkedItemID, + [checkedItemID, toID], + ); + const clickHandler = (event: Event) => { if (!(event.target instanceof HTMLInputElement)) return; const itemIndex = parseInt(event.target.value, 10); - setCheckedItemIndex(itemIndex === checkedItemIndex ? null : itemIndex); + const id = toID(itemIndex); + setCheckedItemID(id === checkedItemID ? null : id); }; return ( @@ -43,7 +52,7 @@ const Accordion = function ({ name={name} onClick={clickHandler} value={itemIndex} - checked={checkedItemIndex === itemIndex} + checked={checked(itemIndex)} />