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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## Unreleased


### Bug Fixes

* **dash0:** landing on an organization you cannot use now takes you to one you can, instead of a dead end. Following a link, a bookmark or an old email to an organization you are not a member of used to answer 403 on every request and show "Permission Denied" — whose only button linked back to the same organization you could not reach. You are now sent to your own organization (the one this browser used last, else your first membership) with a short note saying so, and to the "no organization" screen only if you genuinely have none. Administrators with cross-organization access are unaffected.
* **demo:** entering the live demo from another organization's login page no longer strands you in *that* organization. The "Try the live demo" button raced its own sign-in — whichever navigation committed last won, and the losing one left you on an organization the demo account is not a member of, staring at Permission Denied. A returning visitor who still held a demo session hit the same wall just by opening an ordinary login link, with no race involved.
* **auth:** signing in with a two-factor code now returns the same information as every other way of signing in. It was the one login path that omitted the caller's organization list, which briefly read as "this account belongs to no organization" and could flash the no-organization screen on the way to the dashboard.

## [0.26.0](https://github.com/fclairamb/solidping/compare/v0.25.1...v0.26.0) (2026-09-08)


Expand Down
72 changes: 72 additions & 0 deletions server/internal/handlers/auth/login_organizations_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package auth

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/fclairamb/solidping/server/internal/db/models"
)

// slugsOf projects a login response's organization list down to slugs.
func slugsOf(summaries []OrganizationSummary) []string {
slugs := make([]string, 0, len(summaries))
for _, summary := range summaries {
slugs = append(slugs, summary.Slug)
}

return slugs
}

// TestCompleteLoginAfter2FAReturnsOrganizations pins the login response SHAPE
// for the 2FA path.
//
// Every other completed login hands the caller its full organization list —
// completeLogin gets it from resolveOrgPreference (password, LDAP, OAuth,
// passkey), and the invitation accept re-reads it. 2FA returned none: the
// summaries resolveOrgPreference computed before the challenge were dropped
// with everything else the temp token does not carry.
//
// That is not cosmetic. The dashboard picks which org a fresh session may land
// on from this list, so an empty one reads as "this user belongs to no
// organization at all" — a TOTP user was bounced through the no-organization
// screen until a follow-up /auth/me landed and corrected it.
func TestCompleteLoginAfter2FAReturnsOrganizations(t *testing.T) {
t.Parallel()

fixture, ctx := newLoginAuditFixture(t)
user := fixture.user(ctx, t, "totp-orgs@unknown.example")

// A SECOND membership, so the assertion proves the response carries the
// user's whole list rather than merely echoing the org being signed into —
// which a response built from `orgSlug` alone would also satisfy.
second := models.NewOrganization("acmesecond", "Acme Second")
require.NoError(t, fixture.db.CreateOrganization(ctx, second))
require.NoError(t, fixture.db.CreateOrganizationMember(
ctx, models.NewOrganizationMember(second.UID, user.UID, models.MemberRoleUser)))

want := []string{fixture.org.Slug, second.Slug}

t.Run("signing into a resolved org", func(t *testing.T) {
t.Parallel()

resp, err := fixture.svc.completeLoginAfter2FA(ctx, user, fixture.org.Slug,
string(models.MemberRoleAdmin),
withSecondFactor(AuthMethodPassword, SecondFactorTOTP), Context{})
require.NoError(t, err)
require.ElementsMatch(t, want, slugsOf(resp.Organizations))
})

t.Run("recovery code, no org on the temp token", func(t *testing.T) {
t.Parallel()

// The no-org branch mints an access token with an empty org claim. It
// must still describe the memberships, otherwise the client has
// nothing to offer the user but the org-less screen.
resp, err := fixture.svc.completeLoginAfter2FA(ctx, user, "",
string(models.MemberRoleAdmin),
withSecondFactor(AuthMethodPassword, SecondFactorRecoveryCode), Context{})
require.NoError(t, err)
require.ElementsMatch(t, want, slugsOf(resp.Organizations))
})
}
43 changes: 33 additions & 10 deletions server/internal/handlers/auth/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -4270,6 +4270,27 @@ func (s *Service) completeLoginAfter2FA(

userInfo := newUserInfo(user, role)

// Every OTHER login shape carries the caller's full organization list:
// completeLogin gets it from resolveOrgPreference (password, LDAP, OAuth,
// passkey) and the invitation accept re-reads it here. 2FA was the one
// path that returned none, because resolveOrgPreference ran before the
// challenge and its summaries were dropped along with everything else the
// temp token does not carry.
//
// That asymmetry is load-bearing on the client: the dashboard decides
// which org a fresh session may land on from this list, and an absent one
// reads as "this user belongs to no organization at all" until a
// follow-up /auth/me lands. One shape for every login path.
orgSummaries, orgsErr := s.getOrganizationsForUser(ctx, user.UID)
if orgsErr != nil {
// Decorative, and the client refetches from /auth/me when the payload
// carries none — never worth failing an otherwise valid login for.
slog.ErrorContext(ctx, "Failed to list organizations after 2FA login",
"error", orgsErr, "userUID", user.UID)

orgSummaries = nil
}

// No org case
if orgSlug == "" {
accessToken, tokenErr := s.generateAccessToken(user.UID, "", role, "", user.Demo)
Expand All @@ -4278,10 +4299,11 @@ func (s *Service) completeLoginAfter2FA(
}

return &LoginResponse{
AccessToken: accessToken,
ExpiresIn: int(s.cfg.AccessTokenExpiry.Seconds()),
TokenType: tokenTypeBearer,
User: userInfo,
AccessToken: accessToken,
ExpiresIn: int(s.cfg.AccessTokenExpiry.Seconds()),
TokenType: tokenTypeBearer,
User: userInfo,
Organizations: orgSummaries,
}, nil
}

Expand Down Expand Up @@ -4320,11 +4342,12 @@ func (s *Service) completeLoginAfter2FA(
}

return &LoginResponse{
AccessToken: accessToken,
RefreshToken: refreshTokenValue,
ExpiresIn: int(s.cfg.AccessTokenExpiry.Seconds()),
TokenType: tokenTypeBearer,
User: userInfo,
Organization: newOrganizationInfo(org),
AccessToken: accessToken,
RefreshToken: refreshTokenValue,
ExpiresIn: int(s.cfg.AccessTokenExpiry.Seconds()),
TokenType: tokenTypeBearer,
User: userInfo,
Organization: newOrganizationInfo(org),
Organizations: orgSummaries,
}, nil
}
Loading
Loading