fix: account link proof by auth_time, code in the URL fragment and POST show

- BackendLinkConfirm reads the session's auth_time (bouncer.VerifyAuthTime);
  a token without it, or a refreshed old session, answers 409 stale_session
- POST /_user/api/v1/backend-link/show takes the code in the body; GET
  /backend-link/{code} stays, deprecated; both answer Cache-Control no-store
- My account answers <frontend_url><page_path>#code=<code>; new key
  golem15.user.backend_links.page_path (default /link-admin)
- TestBackendLink now crafts auth_time tokens; TestBackendLinkAuthTimeSmoke
This commit is contained in:
Jakub Zych
2026-10-23 16:38:55 +02:00
parent 72de822a84
commit 32f92591d6
10 changed files with 225 additions and 44 deletions

View File

@@ -71,6 +71,27 @@ func BackendLinkRequestTTL(app *backpack.App) (time.Duration, error) {
return ttl, nil
}
// DefaultBackendLinkPagePath is the site page the account link points to when
// golem15.user.backend_links.page_path is not set.
const DefaultBackendLinkPagePath = "/link-admin"
// BackendLinkPagePath reads golem15.user.backend_links.page_path, the path of
// the site page that confirms an account link (default /link-admin). A value
// without a leading slash gets one.
func BackendLinkPagePath(app *backpack.App) string {
if app == nil || app.Config == nil {
return DefaultBackendLinkPagePath
}
path := strings.TrimSpace(app.Config.String("golem15.user.backend_links.page_path"))
if path == "" {
return DefaultBackendLinkPagePath
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return path
}
// FrontendURL is the site the plugin sends people to for pages it does not
// serve itself (the account link page): golem15.user.frontend_url, falling
// back to app.url, without a trailing slash. Empty when neither is set.
@@ -203,14 +224,17 @@ func requestUsable(req *models.BackendLinkRequest, now time.Time) error {
// ConfirmLinkRequest stores the link between the signed-in frontend user and
// the administrator who created the request, in one transaction: the request
// row is locked, must be unused and unexpired, and the session must have
// been signed in at or after the request was created (sessionIssuedAt is
// the JWT iat, compared in whole seconds because iat has no fraction), so a
// browser that was already signed in has to sign in again (D-05). Neither
// been signed in at or after the request was created, so a browser that was
// already signed in has to sign in again (D-05). sessionAuthTime is the
// session token's auth_time claim (bouncer.VerifyAuthTime): the time of the
// sign-in itself, which a refresh copies and never renews. It is compared in
// whole seconds with the request's created_at because the claim has no
// fraction; a zero time is ErrStaleSession. Neither
// account may be linked already (ErrAlreadyLinked) and the administrator
// must still be activated (ErrBackendUserUnavailable). The request is used
// up with the link; BackendLinkedEvent fires inside the transaction and the
// two pact.MCPSubjectChanged events after it commits.
func (s *BackendLinks) ConfirmLinkRequest(ctx context.Context, plain string, frontendUserID uint, sessionIssuedAt time.Time) (*models.BackendLink, error) {
func (s *BackendLinks) ConfirmLinkRequest(ctx context.Context, plain string, frontendUserID uint, sessionAuthTime time.Time) (*models.BackendLink, error) {
if !validLinkCode(plain) {
return nil, ErrLinkRequestNotFound
}
@@ -236,7 +260,7 @@ func (s *BackendLinks) ConfirmLinkRequest(ctx context.Context, plain string, fro
if err := requestUsable(req, now); err != nil {
return err
}
if sessionIssuedAt.IsZero() || sessionIssuedAt.Before(req.CreatedAt.Truncate(time.Second)) {
if sessionAuthTime.IsZero() || sessionAuthTime.Before(req.CreatedAt.Truncate(time.Second)) {
return ErrStaleSession
}
if _, err := activeBackendUser(ctx, tx, req.BackendUserID); err != nil {