Skip to content

Commit e60d89a

Browse files
committed
feat: allow limiting lifespan of low-aal sessions
1 parent 347e23a commit e60d89a

File tree

6 files changed

+139
-13
lines changed

6 files changed

+139
-13
lines changed

Diff for: internal/api/token_refresh.go

+11-2
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,13 @@ func (a *API) RefreshTokenGrant(ctx context.Context, w http.ResponseWriter, r *h
6565
return badRequestError(apierrors.ErrorCodeSessionNotFound, "Invalid Refresh Token: No Valid Session Found")
6666
}
6767

68-
result := session.CheckValidity(retryStart, &token.UpdatedAt, config.Sessions.Timebox, config.Sessions.InactivityTimeout)
68+
sessionValidityConfig := models.SessionValidityConfig{
69+
Timebox: config.Sessions.Timebox,
70+
InactivityTimeout: config.Sessions.InactivityTimeout,
71+
AllowLowAAL: config.Sessions.AllowLowAAL,
72+
}
73+
74+
result := session.CheckValidity(sessionValidityConfig, retryStart, &token.UpdatedAt, user.HighestPossibleAAL())
6975

7076
switch result {
7177
case models.SessionValid:
@@ -74,6 +80,9 @@ func (a *API) RefreshTokenGrant(ctx context.Context, w http.ResponseWriter, r *h
7480
case models.SessionTimedOut:
7581
return badRequestError(apierrors.ErrorCodeSessionExpired, "Invalid Refresh Token: Session Expired (Inactivity)")
7682

83+
case models.SessionLowAAL:
84+
return badRequestError(apierrors.ErrorCodeSessionExpired, "Invalid Refresh Token: Session Expired (Low AAL: User Needs MFA Verification)")
85+
7786
default:
7887
return badRequestError(apierrors.ErrorCodeSessionExpired, "Invalid Refresh Token: Session Expired")
7988
}
@@ -134,7 +143,7 @@ func (a *API) RefreshTokenGrant(ctx context.Context, w http.ResponseWriter, r *h
134143
continue
135144
}
136145

137-
if s.CheckValidity(retryStart, nil, config.Sessions.Timebox, config.Sessions.InactivityTimeout) != models.SessionValid {
146+
if s.CheckValidity(sessionValidityConfig, retryStart, nil, user.HighestPossibleAAL()) != models.SessionValid {
138147
// session is not valid so it
139148
// can't be regarded as active
140149
// on the user

Diff for: internal/conf/configuration.go

+10-5
Original file line numberDiff line numberDiff line change
@@ -163,20 +163,25 @@ func (a *APIConfiguration) Validate() error {
163163
}
164164

165165
type SessionsConfiguration struct {
166-
Timebox *time.Duration `json:"timebox"`
166+
Timebox *time.Duration `json:"timebox,omitempty"`
167167
InactivityTimeout *time.Duration `json:"inactivity_timeout,omitempty" split_words:"true"`
168+
AllowLowAAL *time.Duration `json:"allow_low_aal,omitempty" split_words:"true"`
168169

169170
SinglePerUser bool `json:"single_per_user" split_words:"true"`
170171
Tags []string `json:"tags,omitempty"`
171172
}
172173

173174
func (c *SessionsConfiguration) Validate() error {
174-
if c.Timebox == nil {
175-
return nil
175+
if c.Timebox != nil && *c.Timebox <= time.Duration(0) {
176+
return fmt.Errorf("conf: session timebox duration must be positive when set, was %v", (*c.Timebox).String())
176177
}
177178

178-
if *c.Timebox <= time.Duration(0) {
179-
return fmt.Errorf("conf: session timebox duration must be positive when set, was %v", (*c.Timebox).String())
179+
if c.InactivityTimeout != nil && *c.InactivityTimeout <= time.Duration(0) {
180+
return fmt.Errorf("conf: session inactivity timeout duration must be positive when set, was %v", (*c.InactivityTimeout).String())
181+
}
182+
183+
if c.AllowLowAAL != nil && *c.AllowLowAAL <= time.Duration(0) {
184+
return fmt.Errorf("conf: session allow low AAL duration must be positive when set, was %v", (*c.InactivityTimeout).String())
180185
}
181186

182187
return nil

Diff for: internal/conf/configuration_test.go

+11
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,17 @@ func TestValidate(t *testing.T) {
490490
err: `conf: session timebox duration must` +
491491
` be positive when set, was -1`,
492492
},
493+
{
494+
val: &SessionsConfiguration{AllowLowAAL: nil},
495+
},
496+
{
497+
val: &SessionsConfiguration{AllowLowAAL: new(time.Duration)},
498+
err: `conf: session allow low AAL duration must be positive when set, was 0`,
499+
},
500+
{
501+
val: &SessionsConfiguration{AllowLowAAL: toPtr(time.Duration(-1))},
502+
err: `conf: session allow low AAL duration must be positive when set, was -1`,
503+
},
493504
{
494505
val: &SessionsConfiguration{Timebox: toPtr(time.Duration(1))},
495506
},

Diff for: internal/models/sessions.go

+45-6
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,36 @@ func (aal AuthenticatorAssuranceLevel) String() string {
3434
}
3535
}
3636

37+
func (aal AuthenticatorAssuranceLevel) PointerString() *string {
38+
value := aal.String()
39+
40+
return &value
41+
}
42+
43+
// CompareAAL returns 0 if both AAL levels are equal, > 0 if A is a higher level than B or < 0 if A is a lower level than B.
44+
func CompareAAL(a, b AuthenticatorAssuranceLevel) int {
45+
return strings.Compare(a.String(), b.String())
46+
}
47+
48+
func ParseAAL(value *string) AuthenticatorAssuranceLevel {
49+
if value == nil {
50+
return AAL1
51+
}
52+
53+
switch *value {
54+
case AAL1.String():
55+
return AAL1
56+
57+
case AAL2.String():
58+
return AAL2
59+
60+
case AAL3.String():
61+
return AAL3
62+
}
63+
64+
return AAL1
65+
}
66+
3767
// AMREntry represents a method that a user has logged in together with the corresponding time
3868
type AMREntry struct {
3969
Method string `json:"method"`
@@ -117,21 +147,32 @@ const (
117147
SessionPastNotAfter = iota
118148
SessionPastTimebox = iota
119149
SessionTimedOut = iota
150+
SessionLowAAL = iota
120151
)
121152

122-
func (s *Session) CheckValidity(now time.Time, refreshTokenTime *time.Time, timebox, inactivityTimeout *time.Duration) SessionValidityReason {
153+
type SessionValidityConfig struct {
154+
Timebox *time.Duration
155+
InactivityTimeout *time.Duration
156+
AllowLowAAL *time.Duration
157+
}
158+
159+
func (s *Session) CheckValidity(config SessionValidityConfig, now time.Time, refreshTokenTime *time.Time, userHighestPossibleAAL AuthenticatorAssuranceLevel) SessionValidityReason {
123160
if s.NotAfter != nil && now.After(*s.NotAfter) {
124161
return SessionPastNotAfter
125162
}
126163

127-
if timebox != nil && *timebox != 0 && now.After(s.CreatedAt.Add(*timebox)) {
164+
if config.Timebox != nil && *config.Timebox != 0 && now.After(s.CreatedAt.Add(*config.Timebox)) {
128165
return SessionPastTimebox
129166
}
130167

131-
if inactivityTimeout != nil && *inactivityTimeout != 0 && now.After(s.LastRefreshedAt(refreshTokenTime).Add(*inactivityTimeout)) {
168+
if config.InactivityTimeout != nil && *config.InactivityTimeout != 0 && now.After(s.LastRefreshedAt(refreshTokenTime).Add(*config.InactivityTimeout)) {
132169
return SessionTimedOut
133170
}
134171

172+
if config.AllowLowAAL != nil && *config.AllowLowAAL != 0 && CompareAAL(ParseAAL(s.AAL), userHighestPossibleAAL) < 0 && now.After(s.CreatedAt.Add(*config.AllowLowAAL)) {
173+
return SessionLowAAL
174+
}
175+
135176
return SessionValid
136177
}
137178

@@ -161,11 +202,9 @@ func (s *Session) DetermineTag(tags []string) string {
161202
func NewSession(userID uuid.UUID, factorID *uuid.UUID) (*Session, error) {
162203
id := uuid.Must(uuid.NewV4())
163204

164-
defaultAAL := AAL1.String()
165-
166205
session := &Session{
167206
ID: id,
168-
AAL: &defaultAAL,
207+
AAL: AAL1.PointerString(),
169208
UserID: userID,
170209
FactorID: factorID,
171210
}

Diff for: internal/models/sessions_test.go

+50
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,53 @@ func (ts *SessionsTestSuite) TestCalculateAALAndAMR() {
102102
}
103103
require.True(ts.T(), found)
104104
}
105+
106+
func pointerDuration(value time.Duration) *time.Duration {
107+
return &value
108+
}
109+
110+
func TestCheckValidity(t *testing.T) {
111+
start := time.Now()
112+
113+
examples := []struct {
114+
name string
115+
session *Session
116+
highestPossibleAAL AuthenticatorAssuranceLevel
117+
now time.Time
118+
config SessionValidityConfig
119+
expected SessionValidityReason
120+
}{
121+
{
122+
name: "low aal session past creation time is invalid",
123+
now: start.Add(time.Second * 61),
124+
highestPossibleAAL: AAL2,
125+
session: &Session{
126+
AAL: AAL1.PointerString(),
127+
CreatedAt: start,
128+
},
129+
config: SessionValidityConfig{
130+
AllowLowAAL: pointerDuration(time.Second * 60),
131+
},
132+
expected: SessionLowAAL,
133+
},
134+
{
135+
name: "high aal session is valid past creation time",
136+
now: start.Add(time.Second * 61),
137+
highestPossibleAAL: AAL2,
138+
session: &Session{
139+
AAL: AAL2.PointerString(),
140+
CreatedAt: start,
141+
},
142+
config: SessionValidityConfig{
143+
AllowLowAAL: pointerDuration(time.Second * 60),
144+
},
145+
expected: SessionValid,
146+
},
147+
}
148+
149+
for _, example := range examples {
150+
t.Run(example.name, func(t *testing.T) {
151+
require.Equal(t, example.expected, example.session.CheckValidity(example.config, example.now, &example.now, example.highestPossibleAAL))
152+
})
153+
}
154+
}

Diff for: internal/models/user.go

+12
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,18 @@ func (u *User) Recover(tx *storage.Connection) error {
584584
return ClearAllOneTimeTokensForUser(tx, u.ID)
585585
}
586586

587+
// HighestPossibleAAL returns the AAL level that this user can obtain. Derived
588+
// from the number of verified MFA factors associated with the user object.
589+
func (u *User) HighestPossibleAAL() AuthenticatorAssuranceLevel {
590+
for _, factor := range u.Factors {
591+
if factor.Status == FactorStateVerified.String() {
592+
return AAL2
593+
}
594+
}
595+
596+
return AAL1
597+
}
598+
587599
// CountOtherUsers counts how many other users exist besides the one provided
588600
func CountOtherUsers(tx *storage.Connection, id uuid.UUID) (int, error) {
589601
userCount, err := tx.Q().Where("instance_id = ? and id != ?", uuid.Nil, id).Count(&User{})

0 commit comments

Comments
 (0)