Refresh exchanges a refresh token for new access and refresh tokens.
(ctx context.Context, req *connect.Request[v1pb.RefreshRequest])
| 426 | |
| 427 | // Refresh exchanges a refresh token for new access and refresh tokens. |
| 428 | func (s *AuthService) Refresh(ctx context.Context, req *connect.Request[v1pb.RefreshRequest]) (*connect.Response[v1pb.RefreshResponse], error) { |
| 429 | // 1. Extract refresh token from cookie |
| 430 | refreshToken := auth.GetRefreshTokenFromCookie(req.Header()) |
| 431 | if refreshToken == "" { |
| 432 | return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("refresh token not found")) |
| 433 | } |
| 434 | |
| 435 | // 2. Look up and delete atomically |
| 436 | tokenHash := auth.HashToken(refreshToken) |
| 437 | stored, err := s.store.GetAndDeleteWebRefreshToken(ctx, tokenHash) |
| 438 | if err != nil { |
| 439 | return nil, connect.NewError(connect.CodeInternal, errors.Wrap(err, "failed to get refresh token")) |
| 440 | } |
| 441 | if stored == nil { |
| 442 | return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("invalid refresh token")) |
| 443 | } |
| 444 | |
| 445 | // 3. Check expiration |
| 446 | if time.Now().After(stored.ExpiresAt) { |
| 447 | return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("refresh token expired")) |
| 448 | } |
| 449 | |
| 450 | // 4. Get user |
| 451 | user, err := s.store.GetUserByEmail(ctx, stored.UserEmail) |
| 452 | if err != nil { |
| 453 | return nil, connect.NewError(connect.CodeInternal, errors.Wrap(err, "failed to get user")) |
| 454 | } |
| 455 | if user == nil || user.MemberDeleted { |
| 456 | return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("user not found")) |
| 457 | } |
| 458 | |
| 459 | // 5. Extract workspace from the access token cookie (still present because cookie |
| 460 | // outlives the JWT by 30 seconds). This ensures per-session workspace isolation. |
| 461 | // Also verify the token's subject matches the refresh token's user to prevent |
| 462 | // pairing a refresh token with an access token from a different session. |
| 463 | accessTokenStr, err := auth.GetTokenFromHeaders(req.Header()) |
| 464 | if err != nil { |
| 465 | return nil, connect.NewError(connect.CodeUnauthenticated, errors.Wrap(err, "invalid access token header")) |
| 466 | } |
| 467 | if accessTokenStr == "" { |
| 468 | return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("access token cookie required for refresh")) |
| 469 | } |
| 470 | tokenClaims, err := auth.ExtractClaimsFromExpiredToken(accessTokenStr, s.secret) |
| 471 | if err != nil || tokenClaims.WorkspaceID == "" { |
| 472 | return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("failed to extract workspace from access token")) |
| 473 | } |
| 474 | if tokenClaims.Subject != stored.UserEmail { |
| 475 | return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("access token does not match refresh token")) |
| 476 | } |
| 477 | workspaceID := tokenClaims.WorkspaceID |
| 478 | |
| 479 | // Verify the user is still a member of the workspace. |
| 480 | ws, err := s.store.FindWorkspace(ctx, &store.FindWorkspaceMessage{ |
| 481 | WorkspaceID: &workspaceID, |
| 482 | Email: user.Email, |
| 483 | IncludeAllUser: !s.profile.SaaS, |
| 484 | }) |
| 485 | if err != nil { |
nothing calls this directly
no test coverage detected