Skip to content

Commit

Permalink
add tests for scopes
Browse files Browse the repository at this point in the history
  • Loading branch information
erudenko committed Jul 11, 2023
1 parent 8ae908c commit 074186d
Show file tree
Hide file tree
Showing 14 changed files with 315 additions and 270 deletions.
15 changes: 10 additions & 5 deletions model/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,18 @@ import (
type TokenType string

const (
TokenTypeInvite TokenType = "invite" // TokenTypeInvite is an invite token type value.
TokenTypeReset TokenType = "reset" // TokenTypeReset is an reset token type value.
TokenTypeWebCookie TokenType = "web-cookie" // TokenTypeWebCookie is a web-cookie token type value.
TokenTypeAccess TokenType = "access" // TokenTypeAccess is an access token type.
TokenTypeRefresh TokenType = "refresh" // TokenTypeRefresh is a refresh token type.
TokenTypeInvite TokenType = "invite" // TokenTypeInvite is an invite token type value.
TokenTypeReset TokenType = "reset" // TokenTypeReset is an reset token type value.
TokenTypeWebCookie TokenType = "web-cookie" // TokenTypeWebCookie is a web-cookie token type value.
TokenTypeAccess TokenType = "access" // TokenTypeAccess is an access token type.
TokenTypeRefresh TokenType = "refresh" // TokenTypeRefresh is a refresh token type.
TokenTypeManagement TokenType = "management" // TokenTypeManagement is a management token type for admin panel."
TokenTypeID TokenType = "id_token" // id token type regarding oidc specification
TokenTypeSignin TokenType = "signin" // signin token issues for user to sign in, etc to exchange for auth tokens. For example from admin panel I can send a link to user to siging to email with magic link.
TokenTypeActor TokenType = "actor" // actor token is token impersonation. Admin could impersonated to be some of the users.

// TODO: Deprecate it?
// ! Deprecated: don't use it.
TokenTypeTFAPreauth TokenType = "2fa-preauth" // TokenTypeTFAPreauth is an 2fa preauth token type.
// TODO: Add other tokens, like admin, one, 2fa etc
)
Expand Down
26 changes: 14 additions & 12 deletions model/token_service.go
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
package model

const (
// OfflineScope is a scope value to request refresh token.
OfflineScope = "offline"
)

// TODO: refactor to reduce number of methods
// TODO: implement key rotation
// TokenService is an abstract token manager.
type TokenService interface {
NewAccessToken(u User, scopes []string, app AppData, requireTFA bool, tokenPayload map[string]interface{}) (Token, error)
NewRefreshToken(u User, scopes []string, app AppData) (Token, error)
RefreshAccessToken(token Token) (Token, error)
NewInviteToken(email, role, audience string, data map[string]interface{}) (Token, error)
NewResetToken(userID string) (Token, error)
NewToken(tokenType TokenType, userID string, payload []any) (Token, error)
NewWebCookieToken(u User) (Token, error)
// new methods
NewToken(tokenType TokenType, u User, fields []string, payload map[string]any) (Token, error)
SignToken(token Token) (string, error)

// // old methods
// NewAccessToken(u User, scopes []string, app AppData, requireTFA bool, tokenPayload map[string]interface{}) (Token, error)
// NewRefreshToken(u User, scopes []string, app AppData) (Token, error)
// RefreshAccessToken(token Token) (Token, error)
// NewInviteToken(email, role, audience string, data map[string]interface{}) (Token, error)
// NewResetToken(userID string) (Token, error)
// // NewToken(tokenType TokenType, userID string, payload []any) (Token, error)
// NewWebCookieToken(u User) (Token, error)
Parse(string) (Token, error)
String(Token) (string, error)
Issuer() string
Expand All @@ -25,6 +26,7 @@ type TokenService interface {
// replace the old private key with a new one
SetPrivateKey(key interface{})
PrivateKey() interface{}

// not using crypto.PublicKey here to avoid dependencies
PublicKey() interface{}
KeyID() string
Expand Down
19 changes: 11 additions & 8 deletions model/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,10 @@ type VerificationDetails struct {

// UserData model represents all collective information about the user
type UserData struct {
UserID string `json:"user_id,omitempty"`
TenantMembership *struct {
TenantID string `json:"tenant_id,omitempty"`
TenantName string `json:"tenant_name,omitempty"`
Groups map[string]string `json:"groups,omitempty"` // map of group names to ids
} `json:"tenant_membership,omitempty"`
AuthEnrollments []UserAuthEnrolment `json:"auth_enrollments,omitempty"`
Identities []UnitedUserIdentity `json:"identities,omitempty"`
UserID string `json:"user_id,omitempty"`
TenantMembership []TenantMembership `json:"tenant_membership,omitempty"`
AuthEnrollments []UserAuthEnrolment `json:"auth_enrollments,omitempty"`
Identities []UnitedUserIdentity `json:"identities,omitempty"`

// User devices
ActiveDevices []UserDevice `json:"active_devices,omitempty"`
Expand All @@ -85,6 +81,13 @@ type UserData struct {
DebugOTPCode string `json:"debug_otp,omitempty"`
}

// UserAuthEnrolment is representation for user tenant membership
type TenantMembership struct {
TenantID string `json:"tenant_id,omitempty"`
TenantName string `json:"tenant_name,omitempty"`
Groups map[string]string `json:"groups,omitempty"` // map of group names to ids
}

type UserBlockedDetails struct {
Reason string `json:"reason,omitempty"`
BlockedAt time.Time `json:"blocked_at,omitempty"`
Expand Down
10 changes: 10 additions & 0 deletions model/user_auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package model

// login response for user
type AuthResponse struct {
IDToken *string `json:"id_token,omitempty"`
AccessToken *string `json:"access_token,omitempty"`
RefreshToken *string `json:"refresh_token,omitempty"`
RedirectURI *string `json:"redirect_uri,omitempty"`
ClientChallenge *string `json:"client_challenge,omitempty"`
}
1 change: 1 addition & 0 deletions model/user_auth_challenge.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type UserAuthChallenge struct {
SolvedUserAgent string `json:"solved_user_agent"`
SolvedDeviceID string `json:"solved_device_id"`
ExpiresMins int `json:"expires_mins"`
ScopesRequested []string `json:"scopes_requested"`
OTP string `json:"value"` // OTP value, it the challenge is OTP code
}

Expand Down
2 changes: 2 additions & 0 deletions model/user_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,5 @@ type ChallengeController interface {
VerifyChallenge(ctx context.Context, challenge UserAuthChallenge, userIDValue string) error
LoginOrRegisterUserWithChallenge(ctx context.Context, challenge UserAuthChallenge, userIDValue string) (User, error)
}


34 changes: 34 additions & 0 deletions model/user_fieldset.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ const (
UserFieldsetPassword UserFieldset = "password"
UserFieldsetSecondaryIdentity UserFieldset = "secondary_identity"
UserFieldsetUpdatableByUser UserFieldset = "updatable_by_user"
UserFieldsetScopeOIDC UserFieldset = OIDCScope
UserFieldsetScopeEmail UserFieldset = EmailScope
UserFieldsetScopePhone UserFieldset = PhoneScope
UserFieldsetScopeProfile UserFieldset = ProfileScope
UserFieldsetScopeAddress UserFieldset = AddressScope

// TODO: Add fieldset cases for other cases.

UserFieldEmail = "Email"
Expand Down Expand Up @@ -61,6 +67,34 @@ var UserFieldsetMap = map[UserFieldset][]string{
"Username",
"PhoneNumber",
},
UserFieldsetScopeOIDC: {
"Profile",
"Picture",
"Website",
"Gender",
"Birthday",
"Timezone",
"Locale",
},
UserFieldsetScopeEmail: {
"Email",
"EmailVerificationDetails",
},
UserFieldsetScopePhone: {
"PhoneNumber",
"PhoneVerificationDetails",
},
UserFieldsetScopeProfile: {
"Username",
"GivenName",
"FamilyName",
"MiddleName",
"Nickname",
"PreferredUsername",
},
UserFieldsetScopeAddress: {
"Address",
},
}

func (f UserFieldset) Fields() []string {
Expand Down
51 changes: 51 additions & 0 deletions model/user_scopes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package model

// we support OIDC scopes, not claims
// https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims
const (
// OfflineScope is a scope value to request refresh token.
OfflineScope = "offline"
OIDCScope = "openid"
EmailScope = "email"
PhoneScope = "phone"
ProfileScope = "profile"
AddressScope = "address"
CustomScopePrefix = "custom:"
IDTokenScope = "id"
TenantScope = "tenant"
AccessTokenScopePrefix = "access:"
TenantScopePrefix = "tenant:" // tenant:123 - request tenant data only for tenant 123
TenantScopeAll = "all" // "tenant:all" - return all scopes for all ten
)

func FieldsetForScopes(scopes []string) []string {
fieldset := []string{}
for _, scope := range scopes {
switch scope {
case OIDCScope:
fieldset = append(fieldset, UserFieldsetMap[UserFieldsetScopeOIDC]...)
case EmailScope:
fieldset = append(fieldset, UserFieldsetMap[UserFieldsetScopeEmail]...)
case PhoneScope:
fieldset = append(fieldset, UserFieldsetMap[UserFieldsetScopePhone]...)
case AddressScope:
fieldset = append(fieldset, UserFieldsetMap[UserFieldsetScopeAddress]...)
case ProfileScope:
fieldset = append(fieldset, UserFieldsetMap[UserFieldsetScopeProfile]...)
}
}
return removeDuplicate(fieldset)
}

// removeDuplicate
func removeDuplicate[T comparable](slice []T) []T {
allKeys := make(map[T]bool)
list := []T{}
for _, item := range slice {
if _, ok := allKeys[item]; !ok {
allKeys[item] = true
list = append(list, item)
}
}
return list
}
48 changes: 37 additions & 11 deletions server/controller/user_controller_challenges.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,17 +237,17 @@ func randomOTP(length int) string {
}

// VerifyChallenge verifies challenge from user
func (c *UserStorageController) VerifyChallenge(ctx context.Context, challenge model.UserAuthChallenge, userIDValue string) (model.User, error) {
func (c *UserStorageController) VerifyChallenge(ctx context.Context, challenge model.UserAuthChallenge, userIDValue string) (model.User, model.AppData, error) {
app, err := c.as.AppByID(challenge.AppID)
if err != nil {
return model.User{}, err
return model.User{}, model.AppData{}, err
}

appAuthStrategies := app.AuthStrategies
compatibleStrategies := model.FilterCompatible(challenge.Strategy, appAuthStrategies)
// the app does not supports that type of challenge
if len(compatibleStrategies) == 0 {
return model.User{}, l.LocalizedError{ErrID: l.ErrorRequestChallengeUnsupportedByAPP}
return model.User{}, model.AppData{}, l.LocalizedError{ErrID: l.ErrorRequestChallengeUnsupportedByAPP}
}

// selecting the first strategy from the list.
Expand All @@ -257,16 +257,21 @@ func (c *UserStorageController) VerifyChallenge(ctx context.Context, challenge m
// using the challenge he requested
// if no user found, just silently return with no error for security reason
u, err := c.UserByAuthStrategy(ctx, auth, userIDValue)
if err != nil {
return model.User{}, nil

// check if we can register passwordless users in the app, if so, let's send a code
if err != nil && errors.Is(err, l.ErrorUserNotFound) && !app.RegistrationForbidden && app.PasswordlessRegistrationAllowed {
u = ephemeralUserForStrategy(challenge.Strategy, userIDValue)
} else if err != nil {
return model.User{}, model.AppData{}, err
}

// check if user has debug challenge and app allows to use it and it matches the code in request
// ? does not works for new users, to register you need to use real code (or not?)
shouldValidateOTP := true
if app.DebugOTPCodeAllowed {
if app.DebugOTPCodeAllowed && !model.ID(u.ID).IsNewUserID() {
ud, err := c.u.UserData(ctx, u.ID, model.UserDataFieldDebugOTPCode)
if err != nil {
return model.User{}, err
return model.User{}, model.AppData{}, err
}
if len(ud.DebugOTPCode) > 0 && ud.DebugOTPCode == challenge.OTP {
shouldValidateOTP = false
Expand All @@ -278,31 +283,52 @@ func (c *UserStorageController) VerifyChallenge(ctx context.Context, challenge m
if shouldValidateOTP {
ch, err := c.uas.GetLatestChallenge(ctx, challenge.Strategy, u.ID)
if err != nil {
return model.User{}, err
return model.User{}, model.AppData{}, err
}
err = ch.Valid()
if err != nil {
// TODO: Login attempt to login with invalid code
return model.User{}, err
return model.User{}, model.AppData{}, err
}
if ch.OTP != challenge.OTP {
// TODO: Login attempt to login with invalid code
return model.User{}, l.LocalizedError{ErrID: l.ErrorOtpIncorrect}
return model.User{}, model.AppData{}, l.LocalizedError{ErrID: l.ErrorOtpIncorrect}
}
// add information about context, when the challenge been solved
ch.SolvedDeviceID = challenge.DeviceID
ch.SolvedUserAgent = challenge.UserAgent
c.uas.MarkChallengeAsSolved(ctx, ch)
}
return u, nil
return u, app, nil
}

// Passwordless login or register user with challenge
func (c *UserStorageController) LoginOrRegisterUserWithChallenge(ctx context.Context, challenge model.UserAuthChallenge, userIDValue string) (model.User, error) {
// guard check
if challenge.Strategy.Type() != model.AuthStrategyFirstFactorInternal {
return model.User{}, l.LocalizedError{ErrID: l.ErrorLoginTypeNotSupported}
}

u, _, err := c.VerifyChallenge(ctx, challenge, userIDValue)
if err != nil {
return model.User{}, err
}

// let's register the user, if it is new user
if model.ID(u.ID).IsNewUserID() {
var err error
u.ID = "" // clear ID, so database layer should generate new one
u, err = c.ums.AddUser(ctx, u)
if err != nil {
return model.User{}, err
}
}

// TODO: save successful login attempt to the database
// TODO: update active devices list

// c.loginFlow(ctx, app, u, challenge.ScopesRequested) // ?? requested scopers

// check if we have no user exists and the code is valid and app allows to register new passwordless users
// then we create one
return model.User{}, nil
Expand Down
2 changes: 1 addition & 1 deletion server/controller/user_controller_communication.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func (c *UserStorageController) SendPasswordResetEmail(ctx context.Context, user
return model.ResetEmailData{}, err
}

resetToken, err := c.ts.NewResetToken(user.ID)
resetToken, err := c.ts.NewToken(model.TokenTypeReset, user, nil, nil)
if err != nil {
return model.ResetEmailData{}, err
}
Expand Down
Loading

0 comments on commit 074186d

Please sign in to comment.