Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions core/authenticate/authenticate.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,40 @@ var APIAssertions = []ClientAssertion{
PassthroughHeaderClientAssertion,
}

// FlowIntent says whether the caller wants to log an existing user in or create
// a new one. It rides on the flow, the only thing that survives an OIDC redirect.
type FlowIntent string

const (
// FlowIntentUnspecified keeps the old create-or-get behaviour, so clients
// that send no intent are unaffected.
FlowIntentUnspecified FlowIntent = ""
FlowIntentLogin FlowIntent = "login"
FlowIntentSignup FlowIntent = "signup"
)

func (i FlowIntent) String() string {
return string(i)
}

// keys under Flow.Metadata.
const (
flowIntentKey = "intent"
flowConsentKey = "consent"

consentDocumentIDsKey = "accepted_document_ids"
consentIPAddressKey = "ip_address"
consentAtKey = "at"
)

// FlowConsent is what the user accepted at flow start, read back after the
// redirect. The IP and timestamp are from the acceptance, not the callback.
type FlowConsent struct {
AcceptedDocumentIDs []string
IPAddress string
At time.Time
}

// Flow is a temporary state used to finish login/registration flows
type Flow struct {
ID uuid.UUID
Expand Down Expand Up @@ -94,6 +128,74 @@ func (f Flow) IsValid(currentTime time.Time) bool {
return f.ExpiresAt.After(currentTime)
}

// Intent reads the flow intent from metadata. A nil flow is allowed, so callers
// with no flow need no branch, and reads as unspecified.
func (f *Flow) Intent() FlowIntent {
if f == nil {
return FlowIntentUnspecified
}
intent, ok := f.Metadata[flowIntentKey].(string)
if !ok {
return FlowIntentUnspecified
}
return FlowIntent(intent)
}

// Consent reads what the user accepted from metadata, reporting whether the flow
// carries one at all. A nil flow is allowed.
//
// Metadata is JSONB and does not return the types it was given — ids come back
// as []any and the timestamp as a string — so this parses rather than asserts,
// and treats an unparseable key as no consent.
func (f *Flow) Consent() (FlowConsent, bool) {
if f == nil {
return FlowConsent{}, false
}
raw, ok := f.Metadata[flowConsentKey].(map[string]any)
if !ok {
return FlowConsent{}, false
}

consent := FlowConsent{
AcceptedDocumentIDs: parseStringSlice(raw[consentDocumentIDsKey]),
}
if ip, ok := raw[consentIPAddressKey].(string); ok {
consent.IPAddress = ip
}
switch at := raw[consentAtKey].(type) {
case time.Time:
consent.At = at
case string:
if parsed, err := time.Parse(time.RFC3339Nano, at); err == nil {
consent.At = parsed
}
}
if len(consent.AcceptedDocumentIDs) == 0 {
// a consent that names no document is not a consent
return FlowConsent{}, false
}
return consent, true
}

// parseStringSlice reads a string list that may have been through a JSON round
// trip, where it comes back as []any. Non-string entries are dropped.
func parseStringSlice(value any) []string {
switch list := value.(type) {
case []string:
return list
case []any:
parsed := make([]string, 0, len(list))
for _, item := range list {
if str, ok := item.(string); ok {
parsed = append(parsed, str)
}
}
return parsed
default:
return nil
}
}

type RegistrationStartRequest struct {
Method string
// ReturnToURL is where flow should end to after successful verification
Expand All @@ -106,6 +208,15 @@ type RegistrationStartRequest struct {
// For most cases it could be host of frontier but in case of proxies, this will be proxy public endpoint.
// callback_url should be one of the allowed urls configured at instance level
CallbackUrl string

// Intent says whether this is a login or a signup. Unset keeps create-or-get.
Intent FlowIntent
// AcceptedDocumentIDs are stored on the flow so they survive the redirect to
// an identity provider, and are checked when the user is created.
AcceptedDocumentIDs []string
// IPAddress is where the acceptance came from. Authenticate is skip-listed,
// so the handler extracts it rather than reading it off the context.
IPAddress string
}

type RegistrationFinishRequest struct {
Expand Down
3 changes: 2 additions & 1 deletion core/authenticate/authenticators.go
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,8 @@ func authenticateWithPassthroughHeader(ctx context.Context, s *Service) (Princip
return Principal{}, errSkip
}

currentUser, err := s.getOrCreateUser(ctx, strings.TrimSpace(val), strings.Split(val, "@")[0])
// no flow on this path; the nil-safe accessors make that a nil argument
currentUser, err := s.getOrCreateUser(ctx, nil, strings.TrimSpace(val), strings.Split(val, "@")[0])
if err != nil {
s.log.DebugContext(ctx, "failed to get user", "err", err)
return Principal{}, err
Expand Down
106 changes: 90 additions & 16 deletions core/authenticate/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ var (
ErrInvalidOIDCState = errors.New("invalid auth state")
ErrFlowInvalid = errors.New("invalid flow or expired")
ErrOIDCTokenExchange = errors.New("failed to exchange oidc authorization code")
ErrLoginUserNotFound = errors.New("no account for this email")
ErrSignupUserExists = errors.New("an account already exists for this email")
)

type UserService interface {
Expand Down Expand Up @@ -203,6 +205,14 @@ func (s Service) StartFlow(ctx context.Context, request RegistrationStartRequest
if !utils.Contains(s.SupportedStrategies(), request.Method) {
return nil, ErrUnsupportedMethod
}
// both mail strategies know the address before anything is sent, and share
// applyMailOTP at the other end, so they share the gate here. Passkey gates
// in its own branch, where it already looks the user up to pick a ceremony.
if request.Method == MailOTPAuthMethod.String() || request.Method == MailLinkAuthMethod.String() {
if err := s.gateFlowStart(ctx, request.Intent, request.Email); err != nil {
return nil, err
}
}
flow := &Flow{
ID: uuid.New(),
Method: request.Method,
Expand All @@ -214,19 +224,37 @@ func (s Service) StartFlow(ctx context.Context, request RegistrationStartRequest
"callback_url": request.CallbackUrl,
},
}
// only write what the caller sent, so today's clients produce today's flow row
if request.Intent != FlowIntentUnspecified {
flow.Metadata[flowIntentKey] = request.Intent.String()
}
if len(request.AcceptedDocumentIDs) > 0 {
flow.Metadata[flowConsentKey] = map[string]any{
consentDocumentIDsKey: request.AcceptedDocumentIDs,
consentIPAddressKey: request.IPAddress,
consentAtKey: s.Now(),
}
}

if request.Method == PassKeyAuthMethod.String() {
needRegistration := false
loggedInUser, err := s.userService.GetByID(ctx, request.Email)
if err != nil {
needRegistration = true
} else {
storedPasskey, passKeyExists := loggedInUser.Metadata["passkey_credentials"]
if !passKeyExists {
needRegistration = true
}
if _, ok := storedPasskey.(string); !ok {
loggedInUser, userErr := s.userService.GetByID(ctx, request.Email)
if err := checkIntent(request.Intent, userErr == nil); err != nil {
return nil, err
}

// the intent picks the ceremony; without one, fall back to the old guess
needRegistration := request.Intent == FlowIntentSignup
if request.Intent == FlowIntentUnspecified {
if userErr != nil {
needRegistration = true
} else {
storedPasskey, passKeyExists := loggedInUser.Metadata["passkey_credentials"]
if !passKeyExists {
needRegistration = true
}
if _, ok := storedPasskey.(string); !ok {
needRegistration = true
}
}
}

Expand Down Expand Up @@ -377,6 +405,34 @@ func otpAttempts(md metadata.Metadata) int {
}
}

// checkIntent applies the login and signup gates. An unspecified intent checks
// nothing, which is what keeps existing clients working.
func checkIntent(intent FlowIntent, userExists bool) error {
switch intent {
case FlowIntentLogin:
if !userExists {
return ErrLoginUserNotFound
}
case FlowIntentSignup:
if userExists {
return ErrSignupUserExists
}
}
return nil
}

// gateFlowStart is the fast path of the login gate, for the strategies that know
// the email up front: it fails before anything is mailed. The gate that matters
// is at user creation, which every strategy reaches including OIDC.
func (s Service) gateFlowStart(ctx context.Context, intent FlowIntent, email string) error {
if intent == FlowIntentUnspecified {
// nothing to check, and no reason to spend a lookup
return nil
}
_, err := s.userService.GetByID(ctx, email)
return checkIntent(intent, err == nil)
}

// applyMailOTP actions when user submitted otp from the email
// user can be considered as verified if code is valid
// create a new user if required
Expand Down Expand Up @@ -417,7 +473,7 @@ func (s Service) applyMailOTP(ctx context.Context, request RegistrationFinishReq
return nil, fmt.Errorf("failed to successfully register via otp: %w", err)
}

newUser, err := s.getOrCreateUser(ctx, flow.Email, "")
newUser, err := s.getOrCreateUser(ctx, flow, flow.Email, "")
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -460,7 +516,12 @@ func (s Service) startPassKeyRegisterMethod(ctx context.Context, flow *Flow) (*R
}

func (s Service) startPassKeyLoginMethod(ctx context.Context, loggedInUser user.User, flow *Flow) (*RegistrationStartResponse, error) {
decodedCredBytes, err := base64.StdEncoding.DecodeString(loggedInUser.Metadata["passkey_credentials"].(string))
// a login intent reaches this unchecked, so read rather than assert
storedPasskey, ok := loggedInUser.Metadata["passkey_credentials"].(string)
if !ok {
return nil, errors.New("no passkey registered for this account")
}
decodedCredBytes, err := base64.StdEncoding.DecodeString(storedPasskey)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -537,7 +598,7 @@ func (s Service) finishPassKeyRegisterMethod(ctx context.Context, request Regist
if err != nil {
return nil, err
}
newUser, err := s.getOrCreateUser(ctx, flow.Email, "")
newUser, err := s.getOrCreateUser(ctx, flow, flow.Email, "")
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -598,7 +659,7 @@ func (s Service) finishPassKeyLoginMethod(ctx context.Context, request Registrat
return nil, err
}

existingUser, err := s.getOrCreateUser(ctx, flow.Email, "")
existingUser, err := s.getOrCreateUser(ctx, flow, flow.Email, "")
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -723,7 +784,7 @@ func (s Service) applyOIDC(ctx context.Context, request RegistrationFinishReques
}

// register a new user
newUser, err := s.getOrCreateUser(ctx, oauthProfile.Email, oauthProfile.Name)
newUser, err := s.getOrCreateUser(ctx, flow, oauthProfile.Email, oauthProfile.Name)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -781,17 +842,30 @@ func (s Service) consumeFlow(ctx context.Context, id uuid.UUID) error {
return s.flowRepo.Delete(ctx, id)
}

func (s Service) getOrCreateUser(ctx context.Context, email, title string) (user.User, error) {
// getOrCreateUser returns the user for the email, creating one if there is none.
// A nil flow means a caller that has none, and gets the old create-or-get.
func (s Service) getOrCreateUser(ctx context.Context, flow *Flow, email, title string) (user.User, error) {
intent := flow.Intent()

// create a new user based on email if it doesn't exist
existingUser, err := s.userService.GetByID(ctx, email)
if err == nil {
if intent == FlowIntentSignup {
return user.User{}, ErrSignupUserExists
}
// user is already registered

// TODO(kushsharma): should we update metadata like profile picture from social logins
// for registered users every time the login?
return existingUser, nil
}

// the gate that matters: every strategy ends here, the last point before an
// account would be created for someone trying to log in
if intent == FlowIntentLogin {
return user.User{}, ErrLoginUserNotFound
}

// register a new user
newUser, err := s.userService.Create(ctx, user.User{
Title: title,
Expand Down
Loading
Loading