Просмотр исходного кода

implement server-side profiles

Actually, OOS previouslyt stored all profiles server-side. In this commit,
only store profiles server side for clients that require them. Else,
temporarily store profiles per-session.

Client-side profile clients (AIM 1.0-6.1) set the profile at signon.
Server-side profile clients (AIM 6.2-6.9) request the profile at signon.

squashed commits:
- make profiles work for AIM 6.5-6.9
- send profile update time only for aim6 client
- add documentation
Mike 8 месяцев назад
Родитель
Сommit
b0e8b8a2c1

+ 6 - 0
cmd/server/factory.go

@@ -274,6 +274,7 @@ func OSCAR(deps Container) *oscar.Server {
 		deps.sqLiteUserStore,
 		deps.sqLiteUserStore,
 		deps.inMemorySessionManager,
+		deps.sqLiteUserStore,
 	)
 	oServiceService := foodgroup.NewOServiceService(
 		deps.cfg,
@@ -286,6 +287,7 @@ func OSCAR(deps Container) *oscar.Server {
 		deps.sqLiteUserStore,
 		deps.snacRateLimits,
 		deps.chatSessionManager,
+		deps.sqLiteUserStore,
 	)
 	userLookupService := foodgroup.NewUserLookupService(deps.sqLiteUserStore)
 	statsService := foodgroup.NewStatsService()
@@ -409,6 +411,7 @@ func TOC(deps Container) *toc.Server {
 				deps.sqLiteUserStore,
 				deps.sqLiteUserStore,
 				deps.inMemorySessionManager,
+				deps.sqLiteUserStore,
 			),
 			Logger: logger,
 			OServiceService: foodgroup.NewOServiceService(
@@ -422,6 +425,7 @@ func TOC(deps Container) *toc.Server {
 				deps.sqLiteUserStore,
 				deps.snacRateLimits,
 				deps.chatSessionManager,
+				deps.sqLiteUserStore,
 			),
 			PermitDenyService: foodgroup.NewPermitDenyService(
 				deps.sqLiteUserStore,
@@ -504,6 +508,7 @@ func WebAPI(deps Container) *webapi.Server {
 			deps.sqLiteUserStore,
 			deps.sqLiteUserStore,
 			deps.inMemorySessionManager,
+			deps.sqLiteUserStore,
 		),
 		Logger: logger,
 		OServiceService: foodgroup.NewOServiceService(
@@ -517,6 +522,7 @@ func WebAPI(deps Container) *webapi.Server {
 			deps.sqLiteUserStore,
 			deps.snacRateLimits,
 			deps.chatSessionManager,
+			deps.sqLiteUserStore,
 		),
 		PermitDenyService: foodgroup.NewPermitDenyService(
 			deps.sqLiteUserStore,

+ 2 - 6
foodgroup/admin_test.go

@@ -498,11 +498,7 @@ func TestAdminService_InfoChangeRequest_ScreenName(t *testing.T) {
 									FoodGroup: wire.OService,
 									SubGroup:  wire.OServiceUserInfoUpdate,
 								},
-								Body: wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
-									UserInfo: []wire.TLVUserInfo{
-										newTestSession("Chatting Chuck").TLVUserInfo(),
-									},
-								},
+								Body: newOServiceUserInfoUpdate(newTestSession("Chatting Chuck")),
 							},
 						},
 					},
@@ -565,7 +561,7 @@ func TestAdminService_InfoChangeRequest_ScreenName(t *testing.T) {
 									FoodGroup: wire.OService,
 									SubGroup:  wire.OServiceUserInfoUpdate,
 								},
-								Body: newMultiSessionInfoUpdate(newTestSession("Chatting Chuck")),
+								Body: newOServiceUserInfoUpdate(newTestSession("Chatting Chuck", sessOptSetFoodGroupVersion(wire.OService, 4))),
 							},
 						},
 					},

+ 6 - 1
foodgroup/auth.go

@@ -120,10 +120,12 @@ func (s AuthService) RegisterBOSSession(ctx context.Context, serverCookie state.
 		sess.SetUserInfoFlag(wire.OServiceUserFlagBot)
 	}
 
+	sess.SetKerberosAuth(serverCookie.KerberosAuth == 1)
+	sess.SetSignonTime(time.Now())
 	sess.SetRateClasses(time.Now(), s.rateLimitClasses)
-
 	// set string containing OSCAR client name and version
 	sess.SetClientID(serverCookie.ClientID)
+	sess.SetMemberSince(time.Now())
 
 	// indicate whether the client supports/wants multiple concurrent sessions
 	sess.SetMultiConnFlag(wire.MultiConnFlag(serverCookie.MultiConnFlag))
@@ -528,6 +530,9 @@ func (s AuthService) loginSuccessResponse(props loginProperties, advertisedHost
 		ClientID:      props.clientID,
 		MultiConnFlag: props.multiConnFlag,
 	}
+	if props.isKerberosPlaintextAuth || props.isKerberosRoastedAuth {
+		loginCookie.KerberosAuth = 1
+	}
 
 	buf := &bytes.Buffer{}
 	if err := wire.MarshalBE(loginCookie, buf); err != nil {

+ 4 - 2
foodgroup/auth_test.go

@@ -1138,7 +1138,8 @@ func TestAuthService_KerberosLogin(t *testing.T) {
 						{
 							dataIn: func() []byte {
 								loginCookie := state.ServerCookie{
-									ScreenName: user.DisplayScreenName,
+									ScreenName:   user.DisplayScreenName,
+									KerberosAuth: 1,
 								}
 								buf := &bytes.Buffer{}
 								assert.NoError(t, wire.MarshalBE(loginCookie, buf))
@@ -1263,7 +1264,8 @@ func TestAuthService_KerberosLogin(t *testing.T) {
 						{
 							dataIn: func() []byte {
 								loginCookie := state.ServerCookie{
-									ScreenName: user.DisplayScreenName,
+									ScreenName:   user.DisplayScreenName,
+									KerberosAuth: 1,
 								}
 								buf := &bytes.Buffer{}
 								assert.NoError(t, wire.MarshalBE(loginCookie, buf))

+ 28 - 21
foodgroup/helpers_test.go

@@ -428,7 +428,7 @@ type setDirectoryInfoParams []struct {
 // ProfileManager.Profile call site
 type retrieveProfileParams []struct {
 	screenName state.IdentScreenName
-	result     string
+	result     state.UserProfile
 	err        error
 }
 
@@ -436,7 +436,7 @@ type retrieveProfileParams []struct {
 // ProfileManager.SetProfile call site
 type setProfileParams []struct {
 	screenName state.IdentScreenName
-	body       any
+	body       state.UserProfile
 }
 
 // setKeywordsParams is the list of parameters passed at the mock
@@ -798,6 +798,32 @@ func sessOptSetRateClasses(classes wire.RateLimitClasses) func(session *state.Se
 	}
 }
 
+// sessOptMemberSince sets the member since timestamp on the session object.
+func sessOptMemberSince(t time.Time) func(session *state.Session) {
+	return func(session *state.Session) {
+		session.SetMemberSince(t)
+	}
+}
+
+// sessOptSignonTime sets the sign-on time on the session object.
+func sessOptSignonTime(t time.Time) func(session *state.Session) {
+	return func(session *state.Session) {
+		session.SetSignonTime(t)
+	}
+}
+
+// sessOptProfile sets profile
+func sessOptProfile(profile state.UserProfile) func(session *state.Session) {
+	return func(session *state.Session) {
+		session.SetProfile(profile)
+	}
+}
+
+// sessOptKerberosAuth indicates the session signed on
+func sessOptKerberosAuth(session *state.Session) {
+	session.SetKerberosAuth(true)
+}
+
 // sessClientID sets the client ID
 func sessClientID(clientID string) func(session *state.Session) {
 	return func(session *state.Session) {
@@ -845,22 +871,3 @@ func matchContext() interface{} {
 		return ok
 	})
 }
-
-// newMultiSessionInfoUpdate is a hack that returns a user info update SNAC with
-// primary and current instance user info blocks.
-func newMultiSessionInfoUpdate(session *state.Session) wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate {
-	return wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
-		UserInfo: []wire.TLVUserInfo{
-			func() wire.TLVUserInfo {
-				info := session.TLVUserInfo()
-				info.Append(wire.NewTLVBE(wire.OServiceUserInfoPrimaryInstance, []byte{0x01}))
-				return info
-			}(),
-			func() wire.TLVUserInfo {
-				info := session.TLVUserInfo()
-				info.Append(wire.NewTLVBE(wire.OServiceUserInfoMyInstanceNum, []byte{0x01}))
-				return info
-			}(),
-		},
-	}
-}

+ 26 - 9
foodgroup/locate.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"time"
 
 	"github.com/mk6i/retro-aim-server/state"
 	"github.com/mk6i/retro-aim-server/wire"
@@ -27,12 +28,14 @@ func NewLocateService(
 	profileManager ProfileManager,
 	relationshipFetcher RelationshipFetcher,
 	sessionRetriever SessionRetriever,
+	userManager UserManager,
 ) LocateService {
 	return LocateService{
 		buddyBroadcaster:    newBuddyNotifier(bartItemManager, relationshipFetcher, messageRelayer, sessionRetriever),
 		relationshipFetcher: relationshipFetcher,
 		profileManager:      profileManager,
 		sessionRetriever:    sessionRetriever,
+		userManager:         userManager,
 	}
 }
 
@@ -44,6 +47,7 @@ type LocateService struct {
 	relationshipFetcher RelationshipFetcher
 	profileManager      ProfileManager
 	sessionRetriever    SessionRetriever
+	userManager         UserManager
 }
 
 // RightsQuery returns SNAC wire.LocateRightsReply, which contains Locate food
@@ -73,10 +77,27 @@ func (s LocateService) RightsQuery(_ context.Context, inFrame wire.SNACFrame) wi
 
 // SetInfo sets the user's profile, away message or capabilities.
 func (s LocateService) SetInfo(ctx context.Context, sess *state.Session, inBody wire.SNAC_0x02_0x04_LocateSetInfo) error {
+
 	// update profile
-	if profile, hasProfile := inBody.String(wire.LocateTLVTagsInfoSigData); hasProfile {
-		if err := s.profileManager.SetProfile(ctx, sess.IdentScreenName(), profile); err != nil {
-			return err
+	if profileText, hasProfile := inBody.String(wire.LocateTLVTagsInfoSigData); hasProfile {
+		mime, _ := inBody.String(wire.LocateTLVTagsInfoSigMime)
+		profile := state.UserProfile{
+			ProfileText: profileText,
+			MIMEType:    mime,
+			UpdateTime:  time.Now(),
+		}
+
+		sess.SetProfile(profile)
+
+		// set the server-side profile
+		if sess.KerberosAuth() || inBody.HasTag(wire.LocateTLVTagsInfoSupportHostSig) {
+			// normally, the SupportHostSig TLV indicates that the profile should
+			// be stored server-side. however, some AIM 6 clients expect server-side
+			// profiles but do not send this TLV. in order to cover all bases, just
+			// save the profile for all kerberos-based clients.
+			if err := s.profileManager.SetProfile(ctx, sess.IdentScreenName(), profile); err != nil {
+				return err
+			}
 		}
 	}
 
@@ -152,13 +173,9 @@ func (s LocateService) UserInfoQuery(ctx context.Context, sess *state.Session, i
 	var list wire.TLVList
 
 	if inBody.RequestProfile() {
-		profile, err := s.profileManager.Profile(ctx, identScreenName)
-		if err != nil {
-			return wire.SNACMessage{}, err
-		}
 		list.AppendList([]wire.TLV{
-			wire.NewTLVBE(wire.LocateTLVTagsInfoSigMime, `text/aolrtf; charset="us-ascii"`),
-			wire.NewTLVBE(wire.LocateTLVTagsInfoSigData, profile),
+			wire.NewTLVBE(wire.LocateTLVTagsInfoSigMime, buddySess.Profile().MIMEType),
+			wire.NewTLVBE(wire.LocateTLVTagsInfoSigData, buddySess.Profile().ProfileText),
 		})
 	}
 

+ 54 - 96
foodgroup/locate_test.go

@@ -103,15 +103,11 @@ func TestLocateService_UserInfoQuery(t *testing.T) {
 							screenName: state.NewIdentScreenName("requested-user"),
 							result: newTestSession("requested-user",
 								sessOptCannedSignonTime,
-								sessOptCannedAwayMessage),
-						},
-					},
-				},
-				profileManagerParams: profileManagerParams{
-					retrieveProfileParams: retrieveProfileParams{
-						{
-							screenName: state.NewIdentScreenName("requested-user"),
-							result:     "this is my profile!",
+								sessOptCannedAwayMessage,
+								sessOptProfile(state.UserProfile{
+									ProfileText: "this is my profile!",
+									MIMEType:    "text/aolrtf; charset=\"us-ascii\"",
+								})),
 						},
 					},
 				},
@@ -136,76 +132,7 @@ func TestLocateService_UserInfoQuery(t *testing.T) {
 				Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
 					TLVUserInfo: newTestSession("requested-user",
 						sessOptCannedSignonTime,
-						sessOptCannedAwayMessage).
-						TLVUserInfo(),
-					LocateInfo: wire.TLVRestBlock{
-						TLVList: wire.TLVList{
-							wire.NewTLVBE(wire.LocateTLVTagsInfoSigMime, `text/aolrtf; charset="us-ascii"`),
-							wire.NewTLVBE(wire.LocateTLVTagsInfoSigData, "this is my profile!"),
-						},
-					},
-				},
-			},
-		},
-		{
-			name: "request user info + profile, expect user info response + profile",
-			mockParams: mockParams{
-				relationshipFetcherParams: relationshipFetcherParams{
-					relationshipParams: relationshipParams{
-						{
-							me:   state.NewIdentScreenName("user_screen_name"),
-							them: state.NewIdentScreenName("requested-user"),
-							result: state.Relationship{
-								User:          state.NewIdentScreenName("requested-user"),
-								BlocksYou:     false,
-								YouBlock:      false,
-								IsOnYourList:  true,
-								IsOnTheirList: true,
-							},
-						},
-					},
-				},
-				sessionRetrieverParams: sessionRetrieverParams{
-					retrieveSessionParams: retrieveSessionParams{
-						{
-							screenName: state.NewIdentScreenName("requested-user"),
-							result: newTestSession("requested-user",
-								sessOptCannedSignonTime,
-								sessOptCannedAwayMessage),
-						},
-					},
-				},
-				profileManagerParams: profileManagerParams{
-					retrieveProfileParams: retrieveProfileParams{
-						{
-							screenName: state.NewIdentScreenName("requested-user"),
-							result:     "this is my profile!",
-						},
-					},
-				},
-			},
-			userSession: newTestSession("user_screen_name"),
-			inputSNAC: wire.SNACMessage{
-				Frame: wire.SNACFrame{
-					RequestID: 1234,
-				},
-				Body: wire.SNAC_0x02_0x05_LocateUserInfoQuery{
-					// 2048 is a dummy to make sure bitmask check works
-					Type:       uint16(wire.LocateTypeSig) | 2048,
-					ScreenName: "requested-user",
-				},
-			},
-			expectOutput: wire.SNACMessage{
-				Frame: wire.SNACFrame{
-					FoodGroup: wire.Locate,
-					SubGroup:  wire.LocateUserInfoReply,
-					RequestID: 1234,
-				},
-				Body: wire.SNAC_0x02_0x06_LocateUserInfoReply{
-					TLVUserInfo: newTestSession("requested-user",
-						sessOptCannedSignonTime,
-						sessOptCannedAwayMessage).
-						TLVUserInfo(),
+						sessOptCannedAwayMessage).TLVUserInfo(),
 					LocateInfo: wire.TLVRestBlock{
 						TLVList: wire.TLVList{
 							wire.NewTLVBE(wire.LocateTLVTagsInfoSigMime, `text/aolrtf; charset="us-ascii"`),
@@ -377,16 +304,9 @@ func TestLocateService_UserInfoQuery(t *testing.T) {
 					RetrieveSession(val.screenName).
 					Return(val.result)
 			}
-			profileManager := newMockProfileManager(t)
-			for _, val := range tc.mockParams.retrieveProfileParams {
-				profileManager.EXPECT().
-					Profile(matchContext(), val.screenName).
-					Return(val.result, val.err)
-			}
 			svc := LocateService{
 				relationshipFetcher: relationshipFetcher,
 				sessionRetriever:    sessionRetriever,
-				profileManager:      profileManager,
 			}
 			outputSNAC, err := svc.UserInfoQuery(context.Background(), tc.userSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x02_0x05_LocateUserInfoQuery))
@@ -564,7 +484,7 @@ func TestLocateService_SetKeywordInfo(t *testing.T) {
 					SetKeywords(matchContext(), params.screenName, params.keywords).
 					Return(params.err)
 			}
-			svc := NewLocateService(nil, nil, profileManager, nil, nil)
+			svc := NewLocateService(nil, nil, profileManager, nil, nil, nil)
 			outputSNAC, err := svc.SetKeywordInfo(context.Background(), tt.userSession, tt.inputSNAC.Frame, tt.inputSNAC.Body.(wire.SNAC_0x02_0x0F_LocateSetKeywordInfo))
 			assert.NoError(t, err)
 			assert.Equal(t, tt.expectOutput, outputSNAC)
@@ -653,7 +573,7 @@ func TestLocateService_SetDirInfo(t *testing.T) {
 					SetDirectoryInfo(matchContext(), params.screenName, params.info).
 					Return(nil)
 			}
-			svc := NewLocateService(nil, nil, profileManager, nil, nil)
+			svc := NewLocateService(nil, nil, profileManager, nil, nil, nil)
 			outputSNAC, err := svc.SetDirInfo(context.Background(), tt.userSession, tt.inputSNAC.Frame, tt.inputSNAC.Body.(wire.SNAC_0x02_0x09_LocateSetDirInfo))
 			assert.NoError(t, err)
 			assert.Equal(t, tt.expectOutput, outputSNAC)
@@ -672,15 +592,35 @@ func TestLocateService_SetInfo(t *testing.T) {
 		// mockParams is the list of params sent to mocks that satisfy this
 		// method's dependencies
 		mockParams mockParams
-		wantErr    error
+		// wantSessionProfile is the expected profile set on the session
+		wantSessionProfile state.UserProfile
+		// wantErr is the expected error
+		wantErr error
 	}{
 		{
-			name:        "set profile",
+			name:        "set session profile (AIM < 6)",
 			userSession: newTestSession("test-user"),
 			inBody: wire.SNAC_0x02_0x04_LocateSetInfo{
 				TLVRestBlock: wire.TLVRestBlock{
 					TLVList: wire.TLVList{
 						wire.NewTLVBE(wire.LocateTLVTagsInfoSigData, "profile-result"),
+						wire.NewTLVBE(wire.LocateTLVTagsInfoSigMime, `text/aolrtf; charset="us-ascii"`),
+					},
+				},
+			},
+			wantSessionProfile: state.UserProfile{
+				ProfileText: "profile-result",
+				MIMEType:    `text/aolrtf; charset="us-ascii"`,
+			},
+		},
+		{
+			name:        "set stored profile (AIM 6-7)",
+			userSession: newTestSession("test-user", sessOptKerberosAuth),
+			inBody: wire.SNAC_0x02_0x04_LocateSetInfo{
+				TLVRestBlock: wire.TLVRestBlock{
+					TLVList: wire.TLVList{
+						wire.NewTLVBE(wire.LocateTLVTagsInfoSigData, "profile-result"),
+						wire.NewTLVBE(wire.LocateTLVTagsInfoSigMime, `text/aolrtf; charset="us-ascii"`),
 					},
 				},
 			},
@@ -689,11 +629,18 @@ func TestLocateService_SetInfo(t *testing.T) {
 					setProfileParams: setProfileParams{
 						{
 							screenName: state.NewIdentScreenName("test-user"),
-							body:       "profile-result",
+							body: state.UserProfile{
+								ProfileText: "profile-result",
+								MIMEType:    `text/aolrtf; charset="us-ascii"`,
+							},
 						},
 					},
 				},
 			},
+			wantSessionProfile: state.UserProfile{
+				ProfileText: "profile-result",
+				MIMEType:    `text/aolrtf; charset="us-ascii"`,
+			},
 		},
 		{
 			name:        "set away message during sign on flow",
@@ -737,7 +684,11 @@ func TestLocateService_SetInfo(t *testing.T) {
 			profileManager := newMockProfileManager(t)
 			for _, params := range tt.mockParams.setProfileParams {
 				profileManager.EXPECT().
-					SetProfile(matchContext(), params.screenName, params.body).
+					SetProfile(matchContext(), params.screenName, mock.MatchedBy(func(profile state.UserProfile) bool {
+						return profile.ProfileText == params.body.ProfileText &&
+							profile.MIMEType == params.body.MIMEType &&
+							!profile.UpdateTime.IsZero()
+					})).
 					Return(nil)
 			}
 			buddyUpdateBroadcaster := newMockbuddyBroadcaster(t)
@@ -748,15 +699,22 @@ func TestLocateService_SetInfo(t *testing.T) {
 					})).
 					Return(params.err)
 			}
-			svc := NewLocateService(nil, nil, profileManager, nil, nil)
+			svc := NewLocateService(nil, nil, profileManager, nil, nil, nil)
 			svc.buddyBroadcaster = buddyUpdateBroadcaster
 			assert.Equal(t, tt.wantErr, svc.SetInfo(context.Background(), tt.userSession, tt.inBody))
+
+			if tt.wantSessionProfile.Empty() {
+				assert.True(t, tt.userSession.Profile().Empty())
+			} else {
+				assert.Equal(t, tt.wantSessionProfile.ProfileText, tt.userSession.Profile().ProfileText)
+				assert.Equal(t, tt.wantSessionProfile.MIMEType, tt.userSession.Profile().MIMEType)
+			}
 		})
 	}
 }
 
 func TestLocateService_SetInfo_SetCaps(t *testing.T) {
-	svc := NewLocateService(nil, nil, nil, nil, nil)
+	svc := NewLocateService(nil, nil, nil, nil, nil, nil)
 
 	sess := newTestSession("screen-name")
 	inBody := wire.SNAC_0x02_0x04_LocateSetInfo{
@@ -789,7 +747,7 @@ func TestLocateService_SetInfo_SetCaps(t *testing.T) {
 }
 
 func TestLocateService_RightsQuery(t *testing.T) {
-	svc := NewLocateService(nil, nil, nil, nil, nil)
+	svc := NewLocateService(nil, nil, nil, nil, nil, nil)
 
 	outputSNAC := svc.RightsQuery(context.Background(), wire.SNACFrame{RequestID: 1234})
 	expectSNAC := wire.SNACMessage{
@@ -933,7 +891,7 @@ func TestLocateService_DirInfo(t *testing.T) {
 					User(matchContext(), params.screenName).
 					Return(params.result, params.err)
 			}
-			svc := NewLocateService(nil, nil, profileManager, nil, nil)
+			svc := NewLocateService(nil, nil, profileManager, nil, nil, nil)
 			outputSNAC, err := svc.DirInfo(context.Background(), tt.inputSNAC.Frame, tt.inputSNAC.Body.(wire.SNAC_0x02_0x0B_LocateGetDirInfo))
 			assert.NoError(t, err)
 			assert.Equal(t, tt.expectOutput, outputSNAC)

+ 18 - 18
foodgroup/mock_profile_manager_test.go

@@ -258,22 +258,22 @@ func (_c *mockProfileManager_InterestList_Call) RunAndReturn(run func(context.Co
 }
 
 // Profile provides a mock function with given fields: ctx, screenName
-func (_m *mockProfileManager) Profile(ctx context.Context, screenName state.IdentScreenName) (string, error) {
+func (_m *mockProfileManager) Profile(ctx context.Context, screenName state.IdentScreenName) (state.UserProfile, error) {
 	ret := _m.Called(ctx, screenName)
 
 	if len(ret) == 0 {
 		panic("no return value specified for Profile")
 	}
 
-	var r0 string
+	var r0 state.UserProfile
 	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) (string, error)); ok {
+	if rf, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) (state.UserProfile, error)); ok {
 		return rf(ctx, screenName)
 	}
-	if rf, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) string); ok {
+	if rf, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) state.UserProfile); ok {
 		r0 = rf(ctx, screenName)
 	} else {
-		r0 = ret.Get(0).(string)
+		r0 = ret.Get(0).(state.UserProfile)
 	}
 
 	if rf, ok := ret.Get(1).(func(context.Context, state.IdentScreenName) error); ok {
@@ -304,12 +304,12 @@ func (_c *mockProfileManager_Profile_Call) Run(run func(ctx context.Context, scr
 	return _c
 }
 
-func (_c *mockProfileManager_Profile_Call) Return(_a0 string, _a1 error) *mockProfileManager_Profile_Call {
+func (_c *mockProfileManager_Profile_Call) Return(_a0 state.UserProfile, _a1 error) *mockProfileManager_Profile_Call {
 	_c.Call.Return(_a0, _a1)
 	return _c
 }
 
-func (_c *mockProfileManager_Profile_Call) RunAndReturn(run func(context.Context, state.IdentScreenName) (string, error)) *mockProfileManager_Profile_Call {
+func (_c *mockProfileManager_Profile_Call) RunAndReturn(run func(context.Context, state.IdentScreenName) (state.UserProfile, error)) *mockProfileManager_Profile_Call {
 	_c.Call.Return(run)
 	return _c
 }
@@ -410,17 +410,17 @@ func (_c *mockProfileManager_SetKeywords_Call) RunAndReturn(run func(context.Con
 	return _c
 }
 
-// SetProfile provides a mock function with given fields: ctx, screenName, body
-func (_m *mockProfileManager) SetProfile(ctx context.Context, screenName state.IdentScreenName, body string) error {
-	ret := _m.Called(ctx, screenName, body)
+// SetProfile provides a mock function with given fields: ctx, screenName, profile
+func (_m *mockProfileManager) SetProfile(ctx context.Context, screenName state.IdentScreenName, profile state.UserProfile) error {
+	ret := _m.Called(ctx, screenName, profile)
 
 	if len(ret) == 0 {
 		panic("no return value specified for SetProfile")
 	}
 
 	var r0 error
-	if rf, ok := ret.Get(0).(func(context.Context, state.IdentScreenName, string) error); ok {
-		r0 = rf(ctx, screenName, body)
+	if rf, ok := ret.Get(0).(func(context.Context, state.IdentScreenName, state.UserProfile) error); ok {
+		r0 = rf(ctx, screenName, profile)
 	} else {
 		r0 = ret.Error(0)
 	}
@@ -436,14 +436,14 @@ type mockProfileManager_SetProfile_Call struct {
 // SetProfile is a helper method to define mock.On call
 //   - ctx context.Context
 //   - screenName state.IdentScreenName
-//   - body string
-func (_e *mockProfileManager_Expecter) SetProfile(ctx interface{}, screenName interface{}, body interface{}) *mockProfileManager_SetProfile_Call {
-	return &mockProfileManager_SetProfile_Call{Call: _e.mock.On("SetProfile", ctx, screenName, body)}
+//   - profile state.UserProfile
+func (_e *mockProfileManager_Expecter) SetProfile(ctx interface{}, screenName interface{}, profile interface{}) *mockProfileManager_SetProfile_Call {
+	return &mockProfileManager_SetProfile_Call{Call: _e.mock.On("SetProfile", ctx, screenName, profile)}
 }
 
-func (_c *mockProfileManager_SetProfile_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName, body string)) *mockProfileManager_SetProfile_Call {
+func (_c *mockProfileManager_SetProfile_Call) Run(run func(ctx context.Context, screenName state.IdentScreenName, profile state.UserProfile)) *mockProfileManager_SetProfile_Call {
 	_c.Call.Run(func(args mock.Arguments) {
-		run(args[0].(context.Context), args[1].(state.IdentScreenName), args[2].(string))
+		run(args[0].(context.Context), args[1].(state.IdentScreenName), args[2].(state.UserProfile))
 	})
 	return _c
 }
@@ -453,7 +453,7 @@ func (_c *mockProfileManager_SetProfile_Call) Return(_a0 error) *mockProfileMana
 	return _c
 }
 
-func (_c *mockProfileManager_SetProfile_Call) RunAndReturn(run func(context.Context, state.IdentScreenName, string) error) *mockProfileManager_SetProfile_Call {
+func (_c *mockProfileManager_SetProfile_Call) RunAndReturn(run func(context.Context, state.IdentScreenName, state.UserProfile) error) *mockProfileManager_SetProfile_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 43 - 0
foodgroup/oservice.go

@@ -26,6 +26,7 @@ type OServiceService struct {
 	cookieIssuer       CookieBaker
 	messageRelayer     MessageRelayer
 	chatMessageRelayer ChatMessageRelayer
+	profileManager     ProfileManager
 }
 
 // NewOServiceService creates a new instance of NewOServiceService.
@@ -40,6 +41,7 @@ func NewOServiceService(
 	bartItemManager BARTItemManager,
 	snacRateLimits wire.SNACRateLimits,
 	chatMessageRelayer ChatMessageRelayer,
+	profileManager ProfileManager,
 ) *OServiceService {
 	return &OServiceService{
 		cookieIssuer:       cookieIssuer,
@@ -51,6 +53,7 @@ func NewOServiceService(
 		timeNow:            time.Now,
 		chatRoomManager:    chatRoomManager,
 		chatMessageRelayer: chatMessageRelayer,
+		profileManager:     profileManager,
 	}
 }
 
@@ -652,6 +655,32 @@ func (s OServiceService) ClientOnline(ctx context.Context, service uint16, bodyI
 			},
 		}
 		s.messageRelayer.RelayToScreenName(ctx, sess.IdentScreenName(), msg)
+
+		// set stored profile
+		if sess.KerberosAuth() {
+			// normally, the SupportHostSig TLV indicates that the profile should
+			// be stored server-side. however, some AIM 6 clients expect server-side
+			// profiles but do not send this TLV. in order to cover all bases, just
+			// save the profile for all kerberos-based clients.
+			profile, err := s.profileManager.Profile(ctx, sess.IdentScreenName())
+			if err != nil {
+				return fmt.Errorf("unable to reload profile: %w", err)
+			}
+
+			if !profile.Empty() {
+				sess.SetProfile(profile)
+
+				// notify client that the server-side profile is ready for retrieval
+				s.messageRelayer.RelayToScreenName(ctx, sess.IdentScreenName(), wire.SNACMessage{
+					Frame: wire.SNACFrame{
+						FoodGroup: wire.OService,
+						SubGroup:  wire.OServiceUserInfoUpdate,
+					},
+					Body: newOServiceUserInfoUpdate(sess),
+				})
+			}
+		}
+
 	case wire.Chat:
 		room, err := s.chatRoomManager.ChatRoomByCookie(ctx, sess.ChatRoomCookie())
 		if err != nil {
@@ -678,6 +707,19 @@ func newOServiceUserInfoUpdate(sess *state.Session) wire.SNAC_0x01_0x0F_OService
 	info := sess.TLVUserInfo()
 	userInfo := []wire.TLVUserInfo{info}
 
+	// set registration date
+	userInfo[0].Append(wire.NewTLVBE(wire.OServiceUserInfoMemberSince, uint32(sess.MemberSince().Unix())))
+	// set sign-on time
+	userInfo[0].Append(wire.NewTLVBE(wire.OServiceUserInfoSignonTOD, uint32(sess.SignonTime().Unix())))
+	// set current session length (seconds)
+	userInfo[0].Append(wire.NewTLVBE(wire.OServiceUserInfoOnlineTime, uint32(time.Since(sess.SignonTime()).Seconds())))
+
+	profile := sess.Profile()
+	if !profile.UpdateTime.IsZero() {
+		// set profile update time if the profile was set
+		userInfo[0].Append(wire.NewTLVBE(wire.OServiceUserInfoSigTime, uint32(profile.UpdateTime.Unix())))
+	}
+
 	if sess.FoodGroupVersions()[wire.OService] >= 4 {
 		// ideally, the second block should contain only instance-specific TLVs,
 		// but since the exact structure is unclear, we temporarily duplicate the first.
@@ -691,4 +733,5 @@ func newOServiceUserInfoUpdate(sess *state.Session) wire.SNAC_0x01_0x0F_OService
 	return wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
 		UserInfo: userInfo,
 	}
+
 }

+ 226 - 22
foodgroup/oservice_test.go

@@ -9,6 +9,7 @@ import (
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/mock"
+	"github.com/stretchr/testify/require"
 
 	"github.com/mk6i/retro-aim-server/config"
 	"github.com/mk6i/retro-aim-server/state"
@@ -79,6 +80,7 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 								0x0, // no client ID
 								0x0, // no chat cookie
 								0x0, // multi conn flag
+								0x0, // kerberos flag
 							},
 							cookieOut: []byte("the-cookie"),
 						},
@@ -126,6 +128,7 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 								0x0, // no client ID
 								0x0, // no chat cookie
 								0x0, // multi conn flag
+								0x0, // kerberos flag
 							},
 							cookieOut: []byte("the-cookie"),
 						},
@@ -173,6 +176,7 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 								0x0, // no client ID
 								0x0, // no chat cookie
 								0x0, // multi conn flag
+								0x0, // kerberos flag
 							},
 							cookieOut: []byte("the-cookie"),
 						},
@@ -220,6 +224,7 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 								0x0, // no client ID
 								0x0, // no chat cookie
 								0x0, // multi conn flag
+								0x0, // kerberos flag
 							},
 							cookieOut: []byte("the-cookie"),
 						},
@@ -285,6 +290,7 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 									0x00, // no client ID
 									0x11, '4', '-', '0', '-', 't', 'h', 'e', '-', 'c', 'h', 'a', 't', '-', 'r', 'o', 'o', 'm',
 									0x0, // multi conn flag
+									0x0, // kerberos flag
 								},
 								cookieOut: []byte("the-auth-cookie"),
 							},
@@ -333,6 +339,7 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 								0x0, // no client ID
 								0x0, // no chat cookie
 								0x0, // multi conn flag
+								0x0, // kerberos flag
 							},
 							cookieOut: []byte("the-cookie"),
 						},
@@ -465,6 +472,7 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 								0x0, // no client ID
 								0x0, // no chat cookie
 								0x0, // multi conn flag
+								0x0, // kerberos flag
 							},
 							cookieOut: []byte("the-cookie"),
 						},
@@ -517,6 +525,7 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 								0x0, // no client ID
 								0x0, // no chat cookie
 								0x0, // multi conn flag
+								0x0, // kerberos flag
 							},
 							cookieOut: []byte("the-cookie"),
 						},
@@ -569,6 +578,7 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 								0x0, // no client ID
 								0x0, // no chat cookie
 								0x0, // multi conn flag
+								0x0, // kerberos flag
 							},
 							cookieOut: []byte("the-cookie"),
 						},
@@ -621,6 +631,7 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 								0x0, // no client ID
 								0x0, // no chat cookie
 								0x0, // multi conn flag
+								0x0, // kerberos flag
 							},
 							cookieOut: []byte("the-cookie"),
 						},
@@ -687,6 +698,7 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 									0x00, // no client ID
 									0x11, '4', '-', '0', '-', 't', 'h', 'e', '-', 'c', 'h', 'a', 't', '-', 'r', 'o', 'o', 'm',
 									0x0, // multi conn flag
+									0x0, // kerberos flag
 								},
 								cookieOut: []byte("the-auth-cookie"),
 							},
@@ -740,6 +752,7 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 								0x0, // no client ID
 								0x0, // no chat cookie
 								0x0, // multi conn flag
+								0x0, // kerberos flag
 							},
 							cookieOut: []byte("the-cookie"),
 						},
@@ -801,7 +814,7 @@ func TestOServiceService_ServiceRequest(t *testing.T) {
 			//
 			// send input SNAC
 			//
-			svc := NewOServiceService(config.Config{}, nil, slog.Default(), cookieIssuer, chatRoomManager, nil, nil, nil, wire.DefaultSNACRateLimits(), chatMessageRelayer)
+			svc := NewOServiceService(config.Config{}, nil, slog.Default(), cookieIssuer, chatRoomManager, nil, nil, nil, wire.DefaultSNACRateLimits(), chatMessageRelayer, nil)
 
 			outputSNAC, err := svc.ServiceRequest(context.Background(), tc.service, tc.userSession, tc.inputSNAC.Frame,
 				tc.inputSNAC.Body.(wire.SNAC_0x01_0x04_OServiceServiceRequest), tc.listener)
@@ -863,11 +876,7 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 					SubGroup:  wire.OServiceUserInfoUpdate,
 					RequestID: 1234,
 				},
-				Body: wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
-					UserInfo: []wire.TLVUserInfo{
-						newTestSession("me").TLVUserInfo(),
-					},
-				},
+				Body: newOServiceUserInfoUpdate(newTestSession("me")),
 			},
 			mockParams: mockParams{
 				buddyBroadcasterParams: buddyBroadcasterParams{
@@ -900,11 +909,7 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 					SubGroup:  wire.OServiceUserInfoUpdate,
 					RequestID: 1234,
 				},
-				Body: wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
-					UserInfo: []wire.TLVUserInfo{
-						newTestSession("me", sessOptInvisible).TLVUserInfo(),
-					},
-				},
+				Body: newOServiceUserInfoUpdate(newTestSession("me", sessOptInvisible)),
 			},
 			mockParams: mockParams{
 				buddyBroadcasterParams: buddyBroadcasterParams{
@@ -937,7 +942,7 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 					SubGroup:  wire.OServiceUserInfoUpdate,
 					RequestID: 1234,
 				},
-				Body: newMultiSessionInfoUpdate(newTestSession("me")),
+				Body: newOServiceUserInfoUpdate(newTestSession("me", sessOptSetFoodGroupVersion(wire.OService, 4))),
 			},
 			mockParams: mockParams{
 				buddyBroadcasterParams: buddyBroadcasterParams{
@@ -970,7 +975,7 @@ func TestOServiceService_SetUserInfoFields(t *testing.T) {
 					SubGroup:  wire.OServiceUserInfoUpdate,
 					RequestID: 1234,
 				},
-				Body: newMultiSessionInfoUpdate(newTestSession("me", sessOptInvisible)),
+				Body: newOServiceUserInfoUpdate(newTestSession("me", sessOptSetFoodGroupVersion(wire.OService, 4), sessOptInvisible)),
 			},
 			mockParams: mockParams{
 				buddyBroadcasterParams: buddyBroadcasterParams{
@@ -1698,7 +1703,7 @@ func TestOServiceService_HostOnline(t *testing.T) {
 
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
-			svc := NewOServiceService(config.Config{}, nil, slog.Default(), nil, nil, nil, nil, nil, wire.DefaultSNACRateLimits(), nil)
+			svc := NewOServiceService(config.Config{}, nil, slog.Default(), nil, nil, nil, nil, nil, wire.DefaultSNACRateLimits(), nil, nil)
 			have := svc.HostOnline(tc.service)
 			assert.Equal(t, tc.expectOutput, have)
 		})
@@ -1732,6 +1737,94 @@ func TestOServiceService_ClientVersions(t *testing.T) {
 	assert.Equal(t, want, have)
 }
 
+func TestNewOServiceUserInfoUpdate(t *testing.T) {
+	memberSince := time.Unix(1_700_000_000, 0)
+	profileUpdated := time.Unix(1_700_100_000, 0)
+	signonTime := time.Now().Add(-3 * time.Second)
+
+	tests := []struct {
+		name              string
+		session           *state.Session
+		expectSigTime     bool
+		sigTime           time.Time
+		expectSecondBlock bool
+	}{
+		{
+			name: "OService version < 4 without profile",
+			session: newTestSession("me",
+				sessOptMemberSince(memberSince),
+				sessOptSignonTime(signonTime)),
+		},
+		{
+			name: "includes profile update time when set",
+			session: newTestSession("me",
+				sessOptMemberSince(memberSince),
+				sessOptSignonTime(signonTime),
+				sessOptProfile(state.UserProfile{UpdateTime: profileUpdated})),
+			expectSigTime: true,
+			sigTime:       profileUpdated,
+		},
+		{
+			name: "duplicates user info for aim >= 4",
+			session: newTestSession("me",
+				sessOptMemberSince(memberSince),
+				sessOptSignonTime(signonTime),
+				sessOptSetFoodGroupVersion(wire.OService, 4)),
+			expectSecondBlock: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			signon := tt.session.SignonTime()
+			onlineLowerBound := uint32(time.Since(signon).Seconds())
+
+			got := newOServiceUserInfoUpdate(tt.session)
+
+			expectedLen := 1
+			if tt.expectSecondBlock {
+				expectedLen = 2
+			}
+			require.Len(t, got.UserInfo, expectedLen)
+
+			memberVal, ok := got.UserInfo[0].Uint32BE(wire.OServiceUserInfoMemberSince)
+			require.True(t, ok)
+			require.Equal(t, uint32(memberSince.Unix()), memberVal)
+
+			signonVal, ok := got.UserInfo[0].Uint32BE(wire.OServiceUserInfoSignonTOD)
+			require.True(t, ok)
+			require.Equal(t, uint32(signon.Unix()), signonVal)
+
+			onlineVal, ok := got.UserInfo[0].Uint32BE(wire.OServiceUserInfoOnlineTime)
+			require.True(t, ok)
+			require.GreaterOrEqual(t, onlineVal, onlineLowerBound)
+			require.LessOrEqual(t, onlineVal-onlineLowerBound, uint32(2))
+
+			hasSigTime := got.UserInfo[0].HasTag(wire.OServiceUserInfoSigTime)
+			require.Equal(t, tt.expectSigTime, hasSigTime)
+			if tt.expectSigTime {
+				sigVal, ok := got.UserInfo[0].Uint32BE(wire.OServiceUserInfoSigTime)
+				require.True(t, ok)
+				require.Equal(t, uint32(tt.sigTime.Unix()), sigVal)
+			}
+
+			if tt.expectSecondBlock {
+				primaryBytes, ok := got.UserInfo[0].Bytes(wire.OServiceUserInfoPrimaryInstance)
+				require.True(t, ok)
+				require.Equal(t, []byte{0x01}, primaryBytes)
+
+				instanceBytes, ok := got.UserInfo[1].Bytes(wire.OServiceUserInfoMyInstanceNum)
+				require.True(t, ok)
+				require.Equal(t, []byte{0x01}, instanceBytes)
+
+				require.Equal(t, got.UserInfo[0].ScreenName, got.UserInfo[1].ScreenName)
+			} else {
+				require.False(t, got.UserInfo[0].HasTag(wire.OServiceUserInfoPrimaryInstance))
+			}
+		})
+	}
+}
+
 func TestOServiceService_UserInfoQuery(t *testing.T) {
 	tests := []struct {
 		name    string
@@ -1752,11 +1845,7 @@ func TestOServiceService_UserInfoQuery(t *testing.T) {
 					SubGroup:  wire.OServiceUserInfoUpdate,
 					RequestID: 1234,
 				},
-				Body: wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate{
-					UserInfo: []wire.TLVUserInfo{
-						newTestSession("me").TLVUserInfo(),
-					},
-				},
+				Body: newOServiceUserInfoUpdate(newTestSession("me")),
 			},
 		},
 		{
@@ -1771,7 +1860,7 @@ func TestOServiceService_UserInfoQuery(t *testing.T) {
 					SubGroup:  wire.OServiceUserInfoUpdate,
 					RequestID: 1234,
 				},
-				Body: newMultiSessionInfoUpdate(newTestSession("me")),
+				Body: newOServiceUserInfoUpdate(newTestSession("me", sessOptSetFoodGroupVersion(wire.OService, 4))),
 			},
 		},
 	}
@@ -1876,7 +1965,7 @@ func TestOServiceService_ClientOnline(t *testing.T) {
 		wantSess *state.Session
 	}{
 		{
-			name:    "notify that user is online",
+			name:    "notify that BOS user is online",
 			sess:    newTestSession("me", sessOptCannedSignonTime),
 			bodyIn:  wire.SNAC_0x01_0x02_OServiceClientOnline{},
 			service: wire.BOS,
@@ -1910,6 +1999,114 @@ func TestOServiceService_ClientOnline(t *testing.T) {
 			},
 			wantSess: newTestSession("me", sessOptCannedSignonTime, sessOptSignonComplete),
 		},
+		{
+			name:    "notify that BOS user is online via Kerberos auth, does not have stored profile",
+			sess:    newTestSession("me", sessOptCannedSignonTime, sessOptKerberosAuth),
+			bodyIn:  wire.SNAC_0x01_0x02_OServiceClientOnline{},
+			service: wire.BOS,
+			mockParams: mockParams{
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastVisibilityParams: broadcastVisibilityParams{
+						{
+							from:             state.NewIdentScreenName("me"),
+							filter:           nil,
+							doSendDepartures: false,
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("me"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Stats,
+									SubGroup:  wire.StatsSetMinReportInterval,
+									RequestID: wire.ReqIDFromServer,
+								},
+								Body: wire.SNAC_0x0B_0x02_StatsSetMinReportInterval{
+									MinReportInterval: 1,
+								},
+							},
+						},
+					},
+				},
+				profileManagerParams: profileManagerParams{
+					retrieveProfileParams: retrieveProfileParams{
+						{
+							screenName: state.NewIdentScreenName("me"),
+							result:     state.UserProfile{},
+						},
+					},
+				},
+			},
+			wantSess: newTestSession("me", sessOptCannedSignonTime, sessOptSignonComplete),
+		},
+		{
+			name:    "notify that BOS user is online via Kerberos auth, has stored profile",
+			sess:    newTestSession("me", sessOptCannedSignonTime, sessOptKerberosAuth),
+			bodyIn:  wire.SNAC_0x01_0x02_OServiceClientOnline{},
+			service: wire.BOS,
+			mockParams: mockParams{
+				buddyBroadcasterParams: buddyBroadcasterParams{
+					broadcastVisibilityParams: broadcastVisibilityParams{
+						{
+							from:             state.NewIdentScreenName("me"),
+							filter:           nil,
+							doSendDepartures: false,
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					relayToScreenNameParams: relayToScreenNameParams{
+						{
+							screenName: state.NewIdentScreenName("me"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.Stats,
+									SubGroup:  wire.StatsSetMinReportInterval,
+									RequestID: wire.ReqIDFromServer,
+								},
+								Body: wire.SNAC_0x0B_0x02_StatsSetMinReportInterval{
+									MinReportInterval: 1,
+								},
+							},
+						},
+						{
+							screenName: state.NewIdentScreenName("me"),
+							message: wire.SNACMessage{
+								Frame: wire.SNACFrame{
+									FoodGroup: wire.OService,
+									SubGroup:  wire.OServiceUserInfoUpdate,
+								},
+								Body: newOServiceUserInfoUpdate(newTestSession("me", sessOptCannedSignonTime)),
+							},
+						},
+					},
+				},
+				profileManagerParams: profileManagerParams{
+					retrieveProfileParams: retrieveProfileParams{
+						{
+							screenName: state.NewIdentScreenName("me"),
+							result: state.UserProfile{
+								ProfileText: "profile-result",
+								MIMEType:    `text/aolrtf; charset="us-ascii"`,
+							},
+						},
+					},
+				},
+			},
+			wantSess: newTestSession("me",
+				sessOptCannedSignonTime,
+				sessOptSignonComplete,
+				sessOptProfile(
+					state.UserProfile{
+						ProfileText: "profile-result",
+						MIMEType:    `text/aolrtf; charset="us-ascii"`,
+					},
+				),
+			),
+		},
 		{
 			name:    "upon joining, send chat room metadata and participant list to joining user; alert arrival to existing participants",
 			sess:    chatter1,
@@ -2026,12 +2223,19 @@ func TestOServiceService_ClientOnline(t *testing.T) {
 				chatMessageRelayer.EXPECT().
 					RelayToScreenName(mock.Anything, params.cookie, params.screenName, params.message)
 			}
+			profileManager := newMockProfileManager(t)
+			for _, params := range tt.mockParams.profileManagerParams.retrieveProfileParams {
+				profileManager.EXPECT().
+					Profile(mock.Anything, params.screenName).
+					Return(params.result, params.err)
+			}
 
-			svc := NewOServiceService(config.Config{}, messageRelayer, slog.Default(), nil, chatRoomManager, nil, nil, nil, wire.DefaultSNACRateLimits(), chatMessageRelayer)
+			svc := NewOServiceService(config.Config{}, messageRelayer, slog.Default(), nil, chatRoomManager, nil, nil, nil, wire.DefaultSNACRateLimits(), chatMessageRelayer, profileManager)
 			svc.buddyBroadcaster = buddyUpdateBroadcaster
 			haveErr := svc.ClientOnline(context.Background(), tt.service, tt.bodyIn, tt.sess)
 			assert.ErrorIs(t, tt.wantErr, haveErr)
 			assert.Equal(t, tt.wantSess.SignonComplete(), tt.sess.SignonComplete())
+			assert.Equal(t, tt.wantSess.Profile(), tt.sess.Profile())
 		})
 	}
 }

+ 4 - 4
foodgroup/types.go

@@ -323,8 +323,8 @@ type ProfileManager interface {
 	// that users can associate with their profiles.
 	InterestList(ctx context.Context) ([]wire.ODirKeywordListItem, error)
 
-	// Profile returns the free-form profile body for the given screen name.
-	Profile(ctx context.Context, screenName state.IdentScreenName) (string, error)
+	// Profile returns the user's profile information for the given screen name.
+	Profile(ctx context.Context, screenName state.IdentScreenName) (state.UserProfile, error)
 
 	// SetDirectoryInfo updates the user's directory listing with name, city, state, zip, and country info.
 	SetDirectoryInfo(ctx context.Context, screenName state.IdentScreenName, info state.AIMNameAndAddr) error
@@ -332,8 +332,8 @@ type ProfileManager interface {
 	// SetKeywords sets up to five interest keywords for the user's profile.
 	SetKeywords(ctx context.Context, screenName state.IdentScreenName, keywords [5]string) error
 
-	// SetProfile sets the free-form profile body content for the user.
-	SetProfile(ctx context.Context, screenName state.IdentScreenName, body string) error
+	// SetProfile sets the user's profile information.
+	SetProfile(ctx context.Context, screenName state.IdentScreenName, profile state.UserProfile) error
 
 	// User returns the full user record associated with the given screen name.
 	User(ctx context.Context, screenName state.IdentScreenName) (*state.User, error)

+ 1 - 1
server/http/helpers_test.go

@@ -234,7 +234,7 @@ type profileRetrieverParams struct {
 // ProfileRetriever.Profile call site
 type retrieveProfileParams []struct {
 	screenName state.IdentScreenName
-	result     string
+	result     state.UserProfile
 	err        error
 }
 

+ 1 - 1
server/http/mgmt_api.go

@@ -717,7 +717,7 @@ func getUserAccountHandler(w http.ResponseWriter, r *http.Request, userManager U
 		EmailAddress:    emailAddress,
 		RegStatus:       regStatus,
 		Confirmed:       confirmStatus,
-		Profile:         profile,
+		Profile:         profile.ProfileText,
 		IsICQ:           user.IsICQ,
 		SuspendedStatus: suspendedStatusText,
 		IsBot:           user.IsBot,

+ 3 - 3
server/http/mgmt_api_test.go

@@ -317,7 +317,7 @@ func TestUserAccountHandler_GET(t *testing.T) {
 					retrieveProfileParams: retrieveProfileParams{
 						{
 							screenName: state.NewIdentScreenName("userA"),
-							result:     "My Profile Text",
+							result:     state.UserProfile{ProfileText: "My Profile Text"},
 						},
 					},
 				},
@@ -368,7 +368,7 @@ func TestUserAccountHandler_GET(t *testing.T) {
 					retrieveProfileParams: retrieveProfileParams{
 						{
 							screenName: state.NewIdentScreenName("userA"),
-							result:     "My Profile Text",
+							result:     state.UserProfile{ProfileText: "My Profile Text"},
 						},
 					},
 				},
@@ -418,7 +418,7 @@ func TestUserAccountHandler_GET(t *testing.T) {
 					retrieveProfileParams: retrieveProfileParams{
 						{
 							screenName: state.NewIdentScreenName("userB"),
-							result:     "My Profile Text",
+							result:     state.UserProfile{ProfileText: "My Profile Text"},
 						},
 					},
 				},

+ 7 - 7
server/http/mock_profile_retriever_test.go

@@ -23,22 +23,22 @@ func (_m *mockProfileRetriever) EXPECT() *mockProfileRetriever_Expecter {
 }
 
 // Profile provides a mock function with given fields: ctx, screenName
-func (_m *mockProfileRetriever) Profile(ctx context.Context, screenName state.IdentScreenName) (string, error) {
+func (_m *mockProfileRetriever) Profile(ctx context.Context, screenName state.IdentScreenName) (state.UserProfile, error) {
 	ret := _m.Called(ctx, screenName)
 
 	if len(ret) == 0 {
 		panic("no return value specified for Profile")
 	}
 
-	var r0 string
+	var r0 state.UserProfile
 	var r1 error
-	if rf, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) (string, error)); ok {
+	if rf, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) (state.UserProfile, error)); ok {
 		return rf(ctx, screenName)
 	}
-	if rf, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) string); ok {
+	if rf, ok := ret.Get(0).(func(context.Context, state.IdentScreenName) state.UserProfile); ok {
 		r0 = rf(ctx, screenName)
 	} else {
-		r0 = ret.Get(0).(string)
+		r0 = ret.Get(0).(state.UserProfile)
 	}
 
 	if rf, ok := ret.Get(1).(func(context.Context, state.IdentScreenName) error); ok {
@@ -69,12 +69,12 @@ func (_c *mockProfileRetriever_Profile_Call) Run(run func(ctx context.Context, s
 	return _c
 }
 
-func (_c *mockProfileRetriever_Profile_Call) Return(_a0 string, _a1 error) *mockProfileRetriever_Profile_Call {
+func (_c *mockProfileRetriever_Profile_Call) Return(_a0 state.UserProfile, _a1 error) *mockProfileRetriever_Profile_Call {
 	_c.Call.Return(_a0, _a1)
 	return _c
 }
 
-func (_c *mockProfileRetriever_Profile_Call) RunAndReturn(run func(context.Context, state.IdentScreenName) (string, error)) *mockProfileRetriever_Profile_Call {
+func (_c *mockProfileRetriever_Profile_Call) RunAndReturn(run func(context.Context, state.IdentScreenName) (state.UserProfile, error)) *mockProfileRetriever_Profile_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 2 - 2
server/http/types.go

@@ -110,8 +110,8 @@ type MessageRelayer interface {
 
 // ProfileRetriever defines a method for retrieving a user's free-form profile.
 type ProfileRetriever interface {
-	// Profile returns the free-form profile body for the given screen name.
-	Profile(ctx context.Context, screenName state.IdentScreenName) (string, error)
+	// Profile returns the user's profile information for the given screen name.
+	Profile(ctx context.Context, screenName state.IdentScreenName) (state.UserProfile, error)
 }
 
 // SessionRetriever defines methods for retrieving active sessions,

+ 17 - 9
server/webapi/handlers/presence.go

@@ -29,10 +29,10 @@ type BuddyBroadcaster interface {
 	BroadcastBuddyDeparted(ctx context.Context, sess *state.Session) error
 }
 
-// ProfileManager manages user profiles
+// ProfileManager manages user profiles (uses types.ProfileManager)
 type ProfileManager interface {
-	SetProfile(ctx context.Context, screenName state.IdentScreenName, profile string) error
-	Profile(ctx context.Context, screenName state.IdentScreenName) (string, error)
+	SetProfile(ctx context.Context, screenName state.IdentScreenName, profile state.UserProfile) error
+	Profile(ctx context.Context, screenName state.IdentScreenName) (state.UserProfile, error)
 }
 
 // PresenceData contains presence information.
@@ -511,15 +511,19 @@ func (h *PresenceHandler) SetProfile(w http.ResponseWriter, r *http.Request) {
 	}
 
 	// Get the profile content
-	profile := r.URL.Query().Get("profile")
+	profileText := r.URL.Query().Get("profile")
 
 	// Limit profile size (4KB max)
-	if len(profile) > 4096 {
+	if len(profileText) > 4096 {
 		h.sendError(w, http.StatusBadRequest, "profile too large (max 4KB)")
 		return
 	}
 
 	// Save profile using ProfileManager
+	profile := state.UserProfile{
+		ProfileText: profileText,
+		UpdateTime:  time.Now().UTC(),
+	}
 	if err := h.ProfileManager.SetProfile(ctx, session.ScreenName.IdentScreenName(), profile); err != nil {
 		h.Logger.ErrorContext(ctx, "failed to set profile", "err", err.Error())
 		h.sendError(w, http.StatusInternalServerError, "failed to save profile")
@@ -528,7 +532,7 @@ func (h *PresenceHandler) SetProfile(w http.ResponseWriter, r *http.Request) {
 
 	h.Logger.InfoContext(ctx, "profile updated",
 		"screenName", session.ScreenName.String(),
-		"profileSize", len(profile),
+		"profileSize", len(profileText),
 	)
 
 	// Send success response
@@ -572,14 +576,18 @@ func (h *PresenceHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
 	if err != nil {
 		h.Logger.WarnContext(ctx, "failed to get profile", "err", err.Error())
 		// Return empty profile on error
-		profile = ""
+		profile = state.UserProfile{}
 	}
 
 	// Send response
+	lastUpdated := int64(0)
+	if !profile.UpdateTime.IsZero() {
+		lastUpdated = profile.UpdateTime.Unix()
+	}
 	responseData := map[string]interface{}{
 		"screenName":  targetSN,
-		"profile":     profile,
-		"lastUpdated": time.Now().Unix(),
+		"profile":     profile.ProfileText,
+		"lastUpdated": lastUpdated,
 	}
 
 	response := BaseResponse{}

+ 2 - 2
server/webapi/types.go

@@ -148,8 +148,8 @@ type BuddyBroadcaster interface {
 
 // ProfileManager manages user profiles
 type ProfileManager interface {
-	SetProfile(ctx context.Context, screenName state.IdentScreenName, profile string) error
-	Profile(ctx context.Context, screenName state.IdentScreenName) (string, error)
+	SetProfile(ctx context.Context, screenName state.IdentScreenName, profile state.UserProfile) error
+	Profile(ctx context.Context, screenName state.IdentScreenName) (state.UserProfile, error)
 }
 
 // UserManager defines methods for user authentication.

+ 2 - 0
state/cookie.go

@@ -24,6 +24,8 @@ type ServerCookie struct {
 	ClientID      string            `oscar:"len_prefix=uint8"`
 	ChatCookie    string            `oscar:"len_prefix=uint8"`
 	MultiConnFlag uint8
+	// KerberosAuth indicates whether the client used Kerberos for authentication.
+	KerberosAuth uint8
 }
 
 func NewHMACCookieBaker() (HMACCookieBaker, error) {

+ 6 - 0
state/migrations/0025_profile_mime_type_update_time.down.sql

@@ -0,0 +1,6 @@
+ALTER TABLE profile
+    DROP COLUMN updateTime;
+
+ALTER TABLE profile
+    DROP COLUMN mimeType;
+

+ 6 - 0
state/migrations/0025_profile_mime_type_update_time.up.sql

@@ -0,0 +1,6 @@
+ALTER TABLE profile
+    ADD COLUMN mimeType TEXT;
+
+ALTER TABLE profile
+    ADD COLUMN updateTime INTEGER NOT NULL DEFAULT 0;
+

+ 45 - 0
state/session.go

@@ -62,6 +62,7 @@ type Session struct {
 	lastObservedStates      [5]RateClassState
 	msgCh                   chan wire.SNACMessage
 	multiConnFlag           wire.MultiConnFlag
+	kerberosAuth            bool
 	mutex                   sync.RWMutex
 	nowFn                   func() time.Time
 	rateLimitStates         [5]RateClassState
@@ -77,6 +78,8 @@ type Session struct {
 	warning                 uint16
 	warningCh               chan uint16
 	lastWarnUpdate          time.Time
+	profile                 UserProfile
+	memberSince             time.Time
 }
 
 // NewSession returns a new instance of Session. By default, the user may have
@@ -667,3 +670,45 @@ func (s *Session) MultiConnFlag() wire.MultiConnFlag {
 	defer s.mutex.RUnlock()
 	return s.multiConnFlag
 }
+
+// SetKerberosAuth sets whether Kerberos authentication was used for this session.
+func (s *Session) SetKerberosAuth(enabled bool) {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+	s.kerberosAuth = enabled
+}
+
+// KerberosAuth indicates whether Kerberos authentication was used for this session.
+func (s *Session) KerberosAuth() bool {
+	s.mutex.RLock()
+	defer s.mutex.RUnlock()
+	return s.kerberosAuth
+}
+
+// SetProfile sets the user's profile information.
+func (s *Session) SetProfile(profile UserProfile) {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+	s.profile = profile
+}
+
+// Profile returns the user's profile information.
+func (s *Session) Profile() UserProfile {
+	s.mutex.RLock()
+	defer s.mutex.RUnlock()
+	return s.profile
+}
+
+// SetMemberSince sets the member since timestamp.
+func (s *Session) SetMemberSince(t time.Time) {
+	s.mutex.Lock()
+	defer s.mutex.Unlock()
+	s.memberSince = t
+}
+
+// MemberSince reports when the user became a member.
+func (s *Session) MemberSince() time.Time {
+	s.mutex.RLock()
+	defer s.mutex.RUnlock()
+	return s.memberSince
+}

+ 41 - 0
state/session_test.go

@@ -80,6 +80,17 @@ func TestSession_SetAndGetClientID(t *testing.T) {
 	assert.Equal(t, clientID, s.ClientID())
 }
 
+func TestSession_SetAndGetKerberosAuth(t *testing.T) {
+	s := NewSession()
+	assert.False(t, s.KerberosAuth())
+
+	s.SetKerberosAuth(true)
+	assert.True(t, s.KerberosAuth())
+
+	s.SetKerberosAuth(false)
+	assert.False(t, s.KerberosAuth())
+}
+
 func TestSession_SetAndGetRemoteAddr(t *testing.T) {
 	s := NewSession()
 	assert.Empty(t, s.RemoteAddr())
@@ -644,6 +655,36 @@ func TestSession_SetAndGetLastWarnLevel(t *testing.T) {
 	assert.Equal(t, level, s.Warning())
 }
 
+func TestSession_SetAndGetProfile(t *testing.T) {
+	s := NewSession()
+	profile := s.Profile()
+	assert.Empty(t, profile.ProfileText)
+	assert.Empty(t, profile.MIMEType)
+	assert.True(t, profile.UpdateTime.IsZero())
+
+	profileTime := time.Unix(1234567890, 0)
+	newProfile := UserProfile{
+		ProfileText: "My profile text",
+		MIMEType:    "text/plain",
+		UpdateTime:  profileTime,
+	}
+	s.SetProfile(newProfile)
+	retrievedProfile := s.Profile()
+	assert.Equal(t, newProfile, retrievedProfile)
+	assert.Equal(t, "My profile text", retrievedProfile.ProfileText)
+	assert.Equal(t, "text/plain", retrievedProfile.MIMEType)
+	assert.Equal(t, profileTime, retrievedProfile.UpdateTime)
+}
+
+func TestSession_SetAndGetMemberSince(t *testing.T) {
+	s := NewSession()
+	assert.True(t, s.MemberSince().IsZero())
+
+	memberTime := time.Unix(1234567890, 0)
+	s.SetMemberSince(memberTime)
+	assert.Equal(t, memberTime, s.MemberSince())
+}
+
 func TestSession_ScaleWarningAndRateLimit(t *testing.T) {
 	t.Run("scale up", func(t *testing.T) {
 		classParams := [5]wire.RateClass{

+ 15 - 0
state/user.go

@@ -208,6 +208,21 @@ type User struct {
 	LastWarnLevel uint16
 }
 
+// UserProfile represents a user's profile information.
+type UserProfile struct {
+	// ProfileText is the free-form profile body content.
+	ProfileText string
+	// MIMEType is the MIME type of the profile content.
+	MIMEType string
+	// UpdateTime is when the profile was last updated.
+	UpdateTime time.Time
+}
+
+// Empty returns true if the profile has not been set (all fields are zero values).
+func (p UserProfile) Empty() bool {
+	return p.ProfileText == "" && p.MIMEType == "" && p.UpdateTime.IsZero()
+}
+
 // AIMNameAndAddr holds name and address AIM directory information.
 type AIMNameAndAddr struct {
 	// FirstName is the user's first name.

+ 24 - 11
state/user_store.go

@@ -948,28 +948,41 @@ func (f SQLiteUserStore) RemovePermitBuddy(ctx context.Context, me IdentScreenNa
 	return err
 }
 
-func (f SQLiteUserStore) Profile(ctx context.Context, screenName IdentScreenName) (string, error) {
+func (f SQLiteUserStore) Profile(ctx context.Context, screenName IdentScreenName) (UserProfile, error) {
 	q := `
-		SELECT IFNULL(body, '')
+		SELECT IFNULL(body, ''), IFNULL(mimeType, ''), IFNULL(updateTime, 0)
 		FROM profile
 		WHERE screenName = ?
 	`
-	var profile string
-	err := f.db.QueryRowContext(ctx, q, screenName.String()).Scan(&profile)
-	if err != nil && !errors.Is(err, sql.ErrNoRows) {
-		return "", err
+	var profile UserProfile
+	var updateTimeUnix int64
+	err := f.db.QueryRowContext(ctx, q, screenName.String()).Scan(&profile.ProfileText, &profile.MIMEType, &updateTimeUnix)
+	if errors.Is(err, sql.ErrNoRows) {
+		return UserProfile{}, nil
+	}
+	if err != nil {
+		return UserProfile{}, err
+	}
+	if updateTimeUnix > 0 {
+		profile.UpdateTime = time.Unix(updateTimeUnix, 0).UTC()
 	}
 	return profile, nil
 }
 
-func (f SQLiteUserStore) SetProfile(ctx context.Context, screenName IdentScreenName, body string) error {
+func (f SQLiteUserStore) SetProfile(ctx context.Context, screenName IdentScreenName, profile UserProfile) error {
+	var updateTimeUnix int64
+	if !profile.UpdateTime.IsZero() {
+		updateTimeUnix = profile.UpdateTime.Unix()
+	}
 	q := `
-		INSERT INTO profile (screenName, body)
-		VALUES (?, ?)
+		INSERT INTO profile (screenName, body, mimeType, updateTime)
+		VALUES (?, ?, ?, ?)
 		ON CONFLICT (screenName)
-			DO UPDATE SET body = excluded.body
+			DO UPDATE SET body = excluded.body,
+			              mimeType = excluded.mimeType,
+			              updateTime = excluded.updateTime
 	`
-	_, err := f.db.ExecContext(ctx, q, screenName.String(), body)
+	_, err := f.db.ExecContext(ctx, q, screenName.String(), profile.ProfileText, profile.MIMEType, updateTimeUnix)
 	return err
 }
 

+ 93 - 8
state/user_store_test.go

@@ -255,11 +255,15 @@ func TestProfile(t *testing.T) {
 		t.Fatalf("failed to retrieve profile: %s", err.Error())
 	}
 
-	if profile != "" {
+	if !profile.Empty() {
 		t.Fatalf("expected empty profile for %s", screenName)
 	}
 
-	newProfile := "here is my profile"
+	newProfile := UserProfile{
+		ProfileText: "here is my profile",
+		MIMEType:    "text/plain",
+		UpdateTime:  time.Now().UTC().Truncate(time.Second),
+	}
 	if err := f.SetProfile(context.Background(), screenName, newProfile); err != nil {
 		t.Fatalf("failed to create new profile: %s", err.Error())
 	}
@@ -269,11 +273,21 @@ func TestProfile(t *testing.T) {
 		t.Fatalf("failed to retrieve profile: %s", err.Error())
 	}
 
-	if !reflect.DeepEqual(newProfile, profile) {
-		t.Fatalf("profiles did not match:\n expected: %v\n actual: %v", newProfile, profile)
+	if profile.ProfileText != newProfile.ProfileText {
+		t.Fatalf("profiles did not match:\n expected: %v\n actual: %v", newProfile.ProfileText, profile.ProfileText)
+	}
+	if profile.MIMEType != newProfile.MIMEType {
+		t.Fatalf("mime types did not match:\n expected: %v\n actual: %v", newProfile.MIMEType, profile.MIMEType)
+	}
+	if !profile.UpdateTime.Equal(newProfile.UpdateTime) {
+		t.Fatalf("update times did not match:\n expected: %v\n actual: %v", newProfile.UpdateTime, profile.UpdateTime)
 	}
 
-	updatedProfile := "here is my profile [updated]"
+	updatedProfile := UserProfile{
+		ProfileText: "here is my profile [updated]",
+		MIMEType:    "text/html",
+		UpdateTime:  time.Now().UTC().Truncate(time.Second),
+	}
 	if err := f.SetProfile(context.Background(), screenName, updatedProfile); err != nil {
 		t.Fatalf("failed to create new profile: %s", err.Error())
 	}
@@ -283,8 +297,14 @@ func TestProfile(t *testing.T) {
 		t.Fatalf("failed to retrieve profile: %s", err.Error())
 	}
 
-	if !reflect.DeepEqual(updatedProfile, profile) {
-		t.Fatalf("updated profiles did not match:\n expected: %v\n actual: %v", newProfile, profile)
+	if profile.ProfileText != updatedProfile.ProfileText {
+		t.Fatalf("updated profiles did not match:\n expected: %v\n actual: %v", updatedProfile.ProfileText, profile.ProfileText)
+	}
+	if profile.MIMEType != updatedProfile.MIMEType {
+		t.Fatalf("updated mime types did not match:\n expected: %v\n actual: %v", updatedProfile.MIMEType, profile.MIMEType)
+	}
+	if !profile.UpdateTime.Equal(updatedProfile.UpdateTime) {
+		t.Fatalf("updated update times did not match:\n expected: %v\n actual: %v", updatedProfile.UpdateTime, profile.UpdateTime)
 	}
 }
 
@@ -301,7 +321,72 @@ func TestProfileNonExistent(t *testing.T) {
 
 	prof, err := f.Profile(context.Background(), screenName)
 	assert.NoError(t, err)
-	assert.Empty(t, prof)
+	assert.True(t, prof.Empty())
+	assert.Equal(t, "", prof.MIMEType)
+	assert.True(t, prof.UpdateTime.IsZero())
+}
+
+func TestProfile_MimeTypeAndUpdateTime(t *testing.T) {
+	screenName := NewIdentScreenName("testuser")
+
+	defer func() {
+		assert.NoError(t, os.Remove(testFile))
+	}()
+
+	f, err := NewSQLiteUserStore(testFile)
+	require.NoError(t, err)
+
+	u := User{
+		IdentScreenName: screenName,
+	}
+	require.NoError(t, f.InsertUser(context.Background(), u))
+
+	t.Run("profile with empty mimeType and zero updateTime", func(t *testing.T) {
+		profile := UserProfile{
+			ProfileText: "test profile",
+			MIMEType:    "",
+			UpdateTime:  time.Time{},
+		}
+		require.NoError(t, f.SetProfile(context.Background(), screenName, profile))
+
+		retrieved, err := f.Profile(context.Background(), screenName)
+		require.NoError(t, err)
+		assert.Equal(t, profile.ProfileText, retrieved.ProfileText)
+		assert.Equal(t, "", retrieved.MIMEType)
+		assert.True(t, retrieved.UpdateTime.IsZero())
+	})
+
+	t.Run("profile with mimeType and updateTime", func(t *testing.T) {
+		updateTime := time.Date(2024, 1, 15, 10, 30, 0, 0, time.UTC)
+		profile := UserProfile{
+			ProfileText: "test profile with mime",
+			MIMEType:    "text/html",
+			UpdateTime:  updateTime,
+		}
+		require.NoError(t, f.SetProfile(context.Background(), screenName, profile))
+
+		retrieved, err := f.Profile(context.Background(), screenName)
+		require.NoError(t, err)
+		assert.Equal(t, profile.ProfileText, retrieved.ProfileText)
+		assert.Equal(t, "text/html", retrieved.MIMEType)
+		assert.True(t, retrieved.UpdateTime.Equal(updateTime), "expected %v, got %v", updateTime, retrieved.UpdateTime)
+	})
+
+	t.Run("update profile with different mimeType and updateTime", func(t *testing.T) {
+		updateTime := time.Date(2024, 2, 20, 14, 45, 0, 0, time.UTC)
+		profile := UserProfile{
+			ProfileText: "updated profile",
+			MIMEType:    "text/plain",
+			UpdateTime:  updateTime,
+		}
+		require.NoError(t, f.SetProfile(context.Background(), screenName, profile))
+
+		retrieved, err := f.Profile(context.Background(), screenName)
+		require.NoError(t, err)
+		assert.Equal(t, profile.ProfileText, retrieved.ProfileText)
+		assert.Equal(t, "text/plain", retrieved.MIMEType)
+		assert.True(t, retrieved.UpdateTime.Equal(updateTime), "expected %v, got %v", updateTime, retrieved.UpdateTime)
+	})
 }
 
 func TestGetUser(t *testing.T) {

+ 3 - 0
wire/snacs.go

@@ -177,13 +177,16 @@ const (
 	OServiceUserInfoUserFlags       uint16 = 0x01
 	OServiceUserInfoSignonTOD       uint16 = 0x03
 	OServiceUserInfoIdleTime        uint16 = 0x04
+	OServiceUserInfoMemberSince     uint16 = 0x05
 	OServiceUserInfoStatus          uint16 = 0x06
 	OServiceUserInfoICQDC           uint16 = 0x0C
 	OServiceUserInfoOscarCaps       uint16 = 0x0D
+	OServiceUserInfoOnlineTime      uint16 = 0x0F
 	OServiceUserInfoBARTInfo        uint16 = 0x1D
 	OServiceUserInfoMySubscriptions uint16 = 0x1E
 	OServiceUserInfoUserFlags2      uint16 = 0x1F
 	OServiceUserInfoMyInstanceNum   uint16 = 0x14
+	OServiceUserInfoSigTime         uint16 = 0x26
 	OServiceUserInfoPrimaryInstance uint16 = 0x28
 
 	OServiceUserStatusAvailable         uint32 = 0x00000000 // user is available