Skip to content

Commit 829a284

Browse files
feat: add passkey option to login method chooser (#2100)
1 parent 324cf14 commit 829a284

File tree

6 files changed

+87
-9
lines changed

6 files changed

+87
-9
lines changed

backend/flow_api/flow/credential_usage/action_continue_with_login_identifier.go

+39-3
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
auditlog "github.com/teamhanko/hanko/backend/audit_log"
77
"github.com/teamhanko/hanko/backend/flow_api/flow/shared"
8+
"github.com/teamhanko/hanko/backend/flow_api/services"
89
"github.com/teamhanko/hanko/backend/flowpilot"
910
"github.com/teamhanko/hanko/backend/persistence/models"
1011
"regexp"
@@ -197,13 +198,48 @@ func (a ContinueWithLoginIdentifier) Execute(c flowpilot.ExecutionContext) error
197198
}
198199

199200
if deps.Cfg.Privacy.OnlyShowActualLoginMethods {
201+
emailAvailable := deps.Cfg.Email.UseForAuthentication && userModel != nil && userModel.Emails.GetPrimary() != nil
202+
passwordAvailable := deps.Cfg.Password.Enabled && userModel != nil && userModel.PasswordCredential != nil
203+
passkeysAvailable := deps.Cfg.Passkey.Enabled && userModel != nil && len(userModel.GetPasskeys()) > 0
204+
availableMethods := 0
205+
if emailAvailable {
206+
availableMethods += 1
207+
}
208+
if passwordAvailable {
209+
availableMethods += 1
210+
}
211+
if passkeysAvailable {
212+
availableMethods += 1
213+
}
214+
200215
switch {
201-
case deps.Cfg.Email.UseForAuthentication && userModel != nil && userModel.Emails.GetPrimary() != nil && deps.Cfg.Password.Enabled && userModel.PasswordCredential != nil:
216+
case availableMethods > 1:
202217
return c.Continue(shared.StateLoginMethodChooser)
203-
case deps.Cfg.Email.UseForAuthentication && userModel != nil && userModel.Emails.GetPrimary() != nil:
218+
case emailAvailable:
204219
return a.continueToPasscodeConfirmation(c)
205-
case deps.Cfg.Password.Enabled && userModel != nil && userModel.PasswordCredential != nil:
220+
case passwordAvailable:
206221
return c.Continue(shared.StateLoginPassword)
222+
case passkeysAvailable:
223+
//goland:noinspection GoDfaNilDereference
224+
userModel.WebauthnCredentials = userModel.GetPasskeys()
225+
params := services.GenerateRequestOptionsPasskeyParams{Tx: deps.Tx, User: userModel}
226+
227+
sessionDataModel, requestOptions, err := deps.WebauthnService.GenerateRequestOptionsPasskey(params)
228+
if err != nil {
229+
return fmt.Errorf("failed to generate webauthn request options: %w", err)
230+
}
231+
232+
err = c.Stash().Set(shared.StashPathWebauthnSessionDataID, sessionDataModel.ID.String())
233+
if err != nil {
234+
return fmt.Errorf("failed to stash webauthn_session_data_id: %w", err)
235+
}
236+
237+
err = c.Payload().Set("request_options", requestOptions)
238+
if err != nil {
239+
return fmt.Errorf("failed to set request_options payload: %w", err)
240+
}
241+
242+
return c.Continue(shared.StateLoginPasskey)
207243
}
208244
} else {
209245
if deps.Cfg.Email.UseForAuthentication && deps.Cfg.Password.Enabled {

backend/flow_api/flow/credential_usage/action_webauthn_generate_request_options.go

+18-2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package credential_usage
22

33
import (
44
"fmt"
5+
"github.com/gofrs/uuid"
56
"github.com/teamhanko/hanko/backend/flow_api/flow/shared"
67
"github.com/teamhanko/hanko/backend/flow_api/services"
78
"github.com/teamhanko/hanko/backend/flowpilot"
@@ -22,15 +23,30 @@ func (a WebauthnGenerateRequestOptions) GetDescription() string {
2223
func (a WebauthnGenerateRequestOptions) Initialize(c flowpilot.InitializationContext) {
2324
deps := a.GetDeps(c)
2425

25-
if !c.Stash().Get(shared.StashPathWebauthnAvailable).Bool() || !deps.Cfg.Passkey.Enabled {
26+
if !c.Stash().Get(shared.StashPathWebauthnAvailable).Bool() || !deps.Cfg.Passkey.Enabled ||
27+
(c.Stash().Get(shared.StashPathUserID).Exists() && !c.Stash().Get(shared.StashPathUserHasPasskey).Bool() && c.GetCurrentState() == shared.StateLoginMethodChooser && deps.Cfg.Privacy.OnlyShowActualLoginMethods) {
2628
c.SuspendAction()
2729
}
2830
}
2931

3032
func (a WebauthnGenerateRequestOptions) Execute(c flowpilot.ExecutionContext) error {
3133
deps := a.GetDeps(c)
3234

33-
params := services.GenerateRequestOptionsPasskeyParams{Tx: deps.Tx}
35+
params := services.GenerateRequestOptionsPasskeyParams{Tx: deps.Tx, User: nil}
36+
37+
userIdStash := c.Stash().Get(shared.StashPathUserID)
38+
if userIdStash.Exists() && deps.Cfg.Privacy.OnlyShowActualLoginMethods {
39+
userId := uuid.FromStringOrNil(userIdStash.String())
40+
userModel, err := deps.Persister.GetUserPersisterWithConnection(deps.Tx).Get(userId)
41+
if err != nil {
42+
return err
43+
}
44+
if userModel != nil {
45+
// Filter webauthn credentials and only use passkeys and not security keys, as only passkeys are allowed
46+
userModel.WebauthnCredentials = userModel.GetPasskeys()
47+
params.User = userModel
48+
}
49+
}
3450

3551
sessionDataModel, requestOptions, err := deps.WebauthnService.GenerateRequestOptionsPasskey(params)
3652
if err != nil {

backend/flow_api/flow/flows.go

+1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ var CredentialUsageSubFlow = flowpilot.NewSubFlow(shared.FlowCredentialUsage).
3535
State(shared.StateLoginMethodChooser,
3636
credential_usage.ContinueToPasswordLogin{},
3737
credential_usage.ContinueToPasscodeConfirmation{},
38+
credential_usage.WebauthnGenerateRequestOptions{},
3839
shared.Back{},
3940
).
4041
State(shared.StateLoginPassword,

backend/flow_api/services/webauthn.go

+6-4
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@ import (
1010
"github.com/teamhanko/hanko/backend/config"
1111
"github.com/teamhanko/hanko/backend/persistence"
1212
"github.com/teamhanko/hanko/backend/persistence/models"
13+
"reflect"
1314
"strings"
1415
"time"
1516
)
1617

1718
type GenerateRequestOptionsPasskeyParams struct {
18-
Tx *pop.Connection
19+
Tx *pop.Connection
20+
User *models.User
1921
}
2022

2123
type GenerateRequestOptionsSecurityKeyParams struct {
@@ -115,7 +117,7 @@ func (s *webauthnService) generateRequestOptions(tx *pop.Connection, user webaut
115117
var options *protocol.CredentialAssertion
116118
var sessionData *webauthn.SessionData
117119
var err error
118-
if user != nil {
120+
if !reflect.ValueOf(user).IsNil() {
119121
options, sessionData, err = s.cfg.Webauthn.Handler.BeginLogin(user, opts...)
120122
} else {
121123
options, sessionData, err = s.cfg.Webauthn.Handler.BeginDiscoverableLogin(opts...)
@@ -141,7 +143,7 @@ func (s *webauthnService) GenerateRequestOptionsPasskey(p GenerateRequestOptions
141143
userVerificationRequirement := protocol.UserVerificationRequirement(s.cfg.Passkey.UserVerification)
142144

143145
return s.generateRequestOptions(p.Tx,
144-
nil,
146+
p.User,
145147
webauthn.WithUserVerification(userVerificationRequirement),
146148
)
147149
}
@@ -205,7 +207,7 @@ func (s *webauthnService) VerifyAssertionResponse(p VerifyAssertionResponseParam
205207
}
206208

207209
sessionData := sessionDataModel.ToSessionData()
208-
if p.IsMFA {
210+
if p.IsMFA || len(sessionData.AllowedCredentialIDs) > 0 {
209211
_, err = s.cfg.Webauthn.Handler.ValidateLogin(webAuthnUser, *sessionData, credentialAssertionData)
210212
} else {
211213
_, err = s.cfg.Webauthn.Handler.ValidateDiscoverableLogin(

frontend/elements/src/pages/LoginMethodChooser.tsx

+22
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,16 @@ const LoginMethodChooserPage = (props: Props) => {
4646
await hanko.flow.run(nextState, stateHandler);
4747
};
4848

49+
const onPasskeySelectSubmit = async (event: Event) => {
50+
event.preventDefault();
51+
setLoadingAction("passkey-submit");
52+
const nextState = await flowState.actions
53+
.webauthn_generate_request_options(null)
54+
.run();
55+
setLoadingAction(null);
56+
await hanko.flow.run(nextState, stateHandler);
57+
};
58+
4959
const onBackClick = async (event: Event) => {
5060
event.preventDefault();
5161
setLoadingAction("back");
@@ -84,6 +94,18 @@ const LoginMethodChooserPage = (props: Props) => {
8494
{t("labels.password")}
8595
</Button>
8696
</Form>
97+
<Form
98+
hidden={!flowState.actions.webauthn_generate_request_options?.(null)}
99+
onSubmit={onPasskeySelectSubmit}
100+
>
101+
<Button
102+
secondary={true}
103+
uiAction={"passkey-submit"}
104+
icon={"passkey"}
105+
>
106+
{t("labels.passkey")}
107+
</Button>
108+
</Form>
87109
</Content>
88110
<Footer>
89111
<Link

frontend/frontend-sdk/src/lib/flow-api/types/action.ts

+1
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export interface ProfileInitActions {
6868
export interface LoginMethodChooserActions {
6969
readonly continue_to_password_login?: Action<null>;
7070
readonly continue_to_passcode_confirmation?: Action<null>;
71+
readonly webauthn_generate_request_options?: Action<null>;
7172
readonly back: Action<null>;
7273
}
7374

0 commit comments

Comments
 (0)