@@ -1178,216 +1178,6 @@ func (r *credentialReader) Read(p []byte) (n int, err error) {
11781178 return n , nil
11791179}
11801180
1181- // =============================================================================
1182- // Tenant-scoped WebAuthn methods
1183- // =============================================================================
1184-
1185- // BeginTenantLogin starts WebAuthn login for a specific tenant
1186- func (s * WebAuthnService ) BeginTenantLogin (ctx context.Context , tenantID domain.TenantID ) (* BeginLoginResponse , error ) {
1187- // Generate login options without credentials (discoverable credentials mode)
1188- _ , session , err := s .webauthn .BeginDiscoverableLogin ()
1189- if err != nil {
1190- s .logger .Error ("Failed to begin tenant login" , zap .Error (err ))
1191- return nil , fmt .Errorf ("failed to begin login: %w" , err )
1192- }
1193-
1194- // Store challenge with tenant ID
1195- challengeID := generateChallengeID ()
1196- challenge := & domain.WebauthnChallenge {
1197- ID : challengeID ,
1198- TenantID : string (tenantID ),
1199- Challenge : session .Challenge ,
1200- Action : "login" ,
1201- ExpiresAt : time .Now ().Add (5 * time .Minute ),
1202- }
1203-
1204- if err := s .store .Challenges ().Create (ctx , challenge ); err != nil {
1205- s .logger .Error ("Failed to store challenge" , zap .Error (err ))
1206- return nil , fmt .Errorf ("failed to store challenge: %w" , err )
1207- }
1208-
1209- // Build response
1210- challengeBytes , err := base64 .RawURLEncoding .DecodeString (session .Challenge )
1211- if err != nil {
1212- return nil , fmt .Errorf ("failed to decode challenge: %w" , err )
1213- }
1214-
1215- getOptions := GetOptionsResponse {
1216- PublicKey : PublicKeyCredentialRequestOptions {
1217- Challenge : challengeBytes ,
1218- RPId : s .cfg .Server .RPID ,
1219- UserVerification : protocol .VerificationRequired ,
1220- AllowCredentials : []PublicKeyCredentialDescriptor {},
1221- },
1222- }
1223-
1224- return & BeginLoginResponse {
1225- ChallengeID : challengeID ,
1226- GetOptions : getOptions ,
1227- }, nil
1228- }
1229-
1230- // FinishTenantLogin completes WebAuthn login for a specific tenant
1231- // It validates that the user handle contains the expected tenant ID prefix
1232- func (s * WebAuthnService ) FinishTenantLogin (ctx context.Context , tenantID domain.TenantID , req * FinishLoginRequest ) (* FinishLoginResponse , error ) {
1233- // Retrieve challenge
1234- challenge , err := s .store .Challenges ().GetByID (ctx , req .ChallengeID )
1235- if err != nil {
1236- if errors .Is (err , storage .ErrNotFound ) {
1237- return nil , ErrChallengeNotFound
1238- }
1239- return nil , fmt .Errorf ("failed to retrieve challenge: %w" , err )
1240- }
1241-
1242- // Check expiration
1243- if time .Now ().After (challenge .ExpiresAt ) {
1244- return nil , ErrChallengeExpired
1245- }
1246-
1247- // Verify tenant matches
1248- if challenge .TenantID != string (tenantID ) {
1249- s .logger .Warn ("Tenant mismatch in login challenge" ,
1250- zap .String ("expected" , string (tenantID )),
1251- zap .String ("actual" , challenge .TenantID ))
1252- return nil , ErrTenantMismatch
1253- }
1254-
1255- // Parse credential response
1256- reader := newCredentialReader (req .Credential )
1257- parsedResponse , err := protocol .ParseCredentialRequestResponseBody (reader )
1258- if err != nil {
1259- s .logger .Error ("Failed to parse credential" , zap .Error (err ))
1260- return nil , fmt .Errorf ("failed to parse credential: %w" , err )
1261- }
1262-
1263- // Decode user ID from user handle
1264- // The new binary format (v1) doesn't include recoverable tenant ID,
1265- // so we validate tenant membership after looking up the user.
1266- userHandle := parsedResponse .Response .UserHandle
1267- userID , err := domain .UserIDFromHandle (userHandle )
1268- if err != nil {
1269- // For backward compatibility, try treating the user handle as just a user ID
1270- s .logger .Debug ("User handle decode failed, treating as legacy user" ,
1271- zap .String ("user_handle" , string (userHandle )),
1272- zap .Error (err ))
1273- userID = domain .UserIDFromUserHandle (userHandle )
1274- }
1275-
1276- // Look up user
1277- user , err := s .store .Users ().GetByID (ctx , userID )
1278- if err != nil {
1279- if errors .Is (err , storage .ErrNotFound ) {
1280- return nil , ErrUserNotFound
1281- }
1282- return nil , fmt .Errorf ("failed to retrieve user: %w" , err )
1283- }
1284-
1285- // Get the user's actual tenant memberships for redirect support
1286- userTenants , err := s .store .UserTenants ().GetUserTenants (ctx , userID )
1287- if err != nil {
1288- s .logger .Warn ("Failed to get user tenant memberships" , zap .Error (err ))
1289- userTenants = []domain.TenantID {}
1290- }
1291-
1292- // Verify the user is a member of the requested tenant
1293- isMember := false
1294- for _ , tid := range userTenants {
1295- if tid == tenantID {
1296- isMember = true
1297- break
1298- }
1299- }
1300-
1301- if ! isMember {
1302- // User is not a member of the requested tenant.
1303- // Return a redirect error with the correct tenant if available.
1304- s .logger .Warn ("User not a member of requested tenant" ,
1305- zap .String ("user_id" , userID .String ()),
1306- zap .String ("requested_tenant" , string (tenantID )),
1307- zap .Any ("user_tenants" , userTenants ))
1308-
1309- if len (userTenants ) > 0 {
1310- // Return redirect error with the user's actual tenant
1311- return nil , & TenantRedirectError {
1312- CorrectTenantID : userTenants [0 ],
1313- UserID : userID ,
1314- }
1315- }
1316- // User has no tenant memberships - this shouldn't happen normally
1317- return nil , ErrTenantMismatch
1318- }
1319-
1320- // Find the credential
1321- // Encode RawID to base64url to match the format used during credential storage
1322- credentialID := base64 .RawURLEncoding .EncodeToString (parsedResponse .RawID )
1323- var foundCred * domain.WebauthnCredential
1324- for i := range user .WebauthnCredentials {
1325- if user .WebauthnCredentials [i ].ID == credentialID {
1326- foundCred = & user .WebauthnCredentials [i ]
1327- break
1328- }
1329- }
1330-
1331- if foundCred == nil {
1332- return nil , ErrCredentialNotFound
1333- }
1334-
1335- // Verify with the correct tenant-scoped user handle
1336- waUser := & TenantWebAuthnUser {user : user , userHandle : domain .EncodeUserHandle (tenantID , userID )}
1337- sessionData := webauthn.SessionData {
1338- Challenge : challenge .Challenge ,
1339- RelyingPartyID : s .cfg .Server .RPID ,
1340- AllowedCredentialIDs : [][]byte {},
1341- Expires : challenge .ExpiresAt ,
1342- UserVerification : protocol .VerificationRequired ,
1343- // UserID intentionally left empty for discoverable login
1344- }
1345-
1346- // Verify credential
1347- _ , err = s .webauthn .ValidateDiscoverableLogin (func (rawID , userHandle []byte ) (webauthn.User , error ) {
1348- return waUser , nil
1349- }, sessionData , parsedResponse )
1350-
1351- if err != nil {
1352- s .logger .Error ("Failed to verify credential" , zap .Error (err ))
1353- return nil , ErrVerificationFailed
1354- }
1355-
1356- // Generate token with tenant_id included for security boundary
1357- token , err := s .generateToken (user , tenantID )
1358- if err != nil {
1359- return nil , fmt .Errorf ("failed to generate token: %w" , err )
1360- }
1361-
1362- // Clean up challenge
1363- _ = s .store .Challenges ().Delete (ctx , req .ChallengeID )
1364-
1365- // Get tenant display name for the response
1366- var tenantDisplayName string
1367- if tenant , err := s .store .Tenants ().GetByID (ctx , tenantID ); err == nil {
1368- tenantDisplayName = tenant .DisplayName
1369- }
1370-
1371- s .logger .Info ("Completed tenant login" ,
1372- zap .String ("user_id" , userID .String ()),
1373- zap .String ("tenant_id" , string (tenantID )))
1374-
1375- displayName := ""
1376- if user .DisplayName != nil {
1377- displayName = * user .DisplayName
1378- }
1379-
1380- return & FinishLoginResponse {
1381- UUID : userID .String (),
1382- Token : token ,
1383- DisplayName : displayName ,
1384- PrivateData : user .PrivateData ,
1385- WebauthnRpId : s .cfg .Server .RPID ,
1386- TenantID : string (tenantID ),
1387- TenantDisplayName : tenantDisplayName ,
1388- }, nil
1389- }
1390-
13911181// TenantWebAuthnUser wraps a user with a tenant-scoped user handle
13921182type TenantWebAuthnUser struct {
13931183 user * domain.User
0 commit comments